Skip to content

Commit

Permalink
Release v4.5.0
Browse files Browse the repository at this point in the history
  • Loading branch information
imjoehaines authored Jul 17, 2023
2 parents cc6aa20 + b411e10 commit 955d088
Show file tree
Hide file tree
Showing 14 changed files with 244 additions and 25 deletions.
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
Changelog
=========

## v4.5.0 (2023-07-17)

### Enhancements

* The exception's `__notes__` field will now be sent as metadata if it exists
[#340](https://github.com/bugsnag/bugsnag-python/pull/340)
[0HyperCube](https://github.com/0HyperCube)

* Allows changing the grouping hash when using `BugsnagHandler` via the logger methods' `extra` keyword argument
[#334](https://github.com/bugsnag/bugsnag-python/pull/334)
[0HyperCube](https://github.com/0HyperCube)

* PathLike objects are now accepted as the project path
[#344](https://github.com/bugsnag/bugsnag-python/pull/344)
[0HyperCube](https://github.com/0HyperCube)

### Bug fixes

* Fixes one of the fields being mistakenly replaced with `[RECURSIVE]` when encoding a list or dictionary with identical siblings but no recursion.
[#341](https://github.com/bugsnag/bugsnag-python/pull/341)
[0HyperCube](https://github.com/0HyperCube)

* Fix the ignore class list not accounting for nested classes
[#342](https://github.com/bugsnag/bugsnag-python/pull/342)
[0HyperCube](https://github.com/0HyperCube)

## v4.4.0 (2023-02-21)

### Enhancements
Expand Down
30 changes: 23 additions & 7 deletions bugsnag/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@
MiddlewareStack,
skip_bugsnag_middleware
)
from bugsnag.utils import (fully_qualified_class_name, validate_str_setter,
validate_bool_setter, validate_iterable_setter,
validate_required_str_setter, validate_int_setter)
from bugsnag.utils import (
fully_qualified_class_name,
partly_qualified_class_name,
validate_str_setter,
validate_bool_setter,
validate_iterable_setter,
validate_required_str_setter,
validate_int_setter,
validate_path_setter
)
from bugsnag.delivery import (create_default_delivery, DEFAULT_ENDPOINT,
DEFAULT_SESSIONS_ENDPOINT)
from bugsnag.uwsgi import warn_if_running_uwsgi_without_threads
Expand All @@ -36,6 +43,14 @@
_request_info = ThreadContextVar('bugsnag-request', default=None) # type: ignore # noqa: E501


try:
from os import PathLike
except ImportError:
# PathLike was added in Python 3.6 so fallback to PurePath on Python 3.5 as
# all builtin Path objects inherit from PurePath
from pathlib import PurePath as PathLike # type: ignore


__all__ = ('Configuration', 'RequestConfiguration')
_sentinel = object()

Expand Down Expand Up @@ -362,9 +377,9 @@ def project_root(self):
return self._project_root

@project_root.setter # type: ignore
@validate_str_setter
def project_root(self, value: str):
self._project_root = value
@validate_path_setter
def project_root(self, value: Union[str, PathLike]):
self._project_root = str(value)

@property
def proxy_host(self):
Expand Down Expand Up @@ -521,7 +536,8 @@ def should_ignore(
if isinstance(exception, list):
return any(e.error_class in self.ignore_classes for e in exception)

return fully_qualified_class_name(exception) in self.ignore_classes
return (fully_qualified_class_name(exception) in self.ignore_classes or
partly_qualified_class_name(exception) in self.ignore_classes)

def _create_default_logger(self) -> logging.Logger:
logger = logging.getLogger('bugsnag')
Expand Down
12 changes: 9 additions & 3 deletions bugsnag/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def get_config(key):

self.metadata = {} # type: Dict[str, Dict[str, Any]]
if 'meta_data' in options:
warnings.warn('The Event "metadata" argument has been replaced ' +
warnings.warn('The Event "meta_data" argument has been replaced ' +
'with "metadata"', DeprecationWarning)
for name, tab in options.pop("meta_data").items():
self.add_tab(name, tab)
Expand All @@ -124,10 +124,16 @@ def get_config(key):
for name, tab in options.items():
self.add_tab(name, tab)

if hasattr(exception, "__notes__"):
self.add_tab(
"exception notes",
dict(enumerate(exception.__notes__)) # type: ignore # noqa
)

@property
def meta_data(self) -> Dict[str, Dict[str, Any]]:
warnings.warn('The Event "metadata" property has been replaced ' +
'with "meta_data".', DeprecationWarning)
warnings.warn('The Event "meta_data" property has been replaced ' +
'with "metadata".', DeprecationWarning)
return self.metadata

@property
Expand Down
10 changes: 9 additions & 1 deletion bugsnag/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def __init__(self, client=None, extra_fields=None):
self.custom_metadata_fields = extra_fields
self.callbacks = [self.extract_default_metadata,
self.extract_custom_metadata,
self.extract_severity]
self.extract_severity,
self.extract_grouping_hash]

def emit(self, record: LogRecord):
"""
Expand Down Expand Up @@ -113,6 +114,13 @@ def extract_severity(self, record: LogRecord, options: Dict):
else:
options['severity'] = 'info'

def extract_grouping_hash(self, record: LogRecord, options: Dict):
"""
Add the grouping_hash from a log record to the options
"""
if 'groupingHash' in record.__dict__:
options['grouping_hash'] = record.__dict__['groupingHash']

def extract_custom_metadata(self, record: LogRecord, options: Dict):
"""
Append the contents of selected fields of a record to the metadata
Expand Down
52 changes: 49 additions & 3 deletions bugsnag/tornado/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import tornado
from tornado.web import RequestHandler, HTTPError
from tornado.wsgi import WSGIContainer
from typing import Dict, Any # noqa
from urllib.parse import parse_qs
from urllib.parse import parse_qs, unquote_to_bytes
from bugsnag.breadcrumbs import BreadcrumbType
from bugsnag.utils import (
is_json_content_type,
Expand All @@ -14,6 +13,53 @@
import json


def tornado_environ(request):
"""Copyright The Tornado Web Library Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Converts a tornado request to a WSGI environment.
Taken from tornado's WSGI implementation
https://github.com/tornadoweb/tornado/blob/6e3521da44c349197cf8048c8a6c69d3f4ccd971/tornado/wsgi.py#L207-L246
but without WSGI prefixed entries that require a WSGI application.
"""
hostport = request.host.split(":")
if len(hostport) == 2:
host = hostport[0]
port = int(hostport[1])
else:
host = request.host
port = 443 if request.protocol == "https" else 80
environ = {
"REQUEST_METHOD": request.method,
"SCRIPT_NAME": "",
"PATH_INFO": unquote_to_bytes(request.path).decode("latin1"),
"QUERY_STRING": request.query,
"REMOTE_ADDR": request.remote_ip,
"SERVER_NAME": host,
"SERVER_PORT": str(port),
"SERVER_PROTOCOL": request.version,
}
if "Content-Type" in request.headers:
environ["CONTENT_TYPE"] = request.headers.pop("Content-Type")
if "Content-Length" in request.headers:
environ["CONTENT_LENGTH"] = request.headers.pop("Content-Length")
for key, value in request.headers.items():
environ["HTTP_" + key.replace("-", "_").upper()] = value
return environ


class BugsnagRequestHandler(RequestHandler):
def add_tornado_request_to_notification(self, event: bugsnag.Event):
if not hasattr(self, "request"):
Expand Down Expand Up @@ -42,7 +88,7 @@ def add_tornado_request_to_notification(self, event: bugsnag.Event):
event.add_tab("request", request_tab)

if bugsnag.configure().send_environment:
env = WSGIContainer.environ(self.request)
env = tornado_environ(self.request)
event.add_tab("environment", env)

def _handle_request_exception(self, exc: BaseException):
Expand Down
43 changes: 39 additions & 4 deletions bugsnag/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
from datetime import datetime, timedelta
from urllib.parse import urlparse, urlunsplit, parse_qs


try:
from os import PathLike
except ImportError:
# PathLike was added in Python 3.6 so fallback to PurePath on Python 3.5 as
# all builtin Path objects inherit from PurePath
from pathlib import PurePath as PathLike # type: ignore


MAX_PAYLOAD_LENGTH = 128 * 1024
MAX_STRING_LENGTH = 1024

Expand Down Expand Up @@ -81,6 +90,9 @@ def filter_string_values(self, obj, ignored=None, seen=None):
clean_dict[key] = self.filter_string_values(
value, ignored, seen)

# Only ignore whilst encoding children
ignored.remove(id(obj))

return clean_dict

return obj
Expand Down Expand Up @@ -119,16 +131,25 @@ def _sanitize(self, obj, trim_strings, ignored=None, seen=None):
if id(obj) in ignored:
return self.recursive_value
elif isinstance(obj, dict):
ignored.add(id(obj))
seen.append(obj)
return self._sanitize_dict(obj, trim_strings, ignored, seen)
elif isinstance(obj, (set, tuple, list)):

ignored.add(id(obj))
sanitized = self._sanitize_dict(obj, trim_strings, ignored, seen)
# Only ignore whilst encoding children
ignored.remove(id(obj))

return sanitized
elif isinstance(obj, (set, tuple, list)):
seen.append(obj)

ignored.add(id(obj))
items = []
for value in obj:
items.append(
self._sanitize(value, trim_strings, ignored, seen))
# Only ignore whilst encoding children
ignored.remove(id(obj))

return items
elif trim_strings and isinstance(obj, str):
return obj[:MAX_STRING_LENGTH]
Expand Down Expand Up @@ -243,7 +264,7 @@ def is_json_content_type(value: str) -> bool:
_ignore_modules = ('__main__', 'builtins')


def fully_qualified_class_name(obj):
def partly_qualified_class_name(obj):
module = inspect.getmodule(obj)

if module is None or module.__name__ in _ignore_modules:
Expand All @@ -252,6 +273,19 @@ def fully_qualified_class_name(obj):
return module.__name__ + '.' + obj.__class__.__name__


def fully_qualified_class_name(obj):
module = inspect.getmodule(obj)
if hasattr(obj.__class__, "__qualname__"):
qualified_name = obj.__class__.__qualname__
else:
qualified_name = obj.__class__.__name__

if module is None or module.__name__ in _ignore_modules:
return qualified_name

return module.__name__ + '.' + qualified_name


def package_version(package_name):
try:
import pkg_resources
Expand Down Expand Up @@ -293,6 +327,7 @@ def wrapper(obj, value):
validate_bool_setter = partial(_validate_setter, (bool,))
validate_iterable_setter = partial(_validate_setter, (list, tuple))
validate_int_setter = partial(_validate_setter, (int,))
validate_path_setter = partial(_validate_setter, (str, PathLike))


class ThreadContextVar:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

setup(
name='bugsnag',
version='4.4.0',
version='4.5.0',
description='Automatic error monitoring for django, flask, etc.',
long_description=__doc__,
author='Simon Maynard',
Expand Down
19 changes: 19 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,25 @@ def test_chained_exceptions_with_explicit_cause(self):
}
]

@pytest.mark.skipif(
sys.version_info < (3, 11),
reason="requires BaseException.add_note (Python 3.11 or higher)"
)
def test_notes(self):
e = Exception("exception")
e.add_note("exception note 1")
e.add_note("exception note 2")
self.client.notify(e)
assert self.sent_report_count == 1

payload = self.server.received[0]['json_body']
metadata = payload['events'][0]['metaData']
notes = metadata['exception notes']

assert len(notes) == 2
assert notes['0'] == "exception note 1"
assert notes['1'] == "exception note 2"

def test_chained_exceptions_with_explicit_cause_using_capture_cm(self):
try:
with self.client.capture():
Expand Down
22 changes: 20 additions & 2 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import socket
import logging
import random
import sys
import time
import unittest
from pathlib import PurePath, Path
from unittest.mock import patch
from io import StringIO
from threading import Thread
Expand Down Expand Up @@ -287,19 +289,35 @@ def test_validate_params_filters(self):

def test_validate_project_root(self):
c = Configuration()

if sys.version_info < (3, 6):
expected_type = 'PurePath'
else:
expected_type = 'PathLike'

with pytest.warns(RuntimeWarning) as record:
c.configure(project_root=True)

assert len(record) == 1
assert (str(record[0].message) ==
'project_root should be str, got bool')
assert str(record[0].message) == \
'project_root should be str or %s, got bool' % expected_type
assert c.project_root == os.getcwd()

c.configure(project_root='/path/to/python/project')

assert len(record) == 1
assert c.project_root == '/path/to/python/project'

c.configure(project_root=Path('/path/to/python/project'))

assert len(record) == 1
assert c.project_root == '/path/to/python/project'

c.configure(project_root=PurePath('/path/to/python/project'))

assert len(record) == 1
assert c.project_root == '/path/to/python/project'

def test_validate_proxy_host(self):
c = Configuration()
with pytest.warns(RuntimeWarning) as record:
Expand Down
Loading

0 comments on commit 955d088

Please sign in to comment.