Skip to content

Commit 955d088

Browse files
authored
Release v4.5.0
2 parents cc6aa20 + b411e10 commit 955d088

File tree

14 files changed

+244
-25
lines changed

14 files changed

+244
-25
lines changed

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
11
Changelog
22
=========
33

4+
## v4.5.0 (2023-07-17)
5+
6+
### Enhancements
7+
8+
* The exception's `__notes__` field will now be sent as metadata if it exists
9+
[#340](https://github.com/bugsnag/bugsnag-python/pull/340)
10+
[0HyperCube](https://github.com/0HyperCube)
11+
12+
* Allows changing the grouping hash when using `BugsnagHandler` via the logger methods' `extra` keyword argument
13+
[#334](https://github.com/bugsnag/bugsnag-python/pull/334)
14+
[0HyperCube](https://github.com/0HyperCube)
15+
16+
* PathLike objects are now accepted as the project path
17+
[#344](https://github.com/bugsnag/bugsnag-python/pull/344)
18+
[0HyperCube](https://github.com/0HyperCube)
19+
20+
### Bug fixes
21+
22+
* Fixes one of the fields being mistakenly replaced with `[RECURSIVE]` when encoding a list or dictionary with identical siblings but no recursion.
23+
[#341](https://github.com/bugsnag/bugsnag-python/pull/341)
24+
[0HyperCube](https://github.com/0HyperCube)
25+
26+
* Fix the ignore class list not accounting for nested classes
27+
[#342](https://github.com/bugsnag/bugsnag-python/pull/342)
28+
[0HyperCube](https://github.com/0HyperCube)
29+
430
## v4.4.0 (2023-02-21)
531

632
### Enhancements

bugsnag/configuration.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,16 @@
2020
MiddlewareStack,
2121
skip_bugsnag_middleware
2222
)
23-
from bugsnag.utils import (fully_qualified_class_name, validate_str_setter,
24-
validate_bool_setter, validate_iterable_setter,
25-
validate_required_str_setter, validate_int_setter)
23+
from bugsnag.utils import (
24+
fully_qualified_class_name,
25+
partly_qualified_class_name,
26+
validate_str_setter,
27+
validate_bool_setter,
28+
validate_iterable_setter,
29+
validate_required_str_setter,
30+
validate_int_setter,
31+
validate_path_setter
32+
)
2633
from bugsnag.delivery import (create_default_delivery, DEFAULT_ENDPOINT,
2734
DEFAULT_SESSIONS_ENDPOINT)
2835
from bugsnag.uwsgi import warn_if_running_uwsgi_without_threads
@@ -36,6 +43,14 @@
3643
_request_info = ThreadContextVar('bugsnag-request', default=None) # type: ignore # noqa: E501
3744

3845

46+
try:
47+
from os import PathLike
48+
except ImportError:
49+
# PathLike was added in Python 3.6 so fallback to PurePath on Python 3.5 as
50+
# all builtin Path objects inherit from PurePath
51+
from pathlib import PurePath as PathLike # type: ignore
52+
53+
3954
__all__ = ('Configuration', 'RequestConfiguration')
4055
_sentinel = object()
4156

@@ -362,9 +377,9 @@ def project_root(self):
362377
return self._project_root
363378

364379
@project_root.setter # type: ignore
365-
@validate_str_setter
366-
def project_root(self, value: str):
367-
self._project_root = value
380+
@validate_path_setter
381+
def project_root(self, value: Union[str, PathLike]):
382+
self._project_root = str(value)
368383

369384
@property
370385
def proxy_host(self):
@@ -521,7 +536,8 @@ def should_ignore(
521536
if isinstance(exception, list):
522537
return any(e.error_class in self.ignore_classes for e in exception)
523538

524-
return fully_qualified_class_name(exception) in self.ignore_classes
539+
return (fully_qualified_class_name(exception) in self.ignore_classes or
540+
partly_qualified_class_name(exception) in self.ignore_classes)
525541

526542
def _create_default_logger(self) -> logging.Logger:
527543
logger = logging.getLogger('bugsnag')

bugsnag/event.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def get_config(key):
113113

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

127+
if hasattr(exception, "__notes__"):
128+
self.add_tab(
129+
"exception notes",
130+
dict(enumerate(exception.__notes__)) # type: ignore # noqa
131+
)
132+
127133
@property
128134
def meta_data(self) -> Dict[str, Dict[str, Any]]:
129-
warnings.warn('The Event "metadata" property has been replaced ' +
130-
'with "meta_data".', DeprecationWarning)
135+
warnings.warn('The Event "meta_data" property has been replaced ' +
136+
'with "metadata".', DeprecationWarning)
131137
return self.metadata
132138

133139
@property

bugsnag/handlers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ def __init__(self, client=None, extra_fields=None):
1818
self.custom_metadata_fields = extra_fields
1919
self.callbacks = [self.extract_default_metadata,
2020
self.extract_custom_metadata,
21-
self.extract_severity]
21+
self.extract_severity,
22+
self.extract_grouping_hash]
2223

2324
def emit(self, record: LogRecord):
2425
"""
@@ -113,6 +114,13 @@ def extract_severity(self, record: LogRecord, options: Dict):
113114
else:
114115
options['severity'] = 'info'
115116

117+
def extract_grouping_hash(self, record: LogRecord, options: Dict):
118+
"""
119+
Add the grouping_hash from a log record to the options
120+
"""
121+
if 'groupingHash' in record.__dict__:
122+
options['grouping_hash'] = record.__dict__['groupingHash']
123+
116124
def extract_custom_metadata(self, record: LogRecord, options: Dict):
117125
"""
118126
Append the contents of selected fields of a record to the metadata

bugsnag/tornado/__init__.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import tornado
22
from tornado.web import RequestHandler, HTTPError
3-
from tornado.wsgi import WSGIContainer
43
from typing import Dict, Any # noqa
5-
from urllib.parse import parse_qs
4+
from urllib.parse import parse_qs, unquote_to_bytes
65
from bugsnag.breadcrumbs import BreadcrumbType
76
from bugsnag.utils import (
87
is_json_content_type,
@@ -14,6 +13,53 @@
1413
import json
1514

1615

16+
def tornado_environ(request):
17+
"""Copyright The Tornado Web Library Authors
18+
19+
Licensed under the Apache License, Version 2.0 (the "License");
20+
you may not use this file except in compliance with the License.
21+
You may obtain a copy of the License at
22+
23+
https://www.apache.org/licenses/LICENSE-2.0
24+
25+
Unless required by applicable law or agreed to in writing, software
26+
distributed under the License is distributed on an "AS IS" BASIS,
27+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
28+
See the License for the specific language governing permissions and
29+
limitations under the License.
30+
31+
Converts a tornado request to a WSGI environment.
32+
33+
Taken from tornado's WSGI implementation
34+
https://github.com/tornadoweb/tornado/blob/6e3521da44c349197cf8048c8a6c69d3f4ccd971/tornado/wsgi.py#L207-L246
35+
but without WSGI prefixed entries that require a WSGI application.
36+
"""
37+
hostport = request.host.split(":")
38+
if len(hostport) == 2:
39+
host = hostport[0]
40+
port = int(hostport[1])
41+
else:
42+
host = request.host
43+
port = 443 if request.protocol == "https" else 80
44+
environ = {
45+
"REQUEST_METHOD": request.method,
46+
"SCRIPT_NAME": "",
47+
"PATH_INFO": unquote_to_bytes(request.path).decode("latin1"),
48+
"QUERY_STRING": request.query,
49+
"REMOTE_ADDR": request.remote_ip,
50+
"SERVER_NAME": host,
51+
"SERVER_PORT": str(port),
52+
"SERVER_PROTOCOL": request.version,
53+
}
54+
if "Content-Type" in request.headers:
55+
environ["CONTENT_TYPE"] = request.headers.pop("Content-Type")
56+
if "Content-Length" in request.headers:
57+
environ["CONTENT_LENGTH"] = request.headers.pop("Content-Length")
58+
for key, value in request.headers.items():
59+
environ["HTTP_" + key.replace("-", "_").upper()] = value
60+
return environ
61+
62+
1763
class BugsnagRequestHandler(RequestHandler):
1864
def add_tornado_request_to_notification(self, event: bugsnag.Event):
1965
if not hasattr(self, "request"):
@@ -42,7 +88,7 @@ def add_tornado_request_to_notification(self, event: bugsnag.Event):
4288
event.add_tab("request", request_tab)
4389

4490
if bugsnag.configure().send_environment:
45-
env = WSGIContainer.environ(self.request)
91+
env = tornado_environ(self.request)
4692
event.add_tab("environment", env)
4793

4894
def _handle_request_exception(self, exc: BaseException):

bugsnag/utils.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
from datetime import datetime, timedelta
1010
from urllib.parse import urlparse, urlunsplit, parse_qs
1111

12+
13+
try:
14+
from os import PathLike
15+
except ImportError:
16+
# PathLike was added in Python 3.6 so fallback to PurePath on Python 3.5 as
17+
# all builtin Path objects inherit from PurePath
18+
from pathlib import PurePath as PathLike # type: ignore
19+
20+
1221
MAX_PAYLOAD_LENGTH = 128 * 1024
1322
MAX_STRING_LENGTH = 1024
1423

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

93+
# Only ignore whilst encoding children
94+
ignored.remove(id(obj))
95+
8496
return clean_dict
8597

8698
return obj
@@ -119,16 +131,25 @@ def _sanitize(self, obj, trim_strings, ignored=None, seen=None):
119131
if id(obj) in ignored:
120132
return self.recursive_value
121133
elif isinstance(obj, dict):
122-
ignored.add(id(obj))
123134
seen.append(obj)
124-
return self._sanitize_dict(obj, trim_strings, ignored, seen)
125-
elif isinstance(obj, (set, tuple, list)):
135+
126136
ignored.add(id(obj))
137+
sanitized = self._sanitize_dict(obj, trim_strings, ignored, seen)
138+
# Only ignore whilst encoding children
139+
ignored.remove(id(obj))
140+
141+
return sanitized
142+
elif isinstance(obj, (set, tuple, list)):
127143
seen.append(obj)
144+
145+
ignored.add(id(obj))
128146
items = []
129147
for value in obj:
130148
items.append(
131149
self._sanitize(value, trim_strings, ignored, seen))
150+
# Only ignore whilst encoding children
151+
ignored.remove(id(obj))
152+
132153
return items
133154
elif trim_strings and isinstance(obj, str):
134155
return obj[:MAX_STRING_LENGTH]
@@ -243,7 +264,7 @@ def is_json_content_type(value: str) -> bool:
243264
_ignore_modules = ('__main__', 'builtins')
244265

245266

246-
def fully_qualified_class_name(obj):
267+
def partly_qualified_class_name(obj):
247268
module = inspect.getmodule(obj)
248269

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

254275

276+
def fully_qualified_class_name(obj):
277+
module = inspect.getmodule(obj)
278+
if hasattr(obj.__class__, "__qualname__"):
279+
qualified_name = obj.__class__.__qualname__
280+
else:
281+
qualified_name = obj.__class__.__name__
282+
283+
if module is None or module.__name__ in _ignore_modules:
284+
return qualified_name
285+
286+
return module.__name__ + '.' + qualified_name
287+
288+
255289
def package_version(package_name):
256290
try:
257291
import pkg_resources
@@ -293,6 +327,7 @@ def wrapper(obj, value):
293327
validate_bool_setter = partial(_validate_setter, (bool,))
294328
validate_iterable_setter = partial(_validate_setter, (list, tuple))
295329
validate_int_setter = partial(_validate_setter, (int,))
330+
validate_path_setter = partial(_validate_setter, (str, PathLike))
296331

297332

298333
class ThreadContextVar:

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
setup(
1616
name='bugsnag',
17-
version='4.4.0',
17+
version='4.5.0',
1818
description='Automatic error monitoring for django, flask, etc.',
1919
long_description=__doc__,
2020
author='Simon Maynard',

tests/test_client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,25 @@ def test_chained_exceptions_with_explicit_cause(self):
10561056
}
10571057
]
10581058

1059+
@pytest.mark.skipif(
1060+
sys.version_info < (3, 11),
1061+
reason="requires BaseException.add_note (Python 3.11 or higher)"
1062+
)
1063+
def test_notes(self):
1064+
e = Exception("exception")
1065+
e.add_note("exception note 1")
1066+
e.add_note("exception note 2")
1067+
self.client.notify(e)
1068+
assert self.sent_report_count == 1
1069+
1070+
payload = self.server.received[0]['json_body']
1071+
metadata = payload['events'][0]['metaData']
1072+
notes = metadata['exception notes']
1073+
1074+
assert len(notes) == 2
1075+
assert notes['0'] == "exception note 1"
1076+
assert notes['1'] == "exception note 2"
1077+
10591078
def test_chained_exceptions_with_explicit_cause_using_capture_cm(self):
10601079
try:
10611080
with self.client.capture():

tests/test_configuration.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
import socket
33
import logging
44
import random
5+
import sys
56
import time
67
import unittest
8+
from pathlib import PurePath, Path
79
from unittest.mock import patch
810
from io import StringIO
911
from threading import Thread
@@ -287,19 +289,35 @@ def test_validate_params_filters(self):
287289

288290
def test_validate_project_root(self):
289291
c = Configuration()
292+
293+
if sys.version_info < (3, 6):
294+
expected_type = 'PurePath'
295+
else:
296+
expected_type = 'PathLike'
297+
290298
with pytest.warns(RuntimeWarning) as record:
291299
c.configure(project_root=True)
292300

293301
assert len(record) == 1
294-
assert (str(record[0].message) ==
295-
'project_root should be str, got bool')
302+
assert str(record[0].message) == \
303+
'project_root should be str or %s, got bool' % expected_type
296304
assert c.project_root == os.getcwd()
297305

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

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

311+
c.configure(project_root=Path('/path/to/python/project'))
312+
313+
assert len(record) == 1
314+
assert c.project_root == '/path/to/python/project'
315+
316+
c.configure(project_root=PurePath('/path/to/python/project'))
317+
318+
assert len(record) == 1
319+
assert c.project_root == '/path/to/python/project'
320+
303321
def test_validate_proxy_host(self):
304322
c = Configuration()
305323
with pytest.warns(RuntimeWarning) as record:

0 commit comments

Comments
 (0)