diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62bd16be..a3aac474 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.9, '3.10', 3.11, 3.12, 3.13, 3.14] + python-version: ['3.10', 3.11, 3.12, 3.13, 3.14] framework: - NONE - FLASK_VERSION=2.3.3 @@ -23,12 +23,6 @@ jobs: - PYRAMID_VERSION=2.0.2 - FASTAPI_VERSION=0.115.1 httpx==0.27.2 python-multipart==0.0.12 - FASTAPI_VERSION=0.118.3 httpx==0.28.1 python-multipart==0.0.20 - exclude: - # Test frameworks on the python versions they support, according to pypi registry - - # Django - - framework: DJANGO_VERSION=5.2.7 - python-version: 3.9 steps: - uses: actions/checkout@v2 @@ -73,3 +67,28 @@ jobs: - name: Run tests run: pytest + + types: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', 3.11, 3.12, 3.13, 3.14] + + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Dependencies + run: | + pip install --group test . + pip install --group type . + + + - name: Check Types + run: mypy diff --git a/README.md b/README.md index f39d0fde..0db11e4c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Python notifier for reporting exceptions, errors, and log messages to [Rollbar]( | PyRollbar Version | Python Version Compatibility | Support Level | |-------------------|-----------------------------------------------|---------------------| -| 1.4.0 | 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 | Full | +| 1.4.0 | 3.10, 3.11, 3.12, 3.13, 3.14 | Full | | 0.16.3 | 2.7, 3.4, 3.5, 3.6, 3.7. 3.8, 3.9, 3.10, 3.11 | Security Fixes Only | #### Support Level Definitions diff --git a/pyproject.toml b/pyproject.toml index 5b546afd..28e99df2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ maintainers = [{name = "Rollbar, Inc.", email = "support@rollbar.com"}] classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -39,9 +38,10 @@ classifiers = [ "Topic :: System :: Logging", "Topic :: System :: Monitoring", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "requests>=0.12.1", + "typing_extensions; python_version < \"3.11\"" ] [dependency-groups] @@ -49,9 +49,25 @@ test = [ "blinker", "httpx", "pytest", + "python-multipart", "webob", ] +type = [ + "bottle", + "djangorestframework-stubs[compatible-mypy]", + "django-stubs", + "fastapi", + "mypy ~= 1.20.2", + "pyramid", + "quart", + "sanic", + "starlette", + "tornado", + "twisted", + "uvicorn", +] + [project.urls] Homepage = "https://rollbar.com/" Documentation = "https://docs.rollbar.com/docs/python" @@ -64,6 +80,9 @@ rollbar = "rollbar.cli:main" [project.entry-points."paste.filter_app_factory"] pyramid = "rollbar.contrib.pyramid:create_rollbar_middleware" +[tool.mypy] +packages = ["rollbar"] + [tool.pytest] testpaths = [ "rollbar/test", diff --git a/rollbar/__init__.py b/rollbar/__init__.py index 87f1769d..172cc365 100644 --- a/rollbar/__init__.py +++ b/rollbar/__init__.py @@ -17,22 +17,37 @@ import wsgiref.util import warnings import queue +from typing import Any, Callable, TypedDict, Literal, cast, Optional, TYPE_CHECKING from urllib.parse import parse_qs, urljoin -import requests +try: + # Python 3.11+ + # This is ignored for mypy to be happy with Python versions before 3.11. + from typing import Unpack # type: ignore +except ImportError: + # Python 3.10 + from typing_extensions import Unpack + +import requests # type: ignore[import-untyped] from rollbar.lib import events, filters, dict_merge, transport, defaultJSONEncode -from rollbar.lib.payload import Attribute from rollbar.lib.session import get_current_session, set_current_session, parse_session_request_baggage_headers +if TYPE_CHECKING: + from rollbar.lib.payload import Attribute + from rollbar.lib.type_info import KeyType + __version__ = '1.4.0-beta' __log_name__ = 'rollbar' + +from rollbar.lib.transform import Transform + log = logging.getLogger(__log_name__) # import request objects from various frameworks, if available try: - from webob import BaseRequest as WebobBaseRequest + from webob import BaseRequest as WebobBaseRequest # type: ignore[import-untyped] except ImportError: WebobBaseRequest = None @@ -44,61 +59,62 @@ else: try: - from django.http import HttpRequest as DjangoHttpRequest + from django.http import HttpRequest as DjangoHttpRequest # type: ignore[assignment] except (ImportError, ImproperlyConfigured): - DjangoHttpRequest = None + DjangoHttpRequest = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: - from rest_framework.request import Request as RestFrameworkRequest + from rest_framework.request import Request as RestFrameworkRequest # type: ignore[assignment, no-redef] except (ImportError, ImproperlyConfigured): - RestFrameworkRequest = None + RestFrameworkRequest = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. del ImproperlyConfigured try: from werkzeug.wrappers import Request as WerkzeugRequest except (ImportError, SyntaxError): - WerkzeugRequest = None + WerkzeugRequest = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: from werkzeug.local import LocalProxy as WerkzeugLocalProxy except (ImportError, SyntaxError): - WerkzeugLocalProxy = None + WerkzeugLocalProxy = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: - from tornado.httpserver import HTTPRequest as TornadoRequest + from tornado.httpserver import HTTPRequest as TornadoRequest # type: ignore[import-untyped] except ImportError: - TornadoRequest = None + TornadoRequest = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: - from bottle import BaseRequest as BottleRequest + from bottle import BaseRequest as BottleRequest # type: ignore[import-untyped] except ImportError: - BottleRequest = None + BottleRequest = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: from sanic.request import Request as SanicRequest except ImportError: - SanicRequest = None + SanicRequest = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: - from google.appengine.api.urlfetch import fetch as AppEngineFetch + from google.appengine.api.urlfetch import fetch as AppEngineFetch # type: ignore[import-untyped, import-not-found] except (ImportError, KeyError): - AppEngineFetch = None + AppEngineFetch = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: - from starlette.requests import Request as StarletteRequest + from starlette.requests import Request as StarletteRequest, State as StarletteState except ImportError: - StarletteRequest = None + StarletteRequest = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. + StarletteState = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: from fastapi.requests import Request as FastAPIRequest except ImportError: - FastAPIRequest = None + FastAPIRequest = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: import httpx except ImportError: - httpx = None + httpx = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. AsyncHTTPClient = httpx @@ -110,16 +126,16 @@ def wrap(*args, **kwargs): try: from tornado.httpclient import AsyncHTTPClient as TornadoAsyncHTTPClient except ImportError: - TornadoAsyncHTTPClient = None + TornadoAsyncHTTPClient = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. try: - import treq + import treq # type: ignore[import-not-found] from twisted.python import log as twisted_log from twisted.web.iweb import IPolicyForHTTPS from twisted.web.client import BrowserLikePolicyForHTTPS, Agent from twisted.internet.ssl import CertificateOptions from twisted.internet import task, defer, ssl, reactor - from zope.interface import implementer + from zope.interface import implementer # type: ignore[import-untyped] @implementer(IPolicyForHTTPS) class VerifyHTTPS(object): @@ -159,7 +175,7 @@ def log_handler(event): treq = None try: - from falcon import Request as FalconRequest + from falcon import Request as FalconRequest # type: ignore[import-not-found] except ImportError: FalconRequest = None @@ -207,14 +223,14 @@ def _get_flask_request(): def _get_pyramid_request(): if WebobBaseRequest is None: return None - from pyramid.threadlocal import get_current_request + from pyramid.threadlocal import get_current_request # type: ignore[import-untyped] return get_current_request() def _get_pylons_request(): if WebobBaseRequest is None: return None - from pylons import request + from pylons import request # type: ignore[import-not-found] return request @@ -238,13 +254,13 @@ def _get_fastapi_request(): return get_current_request() -BASE_DATA_HOOK = None +BASE_DATA_HOOK: Callable[[Any, dict[str, Any]], None] | None = None -agent_log = None +agent_log: logging.Logger | None = None -VERSION = __version__ -DEFAULT_ENDPOINT = 'https://api.rollbar.com/api/1/' -DEFAULT_TIMEOUT = 3 +VERSION: str = __version__ +DEFAULT_ENDPOINT: str = 'https://api.rollbar.com/api/1/' +DEFAULT_TIMEOUT: int = 3 ANONYMIZE = 'anonymize' DEFAULT_LOCALS_SIZES = { @@ -261,9 +277,77 @@ def _get_fastapi_request(): 'maxother': 100, } +Level = Literal['debug', 'info', 'warning', 'error', 'critical'] +IgnorableLevel = Level | Literal['ignored'] + + +class NotifierSettings(TypedDict, total=False): + name: str + version: str + + +class LocalsSettings(TypedDict, total=False): + enabled: bool + safe_repr: bool + scrub_varargs: bool + sizes: dict[str, int] + safelisted_types: list[type] + whitelisted_types: list[type] # deprecated, use safelisted_types instead + + +class SettingsParams(TypedDict, total=False): + enabled: bool + # List of tuples in the form (class, level) where class is an Exception class you want to always filter to the + # respective level. Any subclasses of the given class will also be matched. + # If class is a string, it will be lazy-evaluated to find the class by that name. + exception_level_filters: list[tuple[type | str, IgnorableLevel]] + root: str | None + host: str | None + branch: str | None + code_version: str | None + handler: Literal['default', 'blocking', 'thread', 'async', 'agent', 'tornado', 'gae', 'twisted', 'httpx', 'thread_pool'] + thread_pool_workers: int | None + endpoint: str + timeout: int + agent_log_file: str + notifier: NotifierSettings + allow_logging_basic_config: bool + locals: LocalsSettings + verify_https: bool + shortener_keys: list[tuple[str, ...]] + suppress_reinit_warning: bool + capture_email: bool + capture_username: bool + capture_ip: bool | Literal['anonymize'] + log_all_rate_limited_items: bool + log_payload_on_error: bool + http_proxy: str | None + http_proxy_user: str | None + http_proxy_password: str | None + include_request_body: bool + request_pool_connections: int | None + request_pool_maxsize: int | None + request_max_retries: int | None + batch_transforms: bool + custom_transforms: list[Transform] + + +# Deprecated, will be removed in version 2.0.0 +SettingsIrregular = TypedDict('SettingsIrregular', { + 'agent.log_file': Optional[str], +}) + + +class Settings(TypedDict, SettingsParams, SettingsIrregular): + access_token: str | None + environment: str + scrub_fields: list[str] + url_fields: list[str] + + # configuration settings # configure by calling init() or overriding directly -SETTINGS = { +SETTINGS: Settings = { 'access_token': None, 'enabled': True, 'environment': 'production', @@ -280,7 +364,9 @@ def _get_fastapi_request(): 'thread_pool_workers': None, 'endpoint': DEFAULT_ENDPOINT, 'timeout': DEFAULT_TIMEOUT, - 'agent.log_file': 'log.rollbar', + # Deprecated, use 'agent_log_file' instead. Will be removed in version 2.0.0 + 'agent.log_file': None, + 'agent_log_file': 'log.rollbar', 'scrub_fields': [ 'pw', 'passwd', @@ -333,9 +419,10 @@ def _get_fastapi_request(): _LAST_RESPONSE_STATUS = None # Set in init() -_transforms = [] -_serialize_transform = None -_scrub_redact_transform = None +_transforms: list[Transform] = [] +_serialize_transform: Transform | None = None +_scrub_redact_transform: Transform | None = None +_threads: queue.Queue _initialized = False @@ -353,17 +440,69 @@ def _get_fastapi_request(): ## public api -def init(access_token, environment='production', scrub_fields=None, url_fields=None, **kw): +def init( + access_token: str, + environment: str = 'production', + scrub_fields: list[str] | None = None, + url_fields: list[str] | None = None, + **kw: Unpack[SettingsParams], +) -> None: """ Saves configuration variables in this module's SETTINGS. - access_token: project access token. Get this from the Rollbar UI: - - click "Settings" in the top nav - - click "Projects" in the left nav - - copy-paste the appropriate token. - environment: environment name. Can be any string; suggestions: 'production', 'development', - 'staging', 'yourname' - **kw: provided keyword arguments will override keys in SETTINGS. + :param access_token: Project access token. + :param environment: Environment name. Any string up to 255 chars is OK. For best results, use production for your + production environment. + :param scrub_fields: List of sensitive field names to scrub out of request params and locals. Values will be + replaced with asterisks. If overriding, make sure to list all fields you want to scrub, not + just fields you want to add to the default. Param names are converted to lowercase before + comparing against the scrub list. + :param url_fields: List of fields treated as URLs to be parsed and scrubbed. + :param agent_log_file: If using the 'agent' handler, the path to the log file to write to. Filename must end with + `.rollbar`, Defaults to `log.rollbar`. + :param allow_logging_basic_config: When `True`, `logging.basicConfig()` will be called to set up the logging system. + Set to `False` to skip this call. If using Flask, you'll want to set to False. If + using Pyramid or Django, `True` should be fine. + :param batch_transforms: If `True`, enables batching of transforms for improved performance. Default: `False`. + :param branch: Name of the checked-out branch. + :param capture_ip: If equal to `True`, we will attempt to capture the full client IP address from a request. If + equal to the string `anonymize`, we will capture the client IP address, but then semi-anonymize + it by masking out the least significant bits. If equal to False, we will not capture the client + IP address from a request. Default: `True`. + :param capture_email: If set to `True`, we will attempt to enrich person data with an email address if available. + :param capture_username: If set to `True`, we will attempt to enrich person data with a username if available. + :param code_version: A string describing the current code revision/version (i.e. a git sha). Max 40 characters. + :param custom_transforms: A list of custom Transform instances to apply to payloads. + :param enabled: Whether Rollbar error reporting is enabled. + :param endpoint: URL items are posted to. Default: `https://api.rollbar.com/api/1/`. + :param exception_level_filters: List of tuples in the form (class, level) where class is an Exception class you want + to always filter to the respective level. Any subclasses of the given class will + also be matched. If class is a string, it will be lazy-evaluated to find the class + by that name. + :param handler: The method for reporting rollbar items to api.rollbar.com. Default: `default`. + :param host: Custom hostname of the current host. If not set, will use the system hostname. + :param http_proxy: The HTTP proxy host and optional port e.g. `myhttpproxy.com:5000`. This should not include the + URL scheme. If set all reports to the Rollbar service will be sent through the proxy. + :param http_proxy_user: The basic auth user to use with the HTTP proxy. + :param http_proxy_password: The basic auth password to use with the HTTP proxy. Basic auth will only work if both + `http_proxy_user` and `http_proxy_password` are present. + :param include_request_body: Set to `True` to add the raw HTTP request body to the error report. Currently, works + with Django, Starlette, and FastAPI. Default: `False`. + :param locals: Configuration for collecting local variables. + :param log_all_rate_limited_items: Rollbar will log a warning if you have crossed your limit for logged items. + :param request_pool_connections: If not `None`, used by requests to set the number of `urllib3` connection pools to + cache. Default: `None`. + :param request_pool_maxsize: If not `None`, used by requests to set the maximum number of connections to save in the + pool. Default: `None`. + :param request_max_retries: If not `None`, used by requests to set the maximum number of retries each connection + should attempt. Default: `None`. + :param root: Absolute path to the root of your application, not including the final /. + :param shortener_keys: A list of key prefixes (as tuple) to apply our shortener transform to. Added to built-in + list. + :param suppress_reinit_warning: If `True`, suppresses the warning normally shown when `rollbar.init()` is called + multiple times. + :param timeout: Timeout for any HTTP requests made to the Rollbar API (in seconds). + :param verify_https: If `True`, network requests will fail unless encountering a valid certificate. Default `True`. """ global SETTINGS, agent_log, _initialized, _transforms, _serialize_transform, _scrub_redact_transform, _threads @@ -373,7 +512,8 @@ def init(access_token, environment='production', scrub_fields=None, url_fields=N SETTINGS['url_fields'] = list(url_fields) # Merge the extra config settings into SETTINGS - SETTINGS = dict_merge(SETTINGS, kw) + # Both cast() and dict() are needed to satisfy MyPy + SETTINGS = cast(Settings, dict_merge(dict(SETTINGS), dict(kw))) if _initialized: # NOTE: Temp solution to not being able to re-init. # New versions of pyrollbar will support re-initialization @@ -413,7 +553,7 @@ def init(access_token, environment='production', scrub_fields=None, url_fields=N # A list of key prefixes to apply our shortener transform to. The request # being included in the body key is old behavior and is being retained for # backwards compatibility. - shortener_keys = [ + shortener_keys: list[tuple[str, ...]] = [ ('request', 'POST'), ('request', 'json'), ('body', 'request', 'POST'), @@ -487,7 +627,9 @@ def wrapper(event, context): return wrapper -def report_exc_info(exc_info=None, request=None, extra_data=None, payload_data=None, level=None, **kw): +def report_exc_info( + exc_info: tuple[type[BaseException], BaseException, types.TracebackType] | tuple[None, None, None] | None = None, + request=None, extra_data=None, payload_data=None, level=None, **kw): """ Reports an exception to Rollbar, using exc_info (from calling sys.exc_info()) @@ -515,7 +657,7 @@ def report_exc_info(exc_info=None, request=None, extra_data=None, payload_data=N log.exception("Exception while reporting exc_info to Rollbar. %r", e) -def report_message(message, level='error', request=None, extra_data=None, payload_data=None): +def report_message(message: str, level: Level = 'error', request=None, extra_data=None, payload_data=None): """ Reports an arbitrary string message to Rollbar. @@ -531,7 +673,7 @@ def report_message(message, level='error', request=None, extra_data=None, payloa log.exception("Exception while reporting message to Rollbar. %r", e) -def send_payload(payload, access_token): +def send_payload(payload, access_token: str): """ Sends a payload object, (the result of calling _build_payload() + _serialize_payload()). Uses the configured handler from SETTINGS['handler'] @@ -564,6 +706,9 @@ def send_payload(payload, access_token): if handler == 'blocking': _send_payload(payload_str, access_token) elif handler == 'agent': + if agent_log is None: + log.error('Rollbar agent log not initialized') + return agent_log.error(payload_str) elif handler == 'tornado': if TornadoAsyncHTTPClient is None: @@ -713,7 +858,7 @@ def _resolve_exception_class(idx, filter): return cls, level -def _filtered_level(exception): +def _filtered_level(exception: BaseException): for i, filter in enumerate(SETTINGS['exception_level_filters']): cls, level = _resolve_exception_class(i, filter) if cls and isinstance(exception, cls): @@ -730,11 +875,14 @@ def _create_agent_log(): """ Creates .rollbar log file for use with rollbar-agent """ - log_file = SETTINGS['agent.log_file'] + log_file = SETTINGS['agent_log_file'] + legacy_log_file = SETTINGS.get('agent.log_file') + if isinstance(legacy_log_file, str) and legacy_log_file: + log_file = legacy_log_file if not log_file.endswith('.rollbar'): log.error("Provided agent log file does not end with .rollbar, which it must. " "Using default instead.") - log_file = DEFAULTS['agent.log_file'] + log_file = 'log.rollbar' retval = logging.getLogger('rollbar_agent') handler = logging.FileHandler(log_file, 'a', 'utf-8') @@ -908,7 +1056,7 @@ def _add_session_data(data: dict) -> None: request = _session_data_from_request(data) if request is None: return - session_data = parse_session_request_baggage_headers(request.get('headers', None)) + session_data = parse_session_request_baggage_headers(request.get('headers', {})) if session_data: _add_session_attributes(data, session_data) @@ -930,7 +1078,7 @@ def _add_session_attributes(data: dict, session_data: list[Attribute]) -> None: data['attributes'].append(attribute) -def _session_data_from_request(data: dict) -> dict: +def _session_data_from_request(data: dict) -> dict | None: """ Tries to find session data in the request object. Use the request object if provided, otherwise check the data as it may already contain the request object. This is true for some frameworks (e.g. Django). @@ -957,7 +1105,7 @@ def _check_config(): return True -def _build_base_data(request, level='error'): +def _build_base_data(request, level='error') -> dict[str, Any]: data = { 'timestamp': int(time.time()), 'environment': SETTINGS['environment'], @@ -970,7 +1118,7 @@ def _build_base_data(request, level='error'): if SETTINGS.get('code_version'): data['code_version'] = SETTINGS['code_version'] - if BASE_DATA_HOOK: + if BASE_DATA_HOOK is not None: BASE_DATA_HOOK(request, data) return data @@ -1004,10 +1152,11 @@ def _build_person_data(request): else: return None - if StarletteRequest: + if StarletteRequest is not None: from rollbar.contrib.starlette.requests import hasuser else: - def hasuser(request): return True + def hasuser(request: StarletteRequest[StarletteState]) -> bool: + return True if hasuser(request) and hasattr(request, 'user'): user_prop = request.user @@ -1053,7 +1202,7 @@ def _get_func_from_frame(frame): return func -def _add_locals_data(trace_data, exc_info): +def _add_locals_data(trace_data, exc_info) -> None: if not SETTINGS['locals']['enabled']: return @@ -1079,7 +1228,7 @@ def _add_locals_data(trace_data, exc_info): argspec = None varargspec = None keywordspec = None - _locals = {} + _locals: dict[str, Any] = {} try: arginfo = inspect.getargvalues(tb_frame) @@ -1190,8 +1339,8 @@ def _check_add_locals(frame, frame_num, total_frames): ('root' in SETTINGS and (frame.get('filename') or '').lower().startswith(root.lower())))) -def _get_actual_request(request): - if WerkzeugLocalProxy and isinstance(request, WerkzeugLocalProxy): +def _get_actual_request(request: Any | None) -> Any | None: + if WerkzeugLocalProxy is not None and isinstance(request, WerkzeugLocalProxy): try: actual_request = request._get_current_object() except RuntimeError: @@ -1200,41 +1349,41 @@ def _get_actual_request(request): return request -def _build_request_data(request): +def _build_request_data(request: Any) -> dict | None: """ Returns a dictionary containing data from the request. """ # webob (pyramid) - if WebobBaseRequest and isinstance(request, WebobBaseRequest): + if WebobBaseRequest is not None and isinstance(request, WebobBaseRequest): return _build_webob_request_data(request) # django - if DjangoHttpRequest and isinstance(request, DjangoHttpRequest): + if DjangoHttpRequest is not None and isinstance(request, DjangoHttpRequest): return _build_django_request_data(request) # django rest framework - if RestFrameworkRequest and isinstance(request, RestFrameworkRequest): + if RestFrameworkRequest is not None and isinstance(request, RestFrameworkRequest): return _build_django_request_data(request) # werkzeug (flask) - if WerkzeugRequest and isinstance(request, WerkzeugRequest): + if WerkzeugRequest is not None and isinstance(request, WerkzeugRequest): return _build_werkzeug_request_data(request) # tornado - if TornadoRequest and isinstance(request, TornadoRequest): + if TornadoRequest is not None and isinstance(request, TornadoRequest): return _build_tornado_request_data(request) # bottle - if BottleRequest and isinstance(request, BottleRequest): + if BottleRequest is not None and isinstance(request, BottleRequest): return _build_bottle_request_data(request) # Sanic - if SanicRequest and isinstance(request, SanicRequest): + if SanicRequest is not None and isinstance(request, SanicRequest): return _build_sanic_request_data(request) # falcon - if FalconRequest and isinstance(request, FalconRequest): + if FalconRequest is not None and isinstance(request, FalconRequest): return _build_falcon_request_data(request) # Plain wsgi (should be last) @@ -1242,17 +1391,17 @@ def _build_request_data(request): return _build_wsgi_request_data(request) # FastAPI (built on top of Starlette, so keep the order) - if FastAPIRequest and isinstance(request, FastAPIRequest): + if FastAPIRequest is not None and isinstance(request, FastAPIRequest): return _build_fastapi_request_data(request) # Starlette (should be the last one for Starlette based frameworks) - if StarletteRequest and isinstance(request, StarletteRequest): + if StarletteRequest is not None and isinstance(request, StarletteRequest): return _build_starlette_request_data(request) return None -def _build_webob_request_data(request): +def _build_webob_request_data(request) -> dict: request_data = { 'url': request.url, 'GET': dict(request.GET), @@ -1290,7 +1439,7 @@ def _extract_wsgi_headers(items): return headers -def _build_django_request_data(request): +def _build_django_request_data(request) -> dict: url = request.build_absolute_uri() request_data = { @@ -1312,7 +1461,7 @@ def _build_django_request_data(request): return request_data -def _build_werkzeug_request_data(request): +def _build_werkzeug_request_data(request) -> dict: request_data = { 'url': request.url, 'GET': dict(request.args), @@ -1333,7 +1482,7 @@ def _build_werkzeug_request_data(request): return request_data -def _build_tornado_request_data(request): +def _build_tornado_request_data(request) -> dict: request_data = { 'url': request.full_url(), 'user_ip': request.remote_ip, @@ -1347,7 +1496,7 @@ def _build_tornado_request_data(request): return request_data -def _build_bottle_request_data(request): +def _build_bottle_request_data(request) -> dict: request_data = { 'url': request.url, 'user_ip': request.remote_addr, @@ -1369,7 +1518,7 @@ def _build_bottle_request_data(request): return request_data -def _build_sanic_request_data(request): +def _build_sanic_request_data(request) -> dict: request_data = { 'url': request.url, 'user_ip': request.remote_addr, @@ -1390,7 +1539,7 @@ def _build_sanic_request_data(request): return request_data -def _build_falcon_request_data(request): +def _build_falcon_request_data(request) -> dict: request_data = { 'url': request.url, 'user_ip': _wsgi_extract_user_ip(request.env), @@ -1403,7 +1552,7 @@ def _build_falcon_request_data(request): return request_data -def _build_wsgi_request_data(request): +def _build_wsgi_request_data(request) -> dict: request_data = { 'url': wsgiref.util.request_uri(request), 'user_ip': _wsgi_extract_user_ip(request), @@ -1430,7 +1579,7 @@ def _build_wsgi_request_data(request): return request_data -def _build_starlette_request_data(request): +def _build_starlette_request_data(request) -> dict: from starlette.datastructures import UploadFile request_data = { @@ -1474,7 +1623,7 @@ def _build_starlette_request_data(request): return request_data -def _build_fastapi_request_data(request): +def _build_fastapi_request_data(request) -> dict: return _build_starlette_request_data(request) @@ -1529,7 +1678,7 @@ def _build_server_data(): return server_data -def _transform(obj, key=None): +def _transform(obj: Any, key: tuple[KeyType, ...] | None = None): return transforms.transform( obj, _transforms, @@ -1538,9 +1687,9 @@ def _transform(obj, key=None): ) -def _build_payload(data): +def _build_payload(data: dict) -> dict: """ - Returns the full payload as a string. + Returns the full payload as a dict. """ for k, v in data.items(): diff --git a/rollbar/contrib/bottle/__init__.py b/rollbar/contrib/bottle/__init__.py index 789494c1..d6f4184d 100644 --- a/rollbar/contrib/bottle/__init__.py +++ b/rollbar/contrib/bottle/__init__.py @@ -1,4 +1,6 @@ -import bottle, rollbar, sys +import sys +import bottle # type: ignore[import-untyped] +import rollbar class RollbarBottleReporter(object): ''' diff --git a/rollbar/contrib/django/middleware.py b/rollbar/contrib/django/middleware.py index bca486e6..2906026a 100644 --- a/rollbar/contrib/django/middleware.py +++ b/rollbar/contrib/django/middleware.py @@ -86,19 +86,15 @@ def get_payload_data(self, request, exc): from django.core.exceptions import MiddlewareNotUsed from django.conf import settings from django.http import Http404 +from django.urls import resolve from rollbar import set_current_session from rollbar.lib.session import reset_current_session -try: - from django.urls import resolve -except ImportError: - from django.core.urlresolvers import resolve - try: from django.utils.deprecation import MiddlewareMixin except ImportError: - from rollbar.contrib.django.utils import MiddlewareMixin + from rollbar.contrib.django.utils import MiddlewareMixin # type: ignore[assignment] log = logging.getLogger(__name__) diff --git a/rollbar/contrib/django_rest_framework/__init__.py b/rollbar/contrib/django_rest_framework/__init__.py index 7817e8ce..392f4f1d 100755 --- a/rollbar/contrib/django_rest_framework/__init__.py +++ b/rollbar/contrib/django_rest_framework/__init__.py @@ -1,12 +1,12 @@ try: from django.core.exceptions import ImproperlyConfigured except ImportError: - ImproperlyConfigured = RuntimeError + ImproperlyConfigured = RuntimeError # type: ignore[assignment, misc] # MyPy does not like types assignment. try: - from rest_framework.views import exception_handler as _exception_handler + from rest_framework.views import exception_handler as _exception_handler # type: ignore[import-untyped] except (ImportError, ImproperlyConfigured): - _exception_handler = None + _exception_handler = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. def post_exception_handler(exc, context): diff --git a/rollbar/contrib/fastapi/routing.py b/rollbar/contrib/fastapi/routing.py index df2a832d..1cfd9006 100644 --- a/rollbar/contrib/fastapi/routing.py +++ b/rollbar/contrib/fastapi/routing.py @@ -1,8 +1,10 @@ +from __future__ import annotations + __all__ = ['add_to'] import logging import sys -from typing import Callable, Optional, Type, Union +from typing import Callable, Type from fastapi import APIRouter, FastAPI, __version__ from fastapi.routing import APIRoute @@ -27,7 +29,7 @@ @fastapi_min_version('0.41.0') @integrate(framework_name=f'fastapi {__version__}') -def add_to(app_or_router: Union[FastAPI, APIRouter]) -> Optional[Type[APIRoute]]: +def add_to(app_or_router: FastAPI | APIRouter) -> Type[APIRoute] | None: """ Adds RollbarLoggingRoute handler to the router app. @@ -51,6 +53,10 @@ def add_to(app_or_router: Union[FastAPI, APIRouter]) -> Optional[Type[APIRoute]] """ + if not isinstance(app_or_router, (FastAPI, APIRouter)): + log.error('Error adding RollbarLoggingRoute to application.') + return None + if not has_bare_routing(app_or_router): log.error( 'RollbarLoggingRoute must to be added to a bare router' @@ -70,9 +76,6 @@ def add_to(app_or_router: Union[FastAPI, APIRouter]) -> Optional[Type[APIRoute]] _add_to_app(app_or_router) elif isinstance(app_or_router, APIRouter): _add_to_router(app_or_router) - else: - log.error('Error adding RollbarLoggingRoute to application.') - return None return RollbarLoggingRoute diff --git a/rollbar/contrib/fastapi/utils.py b/rollbar/contrib/fastapi/utils.py index 66cce0df..6cdff85c 100644 --- a/rollbar/contrib/fastapi/utils.py +++ b/rollbar/contrib/fastapi/utils.py @@ -1,9 +1,11 @@ +from __future__ import annotations + import functools import logging -from typing import Union import fastapi from fastapi import APIRouter, FastAPI +from starlette.routing import Route from . import ReporterMiddleware as FastAPIReporterMiddleware from rollbar.contrib.starlette import ReporterMiddleware as StarletteReporterMiddleware @@ -97,7 +99,7 @@ def get_installed_middlewares(app): return middlewares -def has_bare_routing(app_or_router: Union[FastAPI, APIRouter]): +def has_bare_routing(app_or_router: FastAPI | APIRouter): if not isinstance(app_or_router, (FastAPI, APIRouter)): return False @@ -109,7 +111,7 @@ def has_bare_routing(app_or_router: Union[FastAPI, APIRouter]): ] for route in app_or_router.routes: - if route is None or route.path in urls: + if route is None or (isinstance(route, Route) and route.path in urls): continue return False diff --git a/rollbar/contrib/pyramid/__init__.py b/rollbar/contrib/pyramid/__init__.py index 163c4466..6f25f70d 100644 --- a/rollbar/contrib/pyramid/__init__.py +++ b/rollbar/contrib/pyramid/__init__.py @@ -5,10 +5,10 @@ import logging import sys -from pyramid.httpexceptions import WSGIHTTPException -from pyramid.tweens import EXCVIEW -from pyramid.util import DottedNameResolver -from pyramid.settings import asbool +from pyramid.httpexceptions import WSGIHTTPException # type: ignore[import-untyped] +from pyramid.tweens import EXCVIEW # type: ignore[import-untyped] +from pyramid.util import DottedNameResolver # type: ignore[import-untyped] +from pyramid.settings import asbool # type: ignore[import-untyped] import rollbar from rollbar import set_current_session @@ -24,7 +24,7 @@ EXCEPTION_BLOCKLIST = (WSGIHTTPException,) -EXCEPTION_SAFELIST = tuple() +EXCEPTION_SAFELIST: tuple = tuple() def handle_error(request, exception, exc_info): @@ -83,7 +83,7 @@ def patch_debugtoolbar(settings): Patches the pyramid_debugtoolbar (if installed) to display a link to the related rollbar item. """ try: - from pyramid_debugtoolbar import tbtools + from pyramid_debugtoolbar import tbtools # type: ignore[import-not-found] except ImportError: return @@ -182,6 +182,6 @@ def __call__(self, environ, start_resp): try: return self.app(environ, start_resp) except Exception as exc: - from pyramid.request import Request + from pyramid.request import Request # type: ignore[import-untyped] handle_error(Request(environ), exc, sys.exc_info()) raise diff --git a/rollbar/contrib/starlette/requests.py b/rollbar/contrib/starlette/requests.py index cd33981b..fa895384 100644 --- a/rollbar/contrib/starlette/requests.py +++ b/rollbar/contrib/starlette/requests.py @@ -1,66 +1,42 @@ +from __future__ import annotations + __all__ = ['get_current_request'] import logging -import sys -from typing import Optional, Union +from typing import MutableMapping +from contextvars import ContextVar from starlette.requests import Request from starlette.types import Receive, Scope log = logging.getLogger(__name__) -if sys.version_info[:2] == (3, 6): - # Backport PEP 567 - try: - import aiocontextvars - except ImportError: - # Do not raise an exception as the module is exported to package API - # but is still optional - log.error( - 'Python 3.6 requires `aiocontextvars` package to be installed' - ' to support global access to request objects' - ) - -try: - from contextvars import ContextVar -except ImportError: - ContextVar = None - -if ContextVar: - _current_request: ContextVar[Optional[Request]] = ContextVar( - 'rollbar-request-object', default=None - ) +_current_request: ContextVar[Request | None] = ContextVar( + 'rollbar-request-object', default=None +) -def get_current_request() -> Optional[Request]: +def get_current_request() -> Request | None: """ Return current request. Do NOT modify the returned request object. """ - if ContextVar is None: - log.error( - 'Python 3.7+ (or aiocontextvars package)' - ' is required to receive current request.' - ) - return None - return _current_request.get() -def store_current_request( - request_or_scope: Union[Request, Scope], receive: Optional[Receive] = None -) -> Optional[Request]: - if ContextVar is None: - return None +def store_current_request(request_or_scope: Request | Scope, receive: Receive | None = None) -> Request | None: + """ + Store the current request in an async-safe context variable. + """ - if receive is None: + request: Request | None = None + if isinstance(request_or_scope, Request): request = request_or_scope - elif request_or_scope['type'] == 'http': + elif isinstance(request_or_scope, MutableMapping) and request_or_scope['type'] == 'http' and receive is not None: + # The above condition checks if request_or_scope is a Scope request = Request(request_or_scope, receive) - else: - request = None _current_request.set(request) return request diff --git a/rollbar/examples/asgi/app.py b/rollbar/examples/asgi/app.py index 22c52575..de6d8934 100644 --- a/rollbar/examples/asgi/app.py +++ b/rollbar/examples/asgi/app.py @@ -20,6 +20,7 @@ from rollbar.contrib.asgi import ReporterMiddleware as RollbarMiddleware from starlette.applications import Starlette from starlette.responses import PlainTextResponse +from starlette.routing import Route # Initialize Rollbar SDK with your server-side ACCESS_TOKEN rollbar.init( @@ -28,15 +29,10 @@ handler='async', # For asynchronous reporting use: default, async or httpx ) -# Integrate Rollbar with Starlette application -app = Starlette() -app.add_middleware(RollbarMiddleware) # should be added as the first middleware - # Verify application runs correctly # # $ curl http://localhost:8888 -@app.route('/') async def root(request): return PlainTextResponse('hello world') @@ -52,11 +48,19 @@ async def localfunc(arg1, arg2, arg3): cause_error_with_local_variables -@app.route('/error') async def error(request): await localfunc('func_arg1', 'func_arg2', 1) return PlainTextResponse("You shouldn't be seeing this") +routes = [ + Route('/', endpoint=root), + Route('/error', endpoint=error), +] + +# Integrate Rollbar with Starlette application +app = Starlette(routes=routes) +app.add_middleware(RollbarMiddleware) # should be added as the first middleware + if __name__ == '__main__': uvicorn.run(app, host='localhost', port=8888) diff --git a/rollbar/examples/django/app.py b/rollbar/examples/django/app.py index ed0a9a44..20c2f904 100644 --- a/rollbar/examples/django/app.py +++ b/rollbar/examples/django/app.py @@ -42,7 +42,7 @@ MIDDLEWARE_CLASSES = MIDDLEWARE_CONFIG, ) -from django.conf.urls import url +from django.urls import re_path from django.http import HttpResponse @@ -55,8 +55,8 @@ def error(request): urlpatterns = ( - url(r'^$', index), - url(r'^error$', error), + re_path(r'^$', index), + re_path(r'^error$', error), ) if __name__ == "__main__": diff --git a/rollbar/examples/starlette/app.py b/rollbar/examples/starlette/app.py index 24089263..e32cf6d7 100644 --- a/rollbar/examples/starlette/app.py +++ b/rollbar/examples/starlette/app.py @@ -15,6 +15,7 @@ from rollbar.contrib.starlette import ReporterMiddleware as RollbarMiddleware from starlette.applications import Starlette from starlette.responses import PlainTextResponse +from starlette.routing import Route # Initialize Rollbar SDK with your server-side ACCESS_TOKEN rollbar.init( @@ -23,15 +24,10 @@ handler='async', # For asynchronous reporting use: default, async or httpx ) -# Integrate Rollbar with Starlette application -app = Starlette() -app.add_middleware(RollbarMiddleware) # should be added as the first middleware - # Verify application runs correctly # # $ curl http://localhost:8888 -@app.route('/') async def root(request): return PlainTextResponse('hello world') @@ -47,11 +43,19 @@ async def localfunc(arg1, arg2, arg3): cause_error_with_local_variables -@app.route('/error') async def error(request): await localfunc('func_arg1', 'func_arg2', 1) return PlainTextResponse("You shouldn't be seeing this") +routes = [ + Route('/', endpoint=root), + Route('/error', endpoint=error), +] + +# Integrate Rollbar with Starlette application +app = Starlette(routes=routes) +app.add_middleware(RollbarMiddleware) # should be added as the first middleware + if __name__ == '__main__': uvicorn.run(app, host='localhost', port=8888) diff --git a/rollbar/examples/starlette/app_global_request.py b/rollbar/examples/starlette/app_global_request.py index 7904e0f3..32157f45 100644 --- a/rollbar/examples/starlette/app_global_request.py +++ b/rollbar/examples/starlette/app_global_request.py @@ -13,10 +13,7 @@ from rollbar.contrib.starlette import LoggerMiddleware from starlette.applications import Starlette from starlette.responses import JSONResponse - -# Integrate Rollbar with Starlette application -app = Starlette() -app.add_middleware(LoggerMiddleware) # should be added as the last middleware +from starlette.routing import Route async def get_user_agent(): @@ -28,11 +25,18 @@ async def get_user_agent(): # $ curl -i http://localhost:8888 -@app.route('/') async def root(request): user_agent = await get_user_agent() return JSONResponse({'user-agent': user_agent}) +routes = [ + Route('/', endpoint=root), +] + +# Integrate Rollbar with Starlette application +app = Starlette(routes=routes) +app.add_middleware(LoggerMiddleware) # should be added as the last middleware + if __name__ == '__main__': uvicorn.run(app, host='localhost', port=8888) diff --git a/rollbar/examples/starlette/app_logger.py b/rollbar/examples/starlette/app_logger.py index dbe2826a..428d067b 100644 --- a/rollbar/examples/starlette/app_logger.py +++ b/rollbar/examples/starlette/app_logger.py @@ -17,6 +17,7 @@ from rollbar.logger import RollbarHandler from starlette.applications import Starlette from starlette.responses import PlainTextResponse +from starlette.routing import Route # Initialize Rollbar SDK with your server-side ACCESS_TOKEN rollbar.init( @@ -36,14 +37,9 @@ # Attach Rollbar handler to the root logger logger.addHandler(rollbar_handler) -# Integrate Rollbar with Starlette application -app = Starlette() -app.add_middleware(LoggerMiddleware) # should be added as the last middleware - # GET query params will be sent to Rollbar and available in the UI # $ curl http://localhost:8888?param1=hello¶m2=world -@app.route('/') async def root(request): # Report log entries logger.critical('Critical message sent to Rollbar') @@ -57,5 +53,13 @@ async def root(request): return PlainTextResponse('hello world') +routes = [ + Route('/', endpoint=root), +] + +# Integrate Rollbar with Starlette application +app = Starlette(routes=routes) +app.add_middleware(LoggerMiddleware) # should be added as the last middleware + if __name__ == '__main__': uvicorn.run(app, host='localhost', port=8888) diff --git a/rollbar/lib/__init__.py b/rollbar/lib/__init__.py index 58bf7557..95440367 100644 --- a/rollbar/lib/__init__.py +++ b/rollbar/lib/__init__.py @@ -1,9 +1,12 @@ +from __future__ import annotations + import base64 import collections import copy from array import array from collections.abc import Mapping +from typing import Any, TypeVar, MutableMapping binary_type = bytes integer_types = int @@ -106,19 +109,22 @@ def matcher(prefix_or_suffix): return matcher -def is_builtin_type(obj): +def is_builtin_type(obj) -> bool: return obj.__class__.__module__ in ('__builtin__', 'builtins') +T = TypeVar('T', bound=dict | MutableMapping[str, Any]) +U = TypeVar('U', bound=dict | Mapping[str, Any] | Any) + # http://www.xormedia.com/recursively-merge-dictionaries-in-python.html -def dict_merge(a, b, silence_errors=False): +def dict_merge(a: T, b: U, silence_errors: bool = False) -> T | U: """ Recursively merges dict's. not just simple a['key'] = b['key'], if - both a and bhave a key who's value is a dict then dict_merge is called + both a and b have a key whose value is a dict then dict_merge is called on both values and the result stored in the returned dictionary. """ - if not isinstance(b, dict): + if not isinstance(b, (dict, Mapping)): return b result = a @@ -132,44 +138,42 @@ def dict_merge(a, b, silence_errors=False): if not silence_errors: raise e - result[k] = '' % (v,) + result[k] = f'' return result -def circular_reference_label(data, ref_key=None): +def circular_reference_label(data: Any, ref_key=None) -> str: ref = '.'.join([str(x) for x in ref_key]) - return '' % (type(data).__name__, ref) + return f'' -def float_nan_label(data): +def float_nan_label(data) -> str: return '' -def float_infinity_label(data): +def float_infinity_label(data) -> str: if data > 1: return '' else: return '' -def unencodable_object_label(data): - return '' % (type(data).__name__, - base64.b64encode(data).decode('ascii')) +def unencodable_object_label(data) -> str: + return f'' -def undecodable_object_label(data): - return '' % (type(data).__name__, - base64.b64encode(data).decode('ascii')) +def undecodable_object_label(data) -> str: + return f'' try: from django.utils.functional import SimpleLazyObject except ImportError: - SimpleLazyObject = None + SimpleLazyObject = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. def defaultJSONEncode(o): - if SimpleLazyObject and isinstance(o, SimpleLazyObject): + if SimpleLazyObject is not None and isinstance(o, SimpleLazyObject): if not o._wrapped: o._setup() return o._wrapped diff --git a/rollbar/lib/_async.py b/rollbar/lib/_async.py index 163671e5..998ce045 100644 --- a/rollbar/lib/_async.py +++ b/rollbar/lib/_async.py @@ -1,15 +1,15 @@ import asyncio -import contextlib import inspect import logging import sys +from contextvars import ContextVar from unittest import mock from urllib.parse import urljoin try: import httpx except ImportError: - httpx = None + httpx = None # type: ignore[assignment, misc] # MyPy does not like types assigned to None. import rollbar from rollbar import DEFAULT_TIMEOUT @@ -34,15 +34,7 @@ ' Please upgrade Python or install `aiocontextvars`.' ) -try: - from contextvars import ContextVar -except ImportError: - ContextVar = None - -if ContextVar: - _ctx_handler = ContextVar('rollbar-handler', default=None) -else: - _ctx_handler = None +_ctx_handler = ContextVar('rollbar-handler', default=None) class RollbarAsyncError(Exception): @@ -187,26 +179,17 @@ def with_ctx_handler(self): return _ctx_handler.get() - def with_global_handler(self): - return self.global_handler - def __enter__(self): self.global_handler = rollbar.SETTINGS.get('handler') - if _ctx_handler: - return self.with_ctx_handler() - else: - return self.with_global_handler() + return self.with_ctx_handler() def __exit__(self, exc_type, exc_value, traceback): - if _ctx_handler and self.token: + if self.token: _ctx_handler.reset(self.token) def get_current_handler(): - if _ctx_handler is None: - return rollbar.SETTINGS.get('handler') - handler = _ctx_handler.get() if handler is None: diff --git a/rollbar/lib/events.py b/rollbar/lib/events.py index fd568707..f5f12dcd 100644 --- a/rollbar/lib/events.py +++ b/rollbar/lib/events.py @@ -1,32 +1,39 @@ -EXCEPTION_INFO = 'exception_info' -MESSAGE = 'message' -PAYLOAD = 'payload' +from __future__ import annotations +from typing import Any, Callable, Literal -_event_handlers = { +EventHandler = Callable[..., Literal[False] | Any] + +EXCEPTION_INFO: Literal['exception_info'] = 'exception_info' +MESSAGE: Literal['message'] = 'message' +PAYLOAD: Literal['payload'] = 'payload' + +EventType = Literal['exception_info', 'message', 'payload'] + +_event_handlers: dict[EventType, list[EventHandler]] = { EXCEPTION_INFO: [], MESSAGE: [], PAYLOAD: [] } -def _check_type(typ): +def _check_type(typ: str): if typ not in _event_handlers: raise ValueError('Unknown type: %s. Must be one of %s' % (typ, _event_handlers.keys())) -def _add_handler(typ, handler_fn, pos): +def _add_handler(typ: EventType, handler_fn: EventHandler, pos: int | None = None) -> None: _check_type(typ) - pos = pos if pos is not None else -1 + insert_pos = pos if pos is not None else -1 handlers = _event_handlers[typ] try: handlers.index(handler_fn) except ValueError: - handlers.insert(pos, handler_fn) + handlers.insert(insert_pos, handler_fn) -def _remove_handler(typ, handler_fn): +def _remove_handler(typ: EventType, handler_fn: EventHandler): _check_type(typ) handlers = _event_handlers[typ] @@ -38,7 +45,7 @@ def _remove_handler(typ, handler_fn): pass -def _on_event(typ, target, **kw): +def _on_event(typ: EventType, target, **kw): _check_type(typ) ref = target @@ -54,27 +61,27 @@ def _on_event(typ, target, **kw): # Add/remove event handlers -def add_exception_info_handler(handler_fn, pos=None): +def add_exception_info_handler(handler_fn: EventHandler, pos: int | None = None) -> None: _add_handler(EXCEPTION_INFO, handler_fn, pos) -def remove_exception_info_handler(handler_fn): +def remove_exception_info_handler(handler_fn: EventHandler) -> None: _remove_handler(EXCEPTION_INFO, handler_fn) -def add_message_handler(handler_fn, pos=None): +def add_message_handler(handler_fn: EventHandler, pos: int | None = None) -> None: _add_handler(MESSAGE, handler_fn, pos) -def remove_message_handler(handler_fn): +def remove_message_handler(handler_fn: EventHandler) -> None: _remove_handler(MESSAGE, handler_fn) -def add_payload_handler(handler_fn, pos=None): +def add_payload_handler(handler_fn: EventHandler, pos: int | None = None) -> None: _add_handler(PAYLOAD, handler_fn, pos) -def remove_payload_handler(handler_fn): +def remove_payload_handler(handler_fn: EventHandler) -> None: _remove_handler(PAYLOAD, handler_fn) diff --git a/rollbar/lib/session.py b/rollbar/lib/session.py index 300e8aae..ebf0e95c 100644 --- a/rollbar/lib/session.py +++ b/rollbar/lib/session.py @@ -3,8 +3,10 @@ import random import threading from contextvars import ContextVar +from typing import TYPE_CHECKING -from rollbar.lib.payload import Attribute +if TYPE_CHECKING: + from rollbar.lib.payload import Attribute _context_session: ContextVar[list[Attribute]|None] = ContextVar('rollbar-session', default=None) _thread_session: threading.local = threading.local() @@ -74,7 +76,7 @@ def parse_session_request_baggage_headers(headers: dict, generate_missing: bool return [] baggage_items = baggage_header.split(',') - baggage_data = [] + baggage_data: list[Attribute] = [] has_scope_id = False for item in baggage_items: if '=' not in item: diff --git a/rollbar/lib/transform.py b/rollbar/lib/transform.py index e3720665..dccc8cda 100644 --- a/rollbar/lib/transform.py +++ b/rollbar/lib/transform.py @@ -1,51 +1,57 @@ -from typing import Optional +from __future__ import annotations +from os import PathLike +from typing import TypeVar, TYPE_CHECKING +if TYPE_CHECKING: + from rollbar.lib.type_info import KeyType + +T = TypeVar('T') class Transform(object): - depth_first = True - priority = 100 + depth_first: bool = True + priority: int = 100 - def default(self, o, key=None): + def default(self, o: T, key: tuple[KeyType, ...]|None = None) -> T: return o - def transform_circular_reference(self, o, key=None, ref_key=None): + def transform_circular_reference(self, o, key: tuple[KeyType, ...]|None = None, ref_key=None): # By default, we just perform a no-op for circular references. # Subclasses should implement this method to return whatever representation # for the circular reference they need. return self.default(o, key=key) - def transform_tuple(self, o, key=None): + def transform_tuple(self, o: tuple, key: tuple[KeyType, ...]|None = None) -> tuple: return self.default(o, key=key) - def transform_namedtuple(self, o, key=None): + def transform_namedtuple(self, o, key: tuple[KeyType, ...]|None = None): return self.default(o, key=key) - def transform_list(self, o, key=None): + def transform_list(self, o, key: tuple[KeyType, ...]|None = None): return self.default(o, key=key) - def transform_dict(self, o, key=None): + def transform_dict(self, o: dict, key: tuple[KeyType, ...]|None = None) -> dict: return self.default(o, key=key) - def transform_number(self, o, key=None): + def transform_number(self, o: float | int, key: tuple[KeyType, ...]|None = None) -> float | int | str: return self.default(o, key=key) - def transform_bytes(self, o, key=None): + def transform_bytes(self, o: bytes, key: tuple[KeyType, ...]|None = None) -> bytes | str: return self.default(o, key=key) - def transform_unicode(self, o, key=None): + def transform_unicode(self, o: str, key: tuple[KeyType, ...]|None = None) -> str: return self.default(o, key=key) - def transform_boolean(self, o, key=None): + def transform_boolean(self, o: bool, key: tuple[KeyType, ...]|None = None) -> bool: return self.default(o, key=key) - def transform_path(self, o, key=None): + def transform_path(self, o: PathLike, key: tuple[KeyType, ...]|None = None) -> str: return self.default(str(o), key=key) - def transform_custom(self, o, key=None): + def transform_custom(self, o: T, key: tuple[KeyType, ...]|None = None) -> T: return self.default(o, key=key) @staticmethod - def rollbar_repr(obj: object) -> Optional[str]: + def rollbar_repr(obj: object) -> str | None: r = None if hasattr(obj, '__rollbar_repr__'): r = obj.__rollbar_repr__() diff --git a/rollbar/lib/transforms/__init__.py b/rollbar/lib/transforms/__init__.py index 92d180f3..4288a80f 100644 --- a/rollbar/lib/transforms/__init__.py +++ b/rollbar/lib/transforms/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from typing import Callable, TypedDict, Any, TYPE_CHECKING from collections.abc import Iterable from rollbar.lib import ( @@ -11,22 +13,38 @@ from rollbar.lib.transform import Transform from rollbar.lib.transforms.batched import BatchedTransform -_ALLOWED_CIRCULAR_REFERENCE_TYPES = [binary_type, bool, type(None)] +if TYPE_CHECKING: + from rollbar.lib.type_info import KeyType + +_ALLOWED_CIRCULAR_REFERENCE_TYPES: tuple = (binary_type, bool, type(None)) if isinstance(string_types, tuple): - _ALLOWED_CIRCULAR_REFERENCE_TYPES.extend(string_types) + _ALLOWED_CIRCULAR_REFERENCE_TYPES = (*_ALLOWED_CIRCULAR_REFERENCE_TYPES, *string_types) else: - _ALLOWED_CIRCULAR_REFERENCE_TYPES.append(string_types) + _ALLOWED_CIRCULAR_REFERENCE_TYPES = (*_ALLOWED_CIRCULAR_REFERENCE_TYPES, string_types) if isinstance(number_types, tuple): - _ALLOWED_CIRCULAR_REFERENCE_TYPES.extend(number_types) + _ALLOWED_CIRCULAR_REFERENCE_TYPES = (*_ALLOWED_CIRCULAR_REFERENCE_TYPES, *number_types) else: - _ALLOWED_CIRCULAR_REFERENCE_TYPES.append(number_types) + _ALLOWED_CIRCULAR_REFERENCE_TYPES = (*_ALLOWED_CIRCULAR_REFERENCE_TYPES, number_types) _ALLOWED_CIRCULAR_REFERENCE_TYPES = tuple(_ALLOWED_CIRCULAR_REFERENCE_TYPES) -def transform(obj, transforms, key=None, batch_transforms=False): +class Handlers(TypedDict, total=False): + string_handler: Callable + tuple_handler: Callable + namedtuple_handler: Callable + list_handler: Callable + set_handler: Callable + mapping_handler: Callable + path_handler: Callable + circular_reference_handler: Callable + default_handler: Callable + allowed_circular_reference_types: tuple | None + + +def transform(obj, transforms: Transform | list[Transform], key: tuple[KeyType, ...] | None = None, batch_transforms: bool = False): if isinstance(transforms, Transform): transforms = [transforms] @@ -41,22 +59,22 @@ def transform(obj, transforms, key=None, batch_transforms=False): return obj -def _transform(obj, transform, key=None): +def _transform(obj: Any, transform: Transform, key: tuple[KeyType, ...] | None = None) -> Any: key = key or () - def do_transform(type_name, val, key=None, **kw): + def do_transform(type_name: str, val: Any, key: tuple[KeyType, ...] | None = None, **kw) -> Any: fn = getattr(transform, "transform_%s" % type_name, transform.transform_custom) val = fn(val, key=key, **kw) return val - def string_handler(s, key=None): + def string_handler(s: str | bytes, key: tuple[KeyType, ...] | None = None): if isinstance(s, bytes): return do_transform("bytes", s, key=key) - elif isinstance(s, str): - return do_transform("unicode", s, key=key) + # Otherwise it's a string + return do_transform("unicode", s, key=key) - def default_handler(o, key=None): + def default_handler(o, key: tuple[KeyType, ...] | None = None): if isinstance(o, bool): return do_transform("boolean", o, key=key) @@ -72,7 +90,7 @@ def default_handler(o, key=None): return do_transform("custom", o, key=key) - handlers = { + handlers: Handlers = { "string_handler": string_handler, "tuple_handler": lambda o, key=None: do_transform("tuple", o, key=key), "namedtuple_handler": lambda o, key=None: do_transform( diff --git a/rollbar/lib/transforms/batched.py b/rollbar/lib/transforms/batched.py index b0d5d04d..1f1eba59 100644 --- a/rollbar/lib/transforms/batched.py +++ b/rollbar/lib/transforms/batched.py @@ -1,3 +1,5 @@ +from typing import Any, Callable, TypedDict + from rollbar.lib.transform import Transform from rollbar.lib import ( number_types, @@ -5,6 +7,22 @@ ) +Handler = Callable[..., Any] + + +class _NamedHandlers(TypedDict): + """Shape of the named handler table used to build the int-keyed map.""" + + STRING: Handler + TUPLE: Handler + NAMEDTUPLE: Handler + LIST: Handler + SET: Handler + MAPPING: Handler + CIRCULAR: Handler + DEFAULT: Handler + + def do_transform(transform, type_name, val, key=None, **kw): fn = getattr(transform, "transform_%s" % type_name, transform.transform_custom) val = fn(val, key=key, **kw) @@ -36,27 +54,32 @@ def default_handler(transform, o, key=None): return do_transform(transform, "custom", o, key=key) -handlers = { - type_info.STRING: string_handler, - type_info.TUPLE: lambda transform, o, key=None: do_transform( - transform, "tuple", o, key=key - ), - type_info.NAMEDTUPLE: lambda transform, o, key=None: do_transform( +_named_handlers: _NamedHandlers = { + "STRING": string_handler, + "TUPLE": lambda transform, o, key=None: do_transform(transform, "tuple", o, key=key), + "NAMEDTUPLE": lambda transform, o, key=None: do_transform( transform, "namedtuple", o, key=key ), - type_info.LIST: lambda transform, o, key=None: do_transform( - transform, "list", o, key=key - ), - type_info.SET: lambda transform, o, key=None: do_transform( - transform, "set", o, key=key - ), - type_info.MAPPING: lambda transform, o, key=None: do_transform( - transform, "dict", o, key=key - ), - type_info.CIRCULAR: lambda transform, o, key=None, ref_key=None: do_transform( + "LIST": lambda transform, o, key=None: do_transform(transform, "list", o, key=key), + "SET": lambda transform, o, key=None: do_transform(transform, "set", o, key=key), + "MAPPING": lambda transform, o, key=None: do_transform(transform, "dict", o, key=key), + "CIRCULAR": lambda transform, o, key=None, ref_key=None: do_transform( transform, "circular_reference", o, key=key, ref_key=ref_key ), - type_info.DEFAULT: default_handler, + "DEFAULT": default_handler, +} + + +# Keep a numeric-keyed table for compatibility with type_info.get_type(). +handlers: dict[int, Handler] = { + type_info.STRING: _named_handlers["STRING"], + type_info.TUPLE: _named_handlers["TUPLE"], + type_info.NAMEDTUPLE: _named_handlers["NAMEDTUPLE"], + type_info.LIST: _named_handlers["LIST"], + type_info.SET: _named_handlers["SET"], + type_info.MAPPING: _named_handlers["MAPPING"], + type_info.CIRCULAR: _named_handlers["CIRCULAR"], + type_info.DEFAULT: _named_handlers["DEFAULT"], } @@ -68,7 +91,7 @@ def __init__(self, transforms): def default(self, o, key=None): for transform in self._transforms: node_type = type_info.get_type(o) - handler = handlers.get(node_type, handlers.get(type_info.DEFAULT)) + handler = handlers.get(node_type, handlers[type_info.DEFAULT]) o = handler(transform, o, key=key) return o diff --git a/rollbar/lib/transforms/scrub_redact.py b/rollbar/lib/transforms/scrub_redact.py index 09b9a76d..469c0bb5 100644 --- a/rollbar/lib/transforms/scrub_redact.py +++ b/rollbar/lib/transforms/scrub_redact.py @@ -17,4 +17,4 @@ def default(self, o, key=None): return super(ScrubRedactTransform, self).default(o, key=key) -__all__ = ['ScrubRedactTransform'] +__all__ = ['ScrubRedactTransform', 'REDACT_REF'] diff --git a/rollbar/lib/transforms/serializable.py b/rollbar/lib/transforms/serializable.py index 832d0ab6..2f526b2b 100644 --- a/rollbar/lib/transforms/serializable.py +++ b/rollbar/lib/transforms/serializable.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import math from rollbar.lib import binary_type, string_types @@ -27,7 +29,7 @@ def transform_namedtuple(self, o, key=None): return '<%s>' % str(o._make(new_vals)) - def transform_number(self, o, key=None): + def transform_number(self, o: float | int, key=None) -> float | int | str: if math.isnan(o): return float_nan_label(o) elif math.isinf(o): @@ -35,7 +37,7 @@ def transform_number(self, o, key=None): else: return o - def transform_bytes(self, o, key=None): + def transform_bytes(self, o, key=None) -> bytes | str: try: o.decode('utf8') except UnicodeDecodeError: diff --git a/rollbar/lib/transforms/shortener.py b/rollbar/lib/transforms/shortener.py index fd5a35b3..dd337358 100644 --- a/rollbar/lib/transforms/shortener.py +++ b/rollbar/lib/transforms/shortener.py @@ -1,10 +1,11 @@ +from __future__ import annotations + from array import array import collections import itertools import reprlib from collections.abc import Mapping -from typing import Union, Tuple from rollbar.lib import ( integer_types, key_in, key_depth, sequence_types, @@ -26,7 +27,7 @@ } -def _max_left_right(max_len: int, seperator_len: int) -> Tuple[int, int]: +def _max_left_right(max_len: int, seperator_len: int) -> tuple[int, int]: left = max(0, (max_len-seperator_len)//2) right = max(0, max_len-seperator_len-left) return left, right @@ -60,7 +61,7 @@ def shorten_frozenset(obj: frozenset, max_len: int) -> frozenset: return frozenset([elem for i, elem in enumerate(obj) if i < max_len] + ['...']) -def shorten_int(obj: int, max_len: int) -> Union[int, str]: +def shorten_int(obj: int, max_len: int) -> int | str: s = repr(obj) if len(s) <= max_len: return obj @@ -76,7 +77,7 @@ def shorten_list(obj: list, max_len: int) -> list: return obj[:max_len] + ['...'] -def shorten_mapping(obj: Union[dict, Mapping], max_keys: int) -> dict: +def shorten_mapping(obj: dict | Mapping, max_keys: int) -> dict | Mapping: if len(obj) <= max_keys: return obj @@ -106,10 +107,10 @@ def shorten_tuple(obj: tuple, max_len: int) -> tuple: class ShortenerTransform(Transform): - depth_first = False - priority = 10 + depth_first: bool = False + priority: int = 10 - def __init__(self, safe_repr=True, keys=None, **sizes): + def __init__(self, safe_repr=True, keys: list[tuple[str, ...]] | None = None, **sizes): super(ShortenerTransform, self).__init__() self.safe_repr = safe_repr self.keys = keys diff --git a/rollbar/lib/transport.py b/rollbar/lib/transport.py index 320c5e49..c0c106c6 100644 --- a/rollbar/lib/transport.py +++ b/rollbar/lib/transport.py @@ -1,6 +1,4 @@ -from typing import Optional - -import requests +import requests # type: ignore[import-untyped] import threading @@ -14,7 +12,7 @@ def _session(): return _local.session -def _get_proxy_cfg(kw: dict) -> Optional[dict]: +def _get_proxy_cfg(kw: dict) -> dict | None: proxy = kw.pop('proxy', None) proxy_user = kw.pop('proxy_user', None) proxy_password = kw.pop('proxy_password', None) @@ -28,6 +26,7 @@ def _get_proxy_cfg(kw: dict) -> Optional[dict]: 'http': f'http://{proxy}', 'https': f'http://{proxy}', } + return None def configure_pool(**kw): diff --git a/rollbar/lib/traverse.py b/rollbar/lib/traverse.py index 47d4bc8a..cb858270 100644 --- a/rollbar/lib/traverse.py +++ b/rollbar/lib/traverse.py @@ -1,7 +1,11 @@ +from __future__ import annotations + import logging +from os import PathLike from pathlib import Path +from typing import Any, NamedTuple, Callable -from rollbar.lib import binary_type, string_types, circular_reference_label +from rollbar.lib import circular_reference_label # NOTE: Don't remove this line of code as it would cause a breaking change # to the library's API. The items imported here were originally in this file @@ -17,44 +21,49 @@ SET, STRING, PATH, + KeyType, ) log = logging.getLogger(__name__) -def _noop_circular(a, **kw): +def _noop_circular(a, **kw) -> str: return circular_reference_label(a, ref_key=kw.get("ref_key")) -def _noop(a, **_): +def _noop(a: Any, **_) -> Any: return a -def _noop_tuple(a, **_): +def _noop_tuple(a: tuple, **_) -> tuple: return tuple(a) -def _noop_namedtuple(a, **_): +def _noop_namedtuple(a: NamedTuple, **_) -> NamedTuple: return a._make(a) -def _noop_list(a, **_): +def _noop_list(a: list, **_) -> list: return list(a) -def _noop_set(a, **_): +def _noop_set(a: set, **_) -> set: return set(a) -def _noop_mapping(a, **_): +def _noop_mapping(a, **_) -> dict: return dict(a) -def _noop_path(a, **_): + +def _noop_path(a: PathLike, **_) -> PathLike: return Path(a) +# A generic handler: accepts arbitrary args/kwargs and returns Any. +Handler = Callable[..., Any] + -_default_handlers = { +_default_handlers: dict[int, Handler] = { CIRCULAR: _noop_circular, DEFAULT: _noop, STRING: _noop, @@ -68,22 +77,22 @@ def _noop_path(a, **_): def traverse( - obj, - key=(), - string_handler=_default_handlers[STRING], - tuple_handler=_default_handlers[TUPLE], - namedtuple_handler=_default_handlers[NAMEDTUPLE], - list_handler=_default_handlers[LIST], - set_handler=_default_handlers[SET], - mapping_handler=_default_handlers[MAPPING], - path_handler=_default_handlers[PATH], - default_handler=_default_handlers[DEFAULT], - circular_reference_handler=_default_handlers[CIRCULAR], - allowed_circular_reference_types=None, - memo=None, - depth_first=True, - **custom_handlers -): + obj: Any, + key: tuple[KeyType, ...] = (), + string_handler: Handler = _default_handlers[STRING], + tuple_handler: Handler = _default_handlers[TUPLE], + namedtuple_handler: Handler = _default_handlers[NAMEDTUPLE], + list_handler: Handler = _default_handlers[LIST], + set_handler: Handler = _default_handlers[SET], + mapping_handler: Handler = _default_handlers[MAPPING], + path_handler: Handler = _default_handlers[PATH], + default_handler: Handler = _default_handlers[DEFAULT], + circular_reference_handler: Handler = _default_handlers[CIRCULAR], + allowed_circular_reference_types: type | tuple[type, ...] | None = None, + memo: dict[int, tuple[KeyType, ...]] | None = None, + depth_first: bool = True, + **custom_handlers: Handler, +) -> Any: memo = memo or {} obj_id = id(obj) obj_type = get_type(obj) @@ -97,7 +106,7 @@ def traverse( memo[obj_id] = key - kw = { + kw: dict[str, Any] = { "string_handler": string_handler, "tuple_handler": tuple_handler, "namedtuple_handler": namedtuple_handler, @@ -165,7 +174,8 @@ def traverse( return path_handler(obj, key=key) elif obj_type is DEFAULT: for handler_type, handler in custom_handlers.items(): - if isinstance(obj, handler_type): + # Only attempt isinstance checks when the key is a type (or tuple of types). + if isinstance(handler_type, (type, tuple)) and isinstance(obj, handler_type): return handler(obj, key=key) except: # use the default handler for unknown object types diff --git a/rollbar/lib/type_info.py b/rollbar/lib/type_info.py index 9d1bd239..a754c736 100644 --- a/rollbar/lib/type_info.py +++ b/rollbar/lib/type_info.py @@ -1,7 +1,11 @@ +from __future__ import annotations + +from typing import Hashable + from rollbar.lib import binary_type, string_types -from collections.abc import Mapping, Sequence, Set +from collections.abc import Mapping, Sequence from pathlib import Path @@ -40,6 +44,9 @@ def get_type(obj): return DEFAULT +# The keys will be coming from data objects in the payload, so they could be just about anything hashable. +KeyType = str | int | float | binary_type | Hashable + __all__ = [ "CIRCULAR", @@ -52,4 +59,5 @@ def get_type(obj): "STRING", "PATH", "get_type", + "KeyType", ] diff --git a/rollbar/logger.py b/rollbar/logger.py index fccd9050..9f4f1302 100644 --- a/rollbar/logger.py +++ b/rollbar/logger.py @@ -18,6 +18,8 @@ logger.addHandler(rollbar_handler) """ +from __future__ import annotations + import logging import threading @@ -25,11 +27,21 @@ import rollbar -# hack to fix backward compatibility in Python3 -try: - from logging import _checkLevel -except ImportError: - _checkLevel = lambda lvl: lvl + +def check_level(level: str | int ) -> int: + """ + Convert level to numeric logging level. + """ + if isinstance(level, int): + return level + elif isinstance(level, str): + # Note: getLevelName() returns an `int` if the arg is a valid level name `str` and returns a `str` if the arg is + # a valid level `int`. + result = logging.getLevelName(level) + if isinstance(result, int): + return result + raise ValueError(f"Unknown level: {level!r}") + raise TypeError(f"Level not an integer or a valid string: {level!r}") EXCLUDE_RECORD_KEYS = { @@ -76,7 +88,7 @@ def __init__(self, allow_logging_basic_config=False, # a handler shouldn't configure the root logger **resolve_logging_types(kw)) - self.notify_level = _checkLevel(level) + self.notify_level = check_level(level) self.history_size = history_size if history_size > 0: @@ -90,7 +102,7 @@ def setLevel(self, level): log records we notify Rollbar about instead of which records we save to the history. """ - self.notify_level = _checkLevel(level) + self.notify_level = check_level(level) def setHistoryLevel(self, level): """ diff --git a/rollbar/test/async_tests/test_async.py b/rollbar/test/async_tests/test_async.py index 6843c8ec..dec87661 100644 --- a/rollbar/test/async_tests/test_async.py +++ b/rollbar/test/async_tests/test_async.py @@ -225,31 +225,6 @@ def test_ctx_manager_should_use_async_handler(self, mock_log): ) rollbar.SETTINGS['handler'] = 'thread' - @mock.patch('logging.Logger.warning') - def test_ctx_manager_should_use_global_handler_if_contextvar_is_not_supported( - self, mock_log - ): - import rollbar - import rollbar.lib._async - from rollbar.lib._async import AsyncHandler - - try: - # simulate missing `contextvars` module - _ctx_handler = rollbar.lib._async._ctx_handler - rollbar.lib._async._ctx_handler = None - - rollbar.SETTINGS['handler'] = 'thread' - self.assertEqual(rollbar.SETTINGS['handler'], 'thread') - - with AsyncHandler() as handler: - self.assertEqual(handler, 'thread') - mock_log.assert_not_called() - - self.assertEqual(rollbar.SETTINGS['handler'], 'thread') - finally: - # restore original _ctx_handler - rollbar.lib._async._ctx_handler = _ctx_handler - @mock.patch('logging.Logger.warning') def test_ctx_manager_should_not_substitute_global_handler(self, mock_log): import rollbar diff --git a/rollbar/test/fastapi_tests/test_logger.py b/rollbar/test/fastapi_tests/test_logger.py index b852179e..c02a1c58 100644 --- a/rollbar/test/fastapi_tests/test_logger.py +++ b/rollbar/test/fastapi_tests/test_logger.py @@ -124,29 +124,3 @@ async def read_root(): client = TestClient(app) client.get('/') - - @mock.patch('rollbar.contrib.starlette.requests.ContextVar', None) - @mock.patch('logging.Logger.error') - def test_should_not_return_current_request_for_older_python(self, mock_log): - from fastapi import FastAPI - from rollbar.contrib.fastapi.logger import LoggerMiddleware - from rollbar.contrib.fastapi import get_current_request - - try: - from fastapi.testclient import TestClient - except ImportError: # Added in FastAPI v0.51.0+ - from starlette.testclient import TestClient - - app = FastAPI() - app.add_middleware(LoggerMiddleware) - - @app.get('/') - async def read_root(): - self.assertIsNone(get_current_request()) - mock_log.assert_called_once_with( - 'Python 3.7+ (or aiocontextvars package)' - ' is required to receive current request.' - ) - - client = TestClient(app) - client.get('/') diff --git a/rollbar/test/fastapi_tests/test_middleware.py b/rollbar/test/fastapi_tests/test_middleware.py index c3dd4754..2dfe1029 100644 --- a/rollbar/test/fastapi_tests/test_middleware.py +++ b/rollbar/test/fastapi_tests/test_middleware.py @@ -331,37 +331,6 @@ async def read_root(original_request: Request): client = TestClient(app) client.get('/') - @mock.patch('rollbar.contrib.starlette.requests.ContextVar', None) - @mock.patch('logging.Logger.error') - def test_should_not_return_current_request_for_older_python(self, mock_log): - from fastapi import FastAPI - from rollbar.contrib.fastapi.middleware import ReporterMiddleware - from rollbar.contrib.fastapi import get_current_request - - try: - from fastapi import Request - from fastapi.testclient import TestClient - except ImportError: # Added in FastAPI v0.51.0+ - from starlette.requests import Request - from starlette.testclient import TestClient - - app = FastAPI() - app.add_middleware(ReporterMiddleware) - - @app.get('/') - async def read_root(original_request: Request): - request = get_current_request() - - self.assertIsNone(request) - self.assertNotEqual(request, original_request) - mock_log.assert_called_once_with( - 'Python 3.7+ (or aiocontextvars package)' - ' is required to receive current request.' - ) - - client = TestClient(app) - client.get('/') - def test_should_support_http_only(self): from rollbar.contrib.fastapi.middleware import ReporterMiddleware from rollbar.lib._async import FailingTestASGIApp, run diff --git a/rollbar/test/fastapi_tests/test_routing.py b/rollbar/test/fastapi_tests/test_routing.py index 64214d58..0faa97c5 100644 --- a/rollbar/test/fastapi_tests/test_routing.py +++ b/rollbar/test/fastapi_tests/test_routing.py @@ -3,13 +3,14 @@ import json import sys +from packaging.version import Version from unittest import mock try: import fastapi FASTAPI_INSTALLED = True - ALLOWED_FASTAPI_VERSION = fastapi.__version__ >= '0.41.0' + ALLOWED_FASTAPI_VERSION = Version(fastapi.__version__) >= Version('0.41.0') except ImportError: FASTAPI_INSTALLED = False ALLOWED_FASTAPI_VERSION = False @@ -183,9 +184,10 @@ def read_root(path): @mock.patch('rollbar._check_config', return_value=True) @mock.patch('rollbar._serialize_frame_data') @mock.patch('rollbar.send_payload') - def test_should_send_payload_with_request_body(self, mock_send_payload, *mocks): + def test_should_send_payload_with_request_body(self, mock_send_payload, *mocks) -> None: from fastapi import Body, FastAPI from pydantic import BaseModel + from httpx.__version__ import __version__ as httpx_version from rollbar.contrib.fastapi.routing import add_to as rollbar_add_to try: @@ -218,14 +220,21 @@ def read_root(body: TestBody = Body(...)): self.assertEqual(payload_request['method'], 'POST') self.assertEqual(payload_request['user_ip'], 'testclient') self.assertEqual(payload_request['url'], 'http://testserver/') - self.assertEqual(payload_request['body'], json.dumps(expected_body)) + + # Starting in httpx 0.28.0 the encoded JSON was compacted. + if Version(httpx_version) >= Version('0.28.0'): + body = json.dumps(expected_body, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + else: + body = json.dumps(expected_body) + + self.assertEqual(payload_request['body'], body) self.assertDictEqual( payload_request['headers'], { 'accept': '*/*', 'accept-encoding': 'gzip, deflate', 'connection': 'keep-alive', - 'content-length': str(len(json.dumps(expected_body))), + 'content-length': str(len(body)), 'content-type': 'application/json', 'host': 'testserver', 'user-agent': 'testclient', @@ -258,7 +267,7 @@ def read_root(param1: str = Form(...), param2: str = Form(...)): with self.assertRaises(ZeroDivisionError): r = client.post( '/', - data=expected_body, + content=expected_body, headers={'Content-Type': 'application/x-www-form-urlencoded'}, ) @@ -443,16 +452,16 @@ def test_should_disable_loading_route_handler_after_adding_routes_to_app( self, mock_log ): from fastapi import FastAPI + from starlette.routing import Route from rollbar.contrib.fastapi.routing import add_to as rollbar_add_to - app = FastAPI() - old_route_class = app.router.route_class - self.assertEqual(len(app.routes), 4) - - @app.get('/') async def read_root(): ... + app = FastAPI(routes=[ + Route('/', read_root), + ]) + old_route_class = app.router.route_class self.assertEqual(len(app.routes), 5) new_route_class = rollbar_add_to(app) @@ -689,6 +698,7 @@ def test_should_warn_if_middleware_in_use(self): @mock.patch('rollbar.contrib.fastapi.routing.store_current_request') def test_should_store_current_request(self, store_current_request): from fastapi import FastAPI + from starlette.routing import Route from rollbar.contrib.fastapi.routing import add_to as rollbar_add_to try: @@ -728,7 +738,7 @@ async def read_root(): store_current_request.assert_called_once() scope = store_current_request.call_args[0][0] - self.assertEqual(scope, {**expected_scope, **scope}) + self.assertEqual(scope.scope, {**expected_scope, **scope}) @unittest.skipUnless( sys.version_info >= (3, 6), 'Global request access is supported in Python 3.6+' @@ -757,39 +767,8 @@ async def read_root(original_request: Request): client = TestClient(app) client.get('/') - @mock.patch('rollbar.contrib.starlette.requests.ContextVar', None) - @mock.patch('logging.Logger.error') - def test_should_not_return_current_request_for_older_python(self, mock_log): - from fastapi import FastAPI - from rollbar.contrib.fastapi import get_current_request - from rollbar.contrib.fastapi.routing import add_to as rollbar_add_to - - try: - from fastapi import Request - from fastapi.testclient import TestClient - except ImportError: # Added in FastAPI v0.51.0+ - from starlette.requests import Request - from starlette.testclient import TestClient - - app = FastAPI() - rollbar_add_to(app) - - @app.get('/') - async def read_root(original_request: Request): - request = get_current_request() - - self.assertIsNone(request) - self.assertNotEqual(request, original_request) - mock_log.assert_called_once_with( - 'Python 3.7+ (or aiocontextvars package)' - ' is required to receive current request.' - ) - - client = TestClient(app) - client.get('/') - def test_should_support_type_hints(self): - from typing import Optional, Type, Union + from typing import Type from fastapi import APIRouter, FastAPI from fastapi.routing import APIRoute import rollbar.contrib.fastapi.routing @@ -797,7 +776,7 @@ def test_should_support_type_hints(self): self.assertDictEqual( rollbar.contrib.fastapi.routing.add_to.__annotations__, { - 'app_or_router': Union[FastAPI, APIRouter], - 'return': Optional[Type[APIRoute]], + 'app_or_router': 'FastAPI | APIRouter', + 'return': 'Type[APIRoute] | None', }, ) diff --git a/rollbar/test/starlette_tests/test_logger.py b/rollbar/test/starlette_tests/test_logger.py index f392fb33..080cf38b 100644 --- a/rollbar/test/starlette_tests/test_logger.py +++ b/rollbar/test/starlette_tests/test_logger.py @@ -60,6 +60,7 @@ def test_should_support_type_hints(self): @mock.patch('rollbar.contrib.starlette.logger.store_current_request') def test_should_store_current_request(self, store_current_request): from starlette.applications import Starlette + from starlette.routing import Route from starlette.responses import PlainTextResponse from starlette.testclient import TestClient from rollbar.contrib.starlette.logger import LoggerMiddleware @@ -83,13 +84,14 @@ def test_should_store_current_request(self, store_current_request): 'type': 'http', } - app = Starlette() - app.add_middleware(LoggerMiddleware) - - @app.route('/{param}') async def root(request): return PlainTextResponse('OK') + app = Starlette(routes=[ + Route('/', endpoint=root), + ]) + app.add_middleware(LoggerMiddleware) + client = TestClient(app) client.get('/') @@ -100,43 +102,21 @@ async def root(request): def test_should_return_current_request(self): from starlette.applications import Starlette + from starlette.routing import Route from starlette.responses import PlainTextResponse from starlette.testclient import TestClient from rollbar.contrib.starlette import get_current_request from rollbar.contrib.starlette.logger import LoggerMiddleware - app = Starlette() - app.add_middleware(LoggerMiddleware) - - @app.route('/') async def root(request): self.assertIsNotNone(get_current_request()) return PlainTextResponse('OK') - client = TestClient(app) - client.get('/') - - @mock.patch('rollbar.contrib.starlette.requests.ContextVar', None) - @mock.patch('logging.Logger.error') - def test_should_not_return_current_request_for_older_python(self, mock_log): - from starlette.applications import Starlette - from starlette.responses import PlainTextResponse - from starlette.testclient import TestClient - from rollbar.contrib.starlette import get_current_request - from rollbar.contrib.starlette.logger import LoggerMiddleware - - app = Starlette() + app = Starlette(routes=[ + Route('/', endpoint=root), + ]) app.add_middleware(LoggerMiddleware) - @app.route('/') - async def root(request): - self.assertIsNone(get_current_request()) - mock_log.assert_called_once_with( - 'Python 3.7+ (or aiocontextvars package) is required to receive current request.' - ) - - return PlainTextResponse('OK') - client = TestClient(app) client.get('/') diff --git a/rollbar/test/starlette_tests/test_middleware.py b/rollbar/test/starlette_tests/test_middleware.py index 367ef67c..059f9ba7 100644 --- a/rollbar/test/starlette_tests/test_middleware.py +++ b/rollbar/test/starlette_tests/test_middleware.py @@ -35,16 +35,18 @@ def setUp(self): @mock.patch('rollbar.report_exc_info') def test_should_catch_and_report_errors(self, mock_report): from starlette.applications import Starlette + from starlette.routing import Route from starlette.testclient import TestClient from rollbar.contrib.starlette.middleware import ReporterMiddleware - app = Starlette() - app.add_middleware(ReporterMiddleware) - - @app.route('/') async def root(request): 1 / 0 + app = Starlette(routes=[ + Route('/', root), + ]) + app.add_middleware(ReporterMiddleware) + client = TestClient(app) with self.assertRaises(ZeroDivisionError): client.get('/') @@ -62,17 +64,19 @@ async def root(request): @mock.patch('rollbar.report_exc_info') def test_should_report_with_request_data(self, mock_report): from starlette.applications import Starlette + from starlette.routing import Route from starlette.requests import Request from starlette.testclient import TestClient from rollbar.contrib.starlette.middleware import ReporterMiddleware - app = Starlette() - app.add_middleware(ReporterMiddleware) - - @app.route('/') async def root(request): 1 / 0 + app = Starlette(routes=[ + Route('/', root), + ]) + app.add_middleware(ReporterMiddleware) + client = TestClient(app) with self.assertRaises(ZeroDivisionError): client.get('/') @@ -87,17 +91,18 @@ async def root(request): @mock.patch('rollbar.send_payload') def test_should_send_payload_with_request_data(self, mock_send_payload, *mocks): from starlette.applications import Starlette - from starlette.requests import Request + from starlette.routing import Route from starlette.testclient import TestClient from rollbar.contrib.starlette.middleware import ReporterMiddleware - app = Starlette() - app.add_middleware(ReporterMiddleware) - - @app.route('/{path}') async def root(request): 1 / 0 + app = Starlette(routes=[ + Route('/{path}', root), + ]) + app.add_middleware(ReporterMiddleware) + client = TestClient(app) with self.assertRaises(ZeroDivisionError): client.get('/test?param1=value1¶m2=value2') @@ -155,19 +160,21 @@ def test_should_use_async_report_exc_info_if_default_handler( self, sync_report_exc_info, async_report_exc_info ): from starlette.applications import Starlette + from starlette.routing import Route from starlette.testclient import TestClient import rollbar from rollbar.contrib.starlette.middleware import ReporterMiddleware rollbar.SETTINGS['handler'] = 'default' - app = Starlette() - app.add_middleware(ReporterMiddleware) - - @app.route('/') async def root(request): 1 / 0 + app = Starlette(routes=[ + Route('/', endpoint=root), + ]) + app.add_middleware(ReporterMiddleware) + client = TestClient(app) with self.assertRaises(ZeroDivisionError): client.get('/') @@ -181,19 +188,21 @@ def test_should_use_async_report_exc_info_if_any_async_handler( self, sync_report_exc_info, async_report_exc_info ): from starlette.applications import Starlette + from starlette.routing import Route from starlette.testclient import TestClient import rollbar from rollbar.contrib.starlette.middleware import ReporterMiddleware rollbar.SETTINGS['handler'] = 'httpx' - app = Starlette() - app.add_middleware(ReporterMiddleware) - - @app.route('/') async def root(request): 1 / 0 + app = Starlette(routes=[ + Route('/', endpoint=root), + ]) + app.add_middleware(ReporterMiddleware) + client = TestClient(app) with self.assertRaises(ZeroDivisionError): client.get('/') @@ -208,19 +217,21 @@ def test_should_use_sync_report_exc_info_if_non_async_handlers( self, sync_report_exc_info, async_report_exc_info, mock_log ): from starlette.applications import Starlette + from starlette.routing import Route from starlette.testclient import TestClient import rollbar from rollbar.contrib.starlette.middleware import ReporterMiddleware rollbar.SETTINGS['handler'] = 'threading' - app = Starlette() - app.add_middleware(ReporterMiddleware) - - @app.route('/') async def root(request): 1 / 0 + app = Starlette(routes=[ + Route('/', endpoint=root), + ]) + app.add_middleware(ReporterMiddleware) + client = TestClient(app) with self.assertRaises(ZeroDivisionError): client.get('/') @@ -237,6 +248,7 @@ async def root(request): @mock.patch('rollbar.contrib.starlette.middleware.store_current_request') def test_should_store_current_request(self, store_current_request): from starlette.applications import Starlette + from starlette.routing import Route from starlette.responses import PlainTextResponse from starlette.testclient import TestClient from rollbar.contrib.starlette.middleware import ReporterMiddleware @@ -260,13 +272,14 @@ def test_should_store_current_request(self, store_current_request): 'type': 'http', } - app = Starlette() - app.add_middleware(ReporterMiddleware) - - @app.route('/{param}') async def root(request): return PlainTextResponse('OK') + app = Starlette(routes=[ + Route('/{param}', endpoint=root), + ]) + app.add_middleware(ReporterMiddleware) + client = TestClient(app) client.get('/') @@ -280,15 +293,12 @@ async def root(request): ) def test_should_return_current_request(self): from starlette.applications import Starlette + from starlette.routing import Route from starlette.responses import PlainTextResponse from starlette.testclient import TestClient from rollbar.contrib.starlette.middleware import ReporterMiddleware from rollbar.contrib.starlette import get_current_request - app = Starlette() - app.add_middleware(ReporterMiddleware) - - @app.route('/') async def root(original_request): request = get_current_request() @@ -296,34 +306,11 @@ async def root(original_request): return PlainTextResponse('OK') - client = TestClient(app) - client.get('/') - - @mock.patch('rollbar.contrib.starlette.requests.ContextVar', None) - @mock.patch('logging.Logger.error') - def test_should_not_return_current_request_for_older_python(self, mock_log): - from starlette.applications import Starlette - from starlette.responses import PlainTextResponse - from starlette.testclient import TestClient - from rollbar.contrib.starlette.middleware import ReporterMiddleware - from rollbar.contrib.starlette import get_current_request - - app = Starlette() + app = Starlette(routes=[ + Route('/{param}', endpoint=root), + ]) app.add_middleware(ReporterMiddleware) - @app.route('/') - async def root(original_request): - request = get_current_request() - - self.assertIsNone(request) - self.assertNotEqual(request, original_request) - mock_log.assert_called_once_with( - 'Python 3.7+ (or aiocontextvars package)' - ' is required to receive current request.' - ) - - return PlainTextResponse('OK') - client = TestClient(app) client.get('/') diff --git a/rollbar/test/test_pyramid.py b/rollbar/test/test_pyramid.py index 63c4a3ca..7c971695 100644 --- a/rollbar/test/test_pyramid.py +++ b/rollbar/test/test_pyramid.py @@ -3,7 +3,7 @@ from rollbar.test import BaseTest try: - from pyramid.request import Request + from pyramid.request import Request # type: ignore[import-untyped, import-not-found] PYRAMID_INSTALLED = True except ImportError: diff --git a/rollbar/test/test_rollbar.py b/rollbar/test/test_rollbar.py index fcc8760a..c95fead5 100644 --- a/rollbar/test/test_rollbar.py +++ b/rollbar/test/test_rollbar.py @@ -4,17 +4,13 @@ import socket import threading import uuid +from io import StringIO import sys from collections import namedtuple from rollbar.lib.session import reset_current_session -try: - from StringIO import StringIO -except ImportError: - from io import StringIO - from pathlib import Path from unittest import mock @@ -1197,7 +1193,11 @@ def test_failed_locals_serialization(self, send_payload): class tmp(object): @property def __class__(self): - foo() + return foo() + + @__class__.setter + def __class__(self, value): + pass try: t = tmp() @@ -1602,7 +1602,7 @@ def _raise(): payload = send_payload.call_args[0][0] self.assertRegex(payload['data']['body']['trace']['frames'][-1]['locals']['password'], r'\*+') - self.assertRegex(payload['data']['body']['trace']['frames'][-1]['locals']['Data'], 'password=\'\*+\'') + self.assertRegex(payload['data']['body']['trace']['frames'][-1]['locals']['Data'], 'password=\'\\*+\'') @mock.patch('rollbar.send_payload') def test_scrub_nans(self, send_payload): @@ -1894,7 +1894,7 @@ def test_scrub_webob_request_data(self): rollbar.init(_test_access_token, locals={'enabled': True}, dummy_key='asdf', handler='blocking', timeout=12345, scrub_fields=rollbar.SETTINGS['scrub_fields'] + ['token', 'secret', 'cookies', 'authorization']) - import webob + import webob # type: ignore[import-untyped] request = webob.Request.blank('/the/path?q=hello&password=hunter2', base_url='http://example.com', headers={ diff --git a/rollbar/test/test_scrub_redact_transform.py b/rollbar/test/test_scrub_redact_transform.py index d3f2a98e..bb2ea4fd 100644 --- a/rollbar/test/test_scrub_redact_transform.py +++ b/rollbar/test/test_scrub_redact_transform.py @@ -11,10 +11,7 @@ class NotRedactRef(): NOT_REDACT_REF = NotRedactRef() -try: - SCRUBBED = '*' * len(REDACT_REF) -except: - SCRUBBED = '*' * len(str(REDACT_REF)) +SCRUBBED = '*' * len(str(REDACT_REF)) class ScrubRedactTransformTest(BaseTest): diff --git a/rollbar/test/test_shortener_transform.py b/rollbar/test/test_shortener_transform.py index eeaf38eb..b3cdd169 100644 --- a/rollbar/test/test_shortener_transform.py +++ b/rollbar/test/test_shortener_transform.py @@ -5,6 +5,7 @@ from rollbar import DEFAULT_LOCALS_SIZES, SETTINGS from rollbar.lib import transforms from rollbar.lib.transforms.shortener import ShortenerTransform +from rollbar.lib.type_info import KeyType from rollbar.test import BaseTest @@ -16,9 +17,9 @@ class KeyMemShortenerTransform(ShortenerTransform): """ A shortener that just stores the keys. """ - keysUsed = [] + keysUsed: list[KeyType] = [] - def default(self, o, key=None): + def default(self, o, key: KeyType = None): self.keysUsed.append((key, o)) return super(KeyMemShortenerTransform, self).default(o, key=key) diff --git a/rollbar/test/test_traverse.py b/rollbar/test/test_traverse.py index 5606070e..78f46659 100644 --- a/rollbar/test/test_traverse.py +++ b/rollbar/test/test_traverse.py @@ -24,7 +24,7 @@ class KeyMemTransform(Transform): """ A transform that just stores the keys. """ - keys = [] + keys: list = [] def default(self, o, key=None): self.keys.append((key, o)) diff --git a/rollbar/test/utils.py b/rollbar/test/utils.py index 88294592..6d58efd3 100644 --- a/rollbar/test/utils.py +++ b/rollbar/test/utils.py @@ -1,4 +1,8 @@ +from __future__ import annotations + from collections.abc import Mapping -def get_public_attrs(obj: Mapping) -> dict: +def get_public_attrs(obj: Mapping | None) -> dict: + if obj is None: + return {} return {k: obj[k] for k in obj if not k.startswith('_')}