From 7462bd6ea97c45042719e8e98a5bad2c735eecf0 Mon Sep 17 00:00:00 2001 From: Alessandro Molina Date: Fri, 31 Jul 2026 17:37:23 +0200 Subject: [PATCH 1/2] modernize codebase --- .gitignore | 1 + MANIFEST.in | 3 - backlash/__init__.py | 14 +- backlash/_compat.py | 78 ---------- backlash/asgi.py | 1 - backlash/console.py | 32 ++-- backlash/debug.py | 3 +- backlash/debugger.py | 11 +- backlash/frtools.py | 6 +- backlash/repr.py | 86 ++++------- backlash/tbtools.py | 137 +++++++---------- backlash/trace_errors/__init__.py | 5 +- backlash/trace_errors/sentry.py | 5 +- backlash/tracing/errors/__init__.py | 4 +- backlash/tracing/errors/middleware.py | 10 +- backlash/tracing/reporters/mail.py | 42 +++--- backlash/tracing/reporters/sentry.py | 4 +- backlash/tracing/slowrequests/__init__.py | 4 +- backlash/tracing/slowrequests/middleware.py | 18 +-- backlash/tracing/slowrequests/timer.py | 13 +- backlash/utils.py | 9 +- backlash/wsgi.py | 1 - pyproject.toml | 31 +++- setup.py | 3 - tests/test_console.py | 71 +++++++++ tests/test_mail_reporter.py | 154 +++++++++++++++++++ tests/test_repr.py | 58 ++++++++ tests/test_slow_requests.py | 145 ++++++++++++++++++ tests/test_starlette.py | 8 +- tests/test_tbtools.py | 98 ++++++++++++ tests/test_trace_errors.py | 156 ++++++++++++++++++++ tests/test_utils.py | 13 ++ 32 files changed, 902 insertions(+), 322 deletions(-) delete mode 100644 MANIFEST.in delete mode 100644 backlash/_compat.py delete mode 100644 setup.py create mode 100644 tests/test_console.py create mode 100644 tests/test_mail_reporter.py create mode 100644 tests/test_repr.py create mode 100644 tests/test_slow_requests.py create mode 100644 tests/test_tbtools.py create mode 100644 tests/test_trace_errors.py create mode 100644 tests/test_utils.py diff --git a/.gitignore b/.gitignore index e71461f..afad6ed 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dist/* *.egg-info/* .noseids .idea +.pi-subagents/ diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 54edb5b..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -global-exclude *.pyc -recursive-include backlash/statics * -include LICENSE diff --git a/backlash/__init__.py b/backlash/__init__.py index c5e4fb0..91ec1fb 100644 --- a/backlash/__init__.py +++ b/backlash/__init__.py @@ -1,5 +1,13 @@ -from backlash.debug import DebuggedApplication -from backlash.wsgi import WsgiDebuggedApplication from backlash.asgi import AsgiDebuggedApplication +from backlash.debug import DebuggedApplication from backlash.tracing.errors import TraceErrorsMiddleware -from backlash.tracing.slowrequests import TraceSlowRequestsMiddleware \ No newline at end of file +from backlash.tracing.slowrequests import TraceSlowRequestsMiddleware +from backlash.wsgi import WsgiDebuggedApplication + +__all__ = [ + 'DebuggedApplication', + 'WsgiDebuggedApplication', + 'AsgiDebuggedApplication', + 'TraceErrorsMiddleware', + 'TraceSlowRequestsMiddleware', +] diff --git a/backlash/_compat.py b/backlash/_compat.py deleted file mode 100644 index 159ce06..0000000 --- a/backlash/_compat.py +++ /dev/null @@ -1,78 +0,0 @@ -import sys -import types - -# True if we are running on Python 3. -PY3 = sys.version_info[0] == 3 -PY2 = sys.version_info[0] == 2 - -if PY3: # pragma: no cover - string_types = str, - integer_types = int, - class_types = type, - text_type = str - binary_type = bytes - long = int -else: - string_types = basestring, - integer_types = (int, long) - class_types = (type, types.ClassType) - text_type = unicode - binary_type = str - long = long - -def text_(s, encoding='utf-8', errors='strict'): - if isinstance(s, binary_type): - return s.decode(encoding, errors) - return s # pragma: no cover - -def bytes_(s, encoding='utf-8', errors='strict'): - if isinstance(s, text_type): - return s.encode(encoding, errors) - return s - -if PY3: # pragma: no cover - def native_(s, encoding='latin-1', errors='strict'): - if isinstance(s, text_type): - return s - return str(s, encoding, errors) -else: - def native_(s, encoding='latin-1', errors='strict'): - if isinstance(s, text_type): # pragma: no cover - return s.encode(encoding, errors) - return str(s) - -if PY3: # pragma: no cover - def iteritems_(d): - return d.items() -else: - def iteritems_(d): - return d.iteritems() - -if PY3: # pragma: no cover - import builtins - exec_ = getattr(builtins, "exec") - def reraise(exc_info): - etype, exc, tb = exc_info - if exc.__traceback__ is not tb: - raise exc.with_traceback(tb) - raise exc -else: # pragma: no cover - def exec_(code, globs=None, locs=None): - """Execute code in a namespace.""" - if globs is None: - frame = sys._getframe(1) - globs = frame.f_globals - if locs is None: - locs = frame.f_locals - del frame - elif locs is None: - locs = globs - exec("""exec code in globs, locs""") - exec_("""def reraise(exc_info): - raise exc_info[0], exc_info[1], exc_info[2] -""") - -try: - from urllib2 import urlopen -except ImportError: - from urllib.request import urlopen diff --git a/backlash/asgi.py b/backlash/asgi.py index bb53124..8fcbb21 100644 --- a/backlash/asgi.py +++ b/backlash/asgi.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ASGI shell of the interactive traceback debugger.""" import sys from urllib.parse import parse_qs diff --git a/backlash/console.py b/backlash/console.py index e0f9ff9..9e3bb6d 100644 --- a/backlash/console.py +++ b/backlash/console.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ werkzeug.debug.console ~~~~~~~~~~~~~~~~~~~~~~ @@ -8,18 +7,17 @@ :copyright: (c) 2011 by the Werkzeug Team, see AUTHORS for more details. :license: BSD. """ -import sys import code -from types import CodeType +import sys import threading +from types import CodeType -from backlash._compat import exec_, text_, binary_type -from backlash.utils import escape from backlash.repr import debug_repr, dump, helper +from backlash.utils import escape _local = threading.local() -class HTMLStringO(object): +class HTMLStringO: """A StringO version that HTML escapes on write.""" def __init__(self): @@ -50,8 +48,8 @@ def reset(self): return val def _write(self, x): - if isinstance(x, binary_type): - x = text_(x, 'utf-8', 'replace') + if isinstance(x, bytes): + x = x.decode('utf-8', 'replace') self._buffer.append(x) def write(self, x): @@ -61,23 +59,24 @@ def writelines(self, x): self._write(escape(''.join(x))) -class ThreadedStream(object): +class ThreadedStream: """Thread-local wrapper for sys.stdout for the interactive console.""" + @staticmethod def push(): if not isinstance(sys.stdout, ThreadedStream): sys.stdout = ThreadedStream() _local.stream = HTMLStringO() - push = staticmethod(push) + @staticmethod def fetch(): try: stream = _local.stream except AttributeError: return '' return stream.reset() - fetch = staticmethod(fetch) + @staticmethod def displayhook(obj): try: stream = _local.stream @@ -87,10 +86,9 @@ def displayhook(obj): # already generating HTML for us. if obj is not None: stream._write(debug_repr(obj)) - displayhook = staticmethod(displayhook) def __setattr__(self, name, value): - raise AttributeError('read only attribute %s' % name) + raise AttributeError(f'read only attribute {name}') def __dir__(self): return dir(sys.__stdout__) @@ -113,7 +111,7 @@ def __repr__(self): sys.displayhook = ThreadedStream.displayhook -class _ConsoleLoader(object): +class _ConsoleLoader: def __init__(self): self._storage = {} @@ -173,7 +171,7 @@ def runsource(self, source): def runcode(self, code): try: - exec_(code, self.globals, self.locals) + exec(code, self.globals, self.locals) except Exception: self.showtraceback() @@ -191,7 +189,7 @@ def write(self, data): sys.stdout.write(data) -class Console(object): +class Console: """An interactive console.""" def __init__(self, globals=None, locals=None, context=None): @@ -204,7 +202,7 @@ def __init__(self, globals=None, locals=None, context=None): def eval(self, code): return self._ipy.runsource(code) -class _ConsoleFrame(object): +class _ConsoleFrame: """Helper class so that we can reuse the frame console code for the standalone console. """ diff --git a/backlash/debug.py b/backlash/debug.py index 1da79f6..4fc8846 100644 --- a/backlash/debug.py +++ b/backlash/debug.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Kept for backward compatibility only: the WSGI debugger historically lived here as ``backlash.debug.DebuggedApplication``. @@ -6,3 +5,5 @@ :class:`backlash.asgi.AsgiDebuggedApplication`. """ from backlash.wsgi import WsgiDebuggedApplication as DebuggedApplication + +__all__ = ['DebuggedApplication'] diff --git a/backlash/debugger.py b/backlash/debugger.py index d1b5cd0..b0de6fe 100644 --- a/backlash/debugger.py +++ b/backlash/debugger.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Transport-agnostic core of the interactive traceback debugger. ``DebuggerCore`` owns the debugger state (captured tracebacks, per-frame @@ -10,18 +9,18 @@ adapt request parsing, response sending, and exception capture to their transport. """ -import mimetypes import logging -from os.path import join, dirname, basename, isfile +import mimetypes +from os.path import basename, dirname, isfile, join -from backlash.tbtools import get_current_traceback, render_console_html from backlash.console import Console +from backlash.tbtools import get_current_traceback, render_console_html from backlash.utils import gen_salt log = logging.getLogger('backlash') -class DebuggerCore(object): +class DebuggerCore: """State and endpoints shared by the WSGI and ASGI debugger middlewares. :param app: the wrapped application (WSGI or ASGI, owned by the shell). @@ -124,7 +123,7 @@ def get_resource(self, filename): return 404, 'text/plain', b'Not Found' -class _ConsoleFrame(object): +class _ConsoleFrame: """Helper class so that we can reuse the frame console code for the standalone console. """ diff --git a/backlash/frtools.py b/backlash/frtools.py index a3f0d35..043bd14 100644 --- a/backlash/frtools.py +++ b/backlash/frtools.py @@ -1,5 +1,7 @@ -import sys, inspect -from .tbtools import Traceback, Frame +import inspect +import sys + +from .tbtools import Frame, Traceback class DumpThread(Exception): diff --git a/backlash/repr.py b/backlash/repr.py index a2e7597..87be620 100644 --- a/backlash/repr.py +++ b/backlash/repr.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ werkzeug.debug.repr ~~~~~~~~~~~~~~~~~~~ @@ -13,15 +12,15 @@ :copyright: (c) 2011 by the Werkzeug Team, see AUTHORS for more details. :license: BSD. """ -import sys import re +import sys from traceback import format_exception_only + try: from collections import deque except ImportError: # pragma: no cover deque = None -from backlash._compat import text_, text_type, binary_type, iteritems_, string_types, long, PY3 from backlash.utils import escape missing = object() @@ -45,7 +44,7 @@ def debug_repr(obj): - """Creates a debug repr of an object as HTML unicode string.""" + """Creates a debug repr of an object as HTML string.""" return DebugReprGenerator().repr(obj) @@ -61,7 +60,7 @@ def dump(obj=missing): sys.stdout._write(rv) -class _Helper(object): +class _Helper: """Displays an HTML version of the normal help, for the interactive debugger only because it requires a patched sys.stdout. """ @@ -75,7 +74,7 @@ def __call__(self, topic=None): return import pydoc pydoc.help(topic) - rv = text_(sys.stdout.reset(), 'utf-8', 'ignore') + rv = sys.stdout.reset() paragraphs = _paragraph_re.split(rv) if len(paragraphs) > 1: title = paragraphs[0] @@ -101,7 +100,7 @@ def _add_subclass_info(inner, obj, bases): return '%s%s(%s)' % (module, obj.__class__.__name__, inner) -class DebugReprGenerator(object): +class DebugReprGenerator: def __init__(self): self._stack = [] @@ -122,7 +121,7 @@ def proxy(self, obj, recursive): if have_extended_section: buf.append('') buf.append(right) - return _add_subclass_info(text_(''.join(buf)), obj, base) + return _add_subclass_info(''.join(buf), obj, base) return proxy list_repr = _sequence_repr_maker('[', ']', list) @@ -135,33 +134,7 @@ def proxy(self, obj, recursive): del _sequence_repr_maker def regex_repr(self, obj): - if PY3: - pattern = text_("'%s'" % str(obj.pattern), 'string-escape', 'ignore') - pattern = 'r' + pattern - else: - pattern = text_(repr(obj.pattern), 'string-escape', 'ignore') - if pattern[:1] == 'u': - pattern = 'ur' + pattern[1:] - else: - pattern = 'r' + pattern - return text_( - 're.compile(%s)' % pattern) - - def py2_string_repr(self, obj, limit=70): - buf = [''] - escaped = escape(obj) - a = repr(escaped[:limit]) - b = repr(escaped[limit:]) - if isinstance(obj, text_type): - buf.append('u') - a = a[1:] - b = b[1:] - if b != "''": - buf.extend((a[:-1], '', b[1:], '')) - else: - buf.append(a) - buf.append('') - return _add_subclass_info(text_('').join(buf), obj, (str, unicode)) + return 're.compile(r\'%s\')' % obj.pattern def py3_text_repr(self, obj, limit=70): buf = [''] @@ -173,11 +146,11 @@ def py3_text_repr(self, obj, limit=70): else: buf.append(a) buf.append('') - return _add_subclass_info(text_(''.join(buf)), obj, text_type) + return _add_subclass_info(''.join(buf), obj, str) def py3_binary_repr(self, obj, limit=70): buf = [''] - escaped = escape(text_(obj, 'utf-8', 'replace')) + escaped = escape(obj.decode('utf-8', 'replace')) a = repr(escaped[:limit]) b = repr(escaped[limit:]) buf.append('b') @@ -186,14 +159,14 @@ def py3_binary_repr(self, obj, limit=70): else: buf.append(a) buf.append('') - return _add_subclass_info(text_(''.join(buf)), obj, binary_type) + return _add_subclass_info(''.join(buf), obj, bytes) def dict_repr(self, d, recursive, limit=5): if recursive: - return _add_subclass_info(text_('{...}'), d, dict) + return _add_subclass_info('{...}', d, dict) buf = ['{'] have_extended_section = False - for idx, (key, value) in enumerate(iteritems_(d)): + for idx, (key, value) in enumerate(d.items()): if idx: buf.append(', ') if idx == limit - 1: @@ -205,25 +178,20 @@ def dict_repr(self, d, recursive, limit=5): if have_extended_section: buf.append('') buf.append('}') - return _add_subclass_info(text_(''.join(buf)), d, dict) + return _add_subclass_info(''.join(buf), d, dict) def object_repr(self, obj): - return text_('%s' % - escape(text_(repr(obj), 'utf-8', 'replace'))) + return '%s' % escape(repr(obj)) def dispatch_repr(self, obj, recursive): if obj is helper: - return text_('%r' % helper) - if isinstance(obj, (int, long, float, complex)): - return text_('%r' % obj) - if PY3: - if isinstance(obj, text_type): - return self.py3_text_repr(obj) - if isinstance(obj, binary_type): - return self.py3_binary_repr(obj) - else: - if isinstance(obj, basestring): - return self.py2_string_repr(obj) + return '%r' % helper + if isinstance(obj, (int, float, complex)): + return '%r' % obj + if isinstance(obj, str): + return self.py3_text_repr(obj) + if isinstance(obj, bytes): + return self.py3_binary_repr(obj) if isinstance(obj, RegexType): return self.regex_repr(obj) if isinstance(obj, list): @@ -245,10 +213,8 @@ def fallback_repr(self): info = ''.join(format_exception_only(*sys.exc_info()[:2])) except Exception: # pragma: no cover info = '?' - return text_( - '<broken repr (%s)>' - '' % escape(text_(info, 'utf-8', 'ignore').strip()) - ) + return ('<broken repr (%s)>' + '' % escape(info.strip())) def repr(self, obj): recursive = False @@ -270,8 +236,8 @@ def dump_object(self, obj): if isinstance(obj, dict): title = 'Contents of' items = [] - for key, value in iteritems_(obj): - if not isinstance(key, string_types): + for key, value in obj.items(): + if not isinstance(key, str): items = None break items.append((key, self.repr(value))) diff --git a/backlash/tbtools.py b/backlash/tbtools.py index 9aa3bc0..a318b9d 100644 --- a/backlash/tbtools.py +++ b/backlash/tbtools.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """ werkzeug.debug.tbtools ~~~~~~~~~~~~~~~~~~~~~~ @@ -8,26 +7,22 @@ :copyright: (c) 2011 by the Werkzeug Team, see AUTHORS for more details. :license: BSD. """ -import re +import inspect import os +import re import sys -import inspect import traceback -import codecs from tokenize import TokenError -from backlash.utils import escape from backlash.console import Console - -from backlash._compat import PY2, text_, native_, string_types, text_type, exec_ +from backlash.utils import escape _coding_re = re.compile(r'coding[:=]\s*([-\w.]+)') -_line_re = re.compile(r'^(.*?)$', re.MULTILINE) _funcdef_re = re.compile(r'^(\s*def\s)|(.*(? @@ -49,9 +44,9 @@
-''') +''' -FOOTER = text_('''\ +FOOTER = '''\ -''') +''' -PAGE_HTML = HEADER + text_('''\ +PAGE_HTML = HEADER + '''\

%(exception_type)s

%(exception)s

@@ -78,48 +73,48 @@ If you enable JavaScript you can also use additional features such as code execution (if the evalex feature is enabled) and much more.
-''') + FOOTER + text_(''' +''' + FOOTER + ''' -''') +''' -CONSOLE_HTML = HEADER + text_('''\ +CONSOLE_HTML = HEADER + '''\

Interactive Console

In this console you can execute Python expressions in the context of the application. The initial namespace was created by the debugger automatically.
The Console requires JavaScript.
-''') + FOOTER +''' + FOOTER -SUMMARY_HTML = text_('''\ +SUMMARY_HTML = '''\
%(title)s
    %(frames)s
%(description)s
-''') +''' -FRAME_HTML = text_('''\ +FRAME_HTML = '''\

File "%(filename)s", line %(lineno)s, in %(function_name)s

%(current_line)s
-''') +''' -SOURCE_TABLE_HTML = text_('%s
') +SOURCE_TABLE_HTML = '%s
' -SOURCE_LINE_HTML = text_('''\ +SOURCE_LINE_HTML = '''\ %(lineno)s %(code)s -''') +''' def render_console_html(secret): @@ -152,7 +147,7 @@ def get_current_traceback(show_hidden_frames=False, skip=0, context=None, exc_in return tb -class Line(object): +class Line: """Helper for the source renderer.""" __slots__ = ('lineno', 'code', 'in_frame', 'current') @@ -173,13 +168,13 @@ def classes(self): def render(self): return SOURCE_LINE_HTML % { - 'classes': text_(' ').join(self.classes), + 'classes': ' '.join(self.classes), 'lineno': self.lineno, 'code': escape(self.code) } -class Traceback(object): +class Traceback: """Wraps a traceback.""" def __init__(self, exc_type, exc_value, tb, context=None): @@ -246,22 +241,18 @@ def is_syntax_error(self): def exception(self): """String representation of the exception.""" buf = traceback.format_exception_only(self.exc_type, self.exc_value) - return native_(''.join(buf).strip(), 'utf-8', 'replace') + return ''.join(buf).strip() exception = property(exception) def log(self, logfile=None): """Log the ASCII traceback into a file object.""" if logfile is None: logfile = sys.stderr - tb = self.plaintext.rstrip() + '\n' - if PY2: - tb = tb.encode('utf-8') - logfile.write(tb) + logfile.write(self.plaintext.rstrip() + '\n') def render_summary(self, include_title=True): """Render the traceback for the interactive console.""" title = '' - description = '' frames = [] classes = ['traceback'] if not self.frames: @@ -269,25 +260,25 @@ def render_summary(self, include_title=True): if include_title: if self.is_syntax_error: - title = text_('Syntax Error') + title = 'Syntax Error' else: - title = text_('Traceback (most recent call last):') + title = 'Traceback (most recent call last):' for frame in self.frames: - frames.append(text_('%s') % ( - frame.info and text_(' title="%s"') % escape(frame.info) or text_(''), + frames.append('%s' % ( + frame.info and ' title="%s"' % escape(frame.info) or '', frame.render() )) if self.is_syntax_error: - description_wrapper = text_('
%s
') + description_wrapper = '
%s
' else: - description_wrapper = text_('
%s
') + description_wrapper = '
%s
' return SUMMARY_HTML % { - 'classes': text_(' '.join(classes)), - 'title': title and text_('

%s

' % title) or text_(''), - 'frames': text_('\n'.join(frames)), + 'classes': ' '.join(classes), + 'title': title and '

%s

' % title or '', + 'frames': '\n'.join(frames), 'description': description_wrapper % escape(self.exception) } @@ -309,24 +300,24 @@ def render_full(self, evalex=False, secret=None): def generate_plaintext_traceback(self): """Like the plaintext attribute but returns a generator""" - yield text_('Traceback (most recent call last):') + yield 'Traceback (most recent call last):' for frame in self.frames: - yield text_(' File "%s", line %s, in %s' % ( + yield ' File "%s", line %s, in %s' % ( frame.filename, frame.lineno, frame.function_name - )) - yield text_(' ' + frame.current_line.strip()) - yield text_(self.exception) + ) + yield ' ' + frame.current_line.strip() + yield self.exception def plaintext(self): - return text_('\n'.join(self.generate_plaintext_traceback())) + return '\n'.join(self.generate_plaintext_traceback()) plaintext = property(plaintext) id = property(lambda x: id(x)) -class Frame(object): +class Frame: """A single frame in a traceback.""" def __init__(self, exc_type, exc_value, tb, context=None): @@ -356,11 +347,8 @@ def __init__(self, exc_type, exc_value, tb, context=None): # support for paste's traceback extensions self.hide = self.locals.get('__traceback_hide__', False) info = self.locals.get('__traceback_info__') - if info is not None: - try: - info = text_(info) - except UnicodeError: - info = str(info).decode('utf-8', 'replace') + if isinstance(info, bytes): + info = info.decode('utf-8', 'replace') self.info = info def __getattr__(self, item): @@ -417,22 +405,21 @@ def get_annotated_lines(self): def render_source(self): """Render the sourcecode.""" - return SOURCE_TABLE_HTML % text_('\n'.join(line.render() for line in - self.get_annotated_lines())) + return SOURCE_TABLE_HTML % '\n'.join( + line.render() for line in self.get_annotated_lines()) def eval(self, code, mode='single'): """Evaluate code in the context of the frame.""" - if isinstance(code, string_types): - if isinstance(code, text_type): - code = UTF8_COOKIE + code.encode('utf-8') + if isinstance(code, str): + code = UTF8_COOKIE + code.encode('utf-8') code = compile(code, '', mode) if mode != 'exec': return eval(code, self.globals, self.locals) - exec_(code, self.globals, self.locals) + exec(code, self.globals, self.locals) @property def sourcelines(self): - """The sourcecode of the file as list of unicode strings.""" + """The sourcecode of the file as a list of strings.""" # get sourcecode from loader or file source = None if self.loader is not None: @@ -456,38 +443,14 @@ def sourcelines(self): finally: f.close() - # already unicode? return right away - if isinstance(source, text_type): - return source.splitlines() - - # yes. it should be ascii, but we don't want to reject too many - # characters in the debugger if something breaks - charset = 'utf-8' - if source.startswith(UTF8_COOKIE): - source = source[3:] - else: - for idx, match in enumerate(_line_re.finditer(source)): - match = _line_re.search(match.group()) - if match is not None: - charset = match.group(1) - break - if idx > 1: - break - - # on broken cookies we fall back to utf-8 too - try: - codecs.lookup(charset) - except LookupError: - charset = 'utf-8' - - return source.decode(charset, 'replace').splitlines() + return source.splitlines() @property def current_line(self): try: return self.sourcelines[self.lineno - 1] except IndexError: - return text_('') + return '' @property def console(self): diff --git a/backlash/trace_errors/__init__.py b/backlash/trace_errors/__init__.py index efa93d4..1c98629 100644 --- a/backlash/trace_errors/__init__.py +++ b/backlash/trace_errors/__init__.py @@ -1,8 +1,11 @@ # HERE JUST FOR BACKWARD COMPATIBILITY +import warnings + from backlash.tracing.errors import TraceErrorsMiddleware from backlash.tracing.reporters.mail import EmailReporter -import warnings +__all__ = ['TraceErrorsMiddleware', 'EmailReporter'] + warnings.warn( 'backlash.trace_errors is deprecated. Please use backlash.tracing.errors instead', DeprecationWarning) \ No newline at end of file diff --git a/backlash/trace_errors/sentry.py b/backlash/trace_errors/sentry.py index 0e5c2ce..3d8704b 100644 --- a/backlash/trace_errors/sentry.py +++ b/backlash/trace_errors/sentry.py @@ -1,6 +1,9 @@ +import warnings + from backlash.tracing.reporters.sentry import SentryReporter -import warnings +__all__ = ['SentryReporter'] + warnings.warn( 'backlash.trace_errors is deprecated. Please use backlash.tracing.reporters.sentry instead', DeprecationWarning) \ No newline at end of file diff --git a/backlash/tracing/errors/__init__.py b/backlash/tracing/errors/__init__.py index 543db52..52b8631 100644 --- a/backlash/tracing/errors/__init__.py +++ b/backlash/tracing/errors/__init__.py @@ -1 +1,3 @@ -from .middleware import TraceErrorsMiddleware \ No newline at end of file +from .middleware import TraceErrorsMiddleware + +__all__ = ['TraceErrorsMiddleware'] diff --git a/backlash/tracing/errors/middleware.py b/backlash/tracing/errors/middleware.py index 0630544..3cf5edd 100644 --- a/backlash/tracing/errors/middleware.py +++ b/backlash/tracing/errors/middleware.py @@ -1,12 +1,12 @@ -from backlash._compat import string_types, bytes_ +import logging + from backlash.tbtools import get_current_traceback from backlash.utils import RequestContext -import logging log = logging.getLogger('backlash') -class TraceErrorsMiddleware(object): +class TraceErrorsMiddleware: def __init__(self, application, reporters, context_injectors): self.app = application self.reporters = reporters @@ -31,7 +31,7 @@ def _report_errors(self, environ, recorded_exc_info=None): r.report(traceback) except Exception: error = get_current_traceback(skip=1, show_hidden_frames=False) - environ['wsgi.errors'].write('\nError while reporting exception with %s\n' % r) + environ['wsgi.errors'].write(f'\nError while reporting exception with {r}\n') environ['wsgi.errors'].write(error.plaintext) def _generate_response(self, environ, start_response): @@ -52,7 +52,7 @@ def _generate_response(self, environ, start_response): 'response at a point where response headers were already ' 'sent.\n') else: - yield bytes_('Internal Server Error') + yield b'Internal Server Error' def _report_errors_with_response(self, environ, start_response): self._report_errors(environ, None) diff --git a/backlash/tracing/reporters/mail.py b/backlash/tracing/reporters/mail.py index bb1b11e..73f42f2 100644 --- a/backlash/tracing/reporters/mail.py +++ b/backlash/tracing/reporters/mail.py @@ -1,12 +1,11 @@ +import smtplib +import ssl from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -import smtplib -import ssl -from backlash._compat import string_types, bytes_, text_, PY3 -class EmailReporter(object): +class EmailReporter: def __init__(self, smtp_server=None, from_address=None, error_email=None, smtp_username=None, smtp_password=None, smtp_use_tls=False, error_subject_prefix='', dump_request=False, dump_request_size=50000, @@ -21,7 +20,7 @@ def __init__(self, smtp_server=None, from_address=None, error_email=None, self.smtp_use_tls = smtp_use_tls - if isinstance(error_email, string_types): + if isinstance(error_email, str): error_email = error_email.split(",") self.error_email = error_email @@ -47,7 +46,7 @@ def report(self, traceback): if self.smtp_username and self.smtp_password: server.login(self.smtp_username, self.smtp_password) - result = server.sendmail(self.from_address, self.error_email, msg.as_string()) + server.sendmail(self.from_address, self.error_email, msg.as_string()) try: server.quit() @@ -62,57 +61,50 @@ def _repr_value(self, value): # Cope with WebOb response objects that might be unable to get their repr value = repr(e) - if not PY3: - # On PY2 some repr might actually return bytes - # In that case just escape them. - value = value.decode('ascii', 'replace') - return value def _format_cgi(self, environ): return '\n'.join( - ('\t%s: %s' % (k, self._repr_value(environ[k])) for k in sorted(environ.keys()) if k.upper() == k)) + (f'\t{k}: {self._repr_value(environ[k])}' for k in sorted(environ.keys()) if k.upper() == k)) def _format_wsgi(self, environ): return '\n'.join( - ('\t%s: %s' % (k, self._repr_value(environ[k])) for k in sorted(environ.keys()) if k.upper() != k)) + (f'\t{k}: {self._repr_value(environ[k])}' for k in sorted(environ.keys()) if k.upper() != k)) def email_body(self, traceback): - body = 'TRACEBACK:\n%s' % traceback.plaintext + body = f'TRACEBACK:\n{traceback.plaintext}' if self.dump_local_frames: body += '\n\n\nLAST FRAMES:' frames_base_index = len(traceback.frames) - self.dump_local_frames_count for idx, frame in enumerate(traceback.frames[-self.dump_local_frames_count:]): - body += '\n\tFRAME #%d\n' % (frames_base_index+idx) + body += f'\n\tFRAME #{frames_base_index + idx}\n' for key, value in frame.locals.items(): body += "\t\t%20s = " % key try: body += '%r\n' % (value,) - except Exception as e: + except Exception: body += "\n" - body += '\n\n\nENVIRON:\n%s' % self._format_cgi(traceback.context['environ']) - body += '\n\n\nWSGI:\n%s' % self._format_wsgi(traceback.context['environ']) + body += f'\n\n\nENVIRON:\n{self._format_cgi(traceback.context["environ"])}' + body += f'\n\n\nWSGI:\n{self._format_wsgi(traceback.context["environ"])}' for entry, value in traceback.context.items(): if entry == 'environ': continue - body += '\n\n\n%s:\n\t%r' % (entry.upper(), value) + body += f'\n\n\n{entry.upper()}:\n\t{value!r}' return body def assemble_email(self, traceback): msg = MIMEMultipart() - subject = text_(traceback.exception) - - msg['Subject'] = text_(self.error_subject_prefix + subject).replace('\n', ' ') - msg['From'] = text_(self.from_address) - msg['To'] = text_(', '.join(self.error_email)) + msg['Subject'] = (self.error_subject_prefix + traceback.exception).replace('\n', ' ') + msg['From'] = self.from_address + msg['To'] = ', '.join(self.error_email) - text = MIMEText(bytes_(self.email_body(traceback)), 'plain', 'utf-8') + text = MIMEText(self.email_body(traceback).encode('utf-8'), 'plain', 'utf-8') text.set_type('text/plain') text.set_param('charset', 'UTF-8') msg.attach(text) diff --git a/backlash/tracing/reporters/sentry.py b/backlash/tracing/reporters/sentry.py index bbda404..9ad297d 100644 --- a/backlash/tracing/reporters/sentry.py +++ b/backlash/tracing/reporters/sentry.py @@ -1,11 +1,11 @@ try: from raven.base import Client - from raven.utils.wsgi import get_current_url, get_headers, get_environ + from raven.utils.wsgi import get_current_url, get_environ, get_headers except ImportError: Client = None -class SentryReporter(object): +class SentryReporter: def __init__(self, sentry_dsn, **unused): if Client is None: raise RavenNotAvailable('Raven is not installed, maybe run "pip install raven"') diff --git a/backlash/tracing/slowrequests/__init__.py b/backlash/tracing/slowrequests/__init__.py index 0de87fc..2f411da 100644 --- a/backlash/tracing/slowrequests/__init__.py +++ b/backlash/tracing/slowrequests/__init__.py @@ -1 +1,3 @@ -from .middleware import TraceSlowRequestsMiddleware \ No newline at end of file +from .middleware import TraceSlowRequestsMiddleware + +__all__ = ['TraceSlowRequestsMiddleware'] diff --git a/backlash/tracing/slowrequests/middleware.py b/backlash/tracing/slowrequests/middleware.py index ecf365e..d119ecb 100644 --- a/backlash/tracing/slowrequests/middleware.py +++ b/backlash/tracing/slowrequests/middleware.py @@ -1,16 +1,16 @@ import datetime as dt +import logging import os import threading -import logging - +from backlash.frtools import get_thread_stack from backlash.tbtools import get_current_traceback -from backlash.frtools import get_thread_stack, DumpThread from backlash.utils import RequestContext + from .timer import Timer -class TraceSlowRequestsMiddleware(object): +class TraceSlowRequestsMiddleware: def __init__(self, app, reporters, context_injectors, interval=25, exclude_paths=None): @@ -57,9 +57,9 @@ def peek(self, environ, thread_id, started): traceback = get_thread_stack(thread_id, environ.get('PATH_INFO', ''), context=context, error_type='SlowRequestError') except KeyError: - logging.warn('\nUnable to retrieve SlowRequest Stack %s, ' - 'thread %s probably finished execution in mean time\n', - environ.get('PATH_INFO', ''), thread_id) + logging.warning('\nUnable to retrieve SlowRequest Stack %s, ' + 'thread %s probably finished execution in mean time\n', + environ.get('PATH_INFO', ''), thread_id) return for r in self.reporters: @@ -67,7 +67,7 @@ def peek(self, environ, thread_id, started): r.report(traceback) except Exception: error = get_current_traceback(skip=1, show_hidden_frames=False) - environ['wsgi.errors'].write('\nError while reporting slow request with %s\n' % r) + environ['wsgi.errors'].write(f'\nError while reporting slow request with {r}\n') environ['wsgi.errors'].write(error.plaintext) @classmethod @@ -95,7 +95,7 @@ def _start_tracing(self, environ): self.interval, environ, self._get_thread_id(), - dt.datetime.utcnow()) + dt.datetime.now(dt.timezone.utc)) # In some cases due to webob.Request.call_application() or # paste StatusCodeRedirect middleware multiple _start_tracing for the same # environ might happen without consuming the app_iter for the firsts diff --git a/backlash/tracing/slowrequests/timer.py b/backlash/tracing/slowrequests/timer.py index 29f4dff..8fe6a03 100644 --- a/backlash/tracing/slowrequests/timer.py +++ b/backlash/tracing/slowrequests/timer.py @@ -1,10 +1,10 @@ -from functools import partial import logging import threading import time +from functools import partial -class TimerTask(object): +class TimerTask: def __init__(self, callable_, *args, **kwargs): self._callable = partial(callable_, *args, **kwargs) @@ -16,7 +16,12 @@ def is_finished(self): def run(self): try: self._callable() - except: + # BaseException and not Exception: the Timer worker loop invokes jobs + # without any outer guard, so anything escaping here kills the single + # long lived thread and silently disables slow request tracing. + # Under gevent (gevent.Timeout, GreenletExit) or a SystemExit raised + # by a context injector this is a real occurrence. + except BaseException: logging.exception('TimerTask failed') finally: self._finished = True @@ -31,7 +36,7 @@ class Timer(threading.Thread): """ def __init__(self, *args, **kwargs): - super(Timer, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.lock = threading.Condition() self._jobs = [] diff --git a/backlash/utils.py b/backlash/utils.py index 5558bb4..1693c85 100644 --- a/backlash/utils.py +++ b/backlash/utils.py @@ -1,4 +1,3 @@ -from backlash._compat import text_type, binary_type from random import SystemRandom SALT_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' @@ -19,12 +18,12 @@ def escape(s, quote=False): return '' if hasattr(s, '__html__'): return s.__html__() - if not isinstance(s, (text_type, binary_type)): - s = text_type(s) - if isinstance(s, binary_type): + if not isinstance(s, (str, bytes)): + s = str(s) + if isinstance(s, bytes): try: s.decode('ascii') - except: + except Exception: s = s.decode('utf-8', 'replace') s = s.replace('&', '&').replace('<', '<').replace('>', '>') if quote: diff --git a/backlash/wsgi.py b/backlash/wsgi.py index 7d0b1fc..5874ea7 100644 --- a/backlash/wsgi.py +++ b/backlash/wsgi.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """WSGI shell of the interactive traceback debugger.""" from http.client import responses as _HTTP_REASONS from urllib.parse import parse_qs diff --git a/pyproject.toml b/pyproject.toml index 1879da7..0a2e676 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61", "wheel"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -7,7 +7,8 @@ name = "backlash" version = "0.4.1" description = "Interactive in-browser debugger and error reporting middleware for WSGI and ASGI applications" readme = { file = "README.rst", content-type = "text/x-rst" } -license = { file = "LICENSE" } +license = "MIT" +requires-python = ">=3.9" authors = [ { name = "Alessandro Molina", email = "alessandro@molina.fyi" } ] @@ -16,9 +17,17 @@ classifiers = [ "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", "Topic :: Internet :: WWW/HTTP :: WSGI" ] + [project.optional-dependencies] testing = [ "pytest", @@ -27,6 +36,8 @@ testing = [ [project.urls] Homepage = "https://github.com/TurboGears/backlash" +Repository = "https://github.com/TurboGears/backlash" +Issues = "https://github.com/TurboGears/backlash/issues" [tool.setuptools] include-package-data = true @@ -34,3 +45,19 @@ zip-safe = false [tool.setuptools.packages.find] exclude = ["examples", "tests"] + +[tool.setuptools.package-data] +backlash = ["statics/*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v" + +[tool.ruff] +line-length = 88 +target-version = "py39" +output-format = "grouped" +exclude = ["build", "dist", ".venv", "venv", "env", "__pycache__"] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I001"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 6068493..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..aa544fd --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,71 @@ +"""Behavioral tests for the interactive debugger console. + +Covers evaluating code in the frame namespace, the HTML rendering of results +and printed output, the ``dump``/``help`` helpers and error reporting. +Every test restores sys.stdout, which the console hijacks process wide. +""" +import sys + +from backlash.console import Console + + +def evaluate(monkeypatch, source, globals=None, locals=None, context=None): + """Run one console statement and give back its HTML transcript.""" + # the console replaces sys.stdout globally and never restores it + monkeypatch.setattr(sys, 'stdout', sys.stdout) + return Console(globals, locals, context).eval(source) + + +def test_expression_result_is_rendered_as_html(monkeypatch): + assert evaluate(monkeypatch, '6*7') == \ + '>>> 6*7\n42' + + +def test_code_runs_in_the_given_namespaces(monkeypatch): + transcript = evaluate(monkeypatch, 'frame_local + offset', + globals={'offset': 1}, locals={'frame_local': 42}) + assert transcript.endswith('43') + + +def test_context_is_available_as_ctx(monkeypatch): + transcript = evaluate(monkeypatch, 'ctx["request"]', + context={'request': 'fake-request'}) + assert transcript.endswith('\'fake-request\'') + + +def test_printed_output_is_escaped(monkeypatch): + assert evaluate(monkeypatch, "print('')") == ">>> print('')\n<b>\n" + + +def test_dump_lists_the_object_contents(monkeypatch): + transcript = evaluate(monkeypatch, 'dump({"a": 1})') + assert '

Contents of dict object at' in transcript + assert 'a
1
' \ + in transcript + + +def test_dump_of_non_string_keyed_dict_falls_back_to_details(monkeypatch): + # only string keys can be listed as contents, anything else is introspected + assert '

Details for dict object at' in evaluate(monkeypatch, 'dump({1: "x"})') + + +def test_dump_without_arguments_lists_the_frame_locals(monkeypatch): + transcript = evaluate(monkeypatch, 'dump()', locals={'frame_local': 42}) + assert '

Local variables in frame

' in transcript + assert 'frame_local
' \
+        '42
' in transcript + + +def test_help_renders_the_documentation_as_html(monkeypatch): + transcript = evaluate(monkeypatch, 'help(len)') + assert '

Help on built-in function len' in transcript + assert '
len(' in transcript
+    assert evaluate(monkeypatch, 'help').endswith(
+        'Type help(object) for help about object.')
+
+
+def test_exceptions_are_rendered_as_a_traceback(monkeypatch):
+    transcript = evaluate(monkeypatch, '1/0')
+    assert '
' in transcript + assert '
1/0
' in transcript # the offending console line + assert '
ZeroDivisionError: division by zero
' in transcript diff --git a/tests/test_mail_reporter.py b/tests/test_mail_reporter.py new file mode 100644 index 0000000..e42bffd --- /dev/null +++ b/tests/test_mail_reporter.py @@ -0,0 +1,154 @@ +"""Behavioral tests for the email crash reporter. + +Covers the assembled message (subject, recipients, body sections), the SMTP +conversation, local frames dumping and the required settings validation. +The network is never touched: smtplib.SMTP is replaced by a recorder. +""" +import email +import smtplib + +import pytest + +from backlash.tbtools import get_current_traceback +from backlash.tracing.reporters.mail import EmailReporter +from backlash.utils import RequestContext + + +def install_fake_smtp(monkeypatch): + """Replace smtplib.SMTP with a recorder: returns the list of connections.""" + connections = [] + + class FakeSMTP(object): + def __init__(self, host, port): + self.host = host + self.port = port + self.sent = [] + self.tls = False + self.credentials = None + self.quit_called = False + connections.append(self) + + def ehlo(self): + pass + + def starttls(self): + self.tls = True + + def login(self, username, password): + self.credentials = (username, password) + + def sendmail(self, from_address, recipients, message): + self.sent.append((from_address, recipients, message)) + + def quit(self): + self.quit_called = True + + monkeypatch.setattr(smtplib, 'SMTP', FakeSMTP) + return connections + + +def crash_traceback(message='boom %d'): + """Real traceback with a request context, as the middlewares build it.""" + try: + frame_local = 42 # must be visible when dumping local frames + raise ZeroDivisionError(message % frame_local) + except ZeroDivisionError: + return get_current_traceback(context=RequestContext({'environ': { + 'PATH_INFO': '/crash', + 'REQUEST_METHOD': 'GET', + 'wsgi.url_scheme': 'http', + }})) + + +def sent_message(connection): + """Parse the single message the reporter handed to SMTP.""" + _, _, raw = connection.sent[0] + return email.message_from_string(raw) + + +def body_of(message): + return message.get_payload(0).get_payload(decode=True).decode('utf-8') + + +def test_report_sends_the_assembled_email(monkeypatch): + connections = install_fake_smtp(monkeypatch) + reporter = EmailReporter(smtp_server='smtp.example.com', smtp_port=2525, + from_address='crash@example.com', + error_email='dev@example.com,ops@example.com', + error_subject_prefix='[APP] ') + reporter.report(crash_traceback()) + + connection, = connections + assert (connection.host, connection.port) == ('smtp.example.com', 2525) + assert connection.quit_called + from_address, recipients, _ = connection.sent[0] + assert from_address == 'crash@example.com' + assert recipients == ['dev@example.com', 'ops@example.com'] + + message = sent_message(connection) + assert message['Subject'] == '[APP] ZeroDivisionError: boom 42' + assert message['From'] == 'crash@example.com' + assert message['To'] == 'dev@example.com, ops@example.com' + body = body_of(message) + assert 'TRACEBACK:\nTraceback (most recent call last):' in body + assert 'ZeroDivisionError: boom 42' in body + assert "\tPATH_INFO: '/crash'" in body # CGI keys in the ENVIRON section + assert "\twsgi.url_scheme: 'http'" in body # lowercase keys in the WSGI one + assert body.index('ENVIRON:') < body.index('WSGI:') + assert 'FRAME #' not in body + + +def test_multiline_exception_message_is_flattened_in_the_subject(monkeypatch): + connections = install_fake_smtp(monkeypatch) + reporter = EmailReporter(smtp_server='smtp.example.com', + from_address='crash@example.com', + error_email='dev@example.com') + reporter.report(crash_traceback('boom\non two lines %d')) + assert sent_message(connections[0])['Subject'] == \ + 'ZeroDivisionError: boom on two lines 42' + + +def test_non_ascii_report_is_utf8_encoded(monkeypatch): + connections = install_fake_smtp(monkeypatch) + reporter = EmailReporter(smtp_server='smtp.example.com', + from_address='crash@example.com', + error_email='dev@example.com') + reporter.report(crash_traceback('boom pi\u00f9 %d')) + assert 'boom pi\u00f9 42' in body_of(sent_message(connections[0])) + + +def test_dump_local_frames_includes_frame_variables(monkeypatch): + connections = install_fake_smtp(monkeypatch) + reporter = EmailReporter(smtp_server='smtp.example.com', + from_address='crash@example.com', + error_email='dev@example.com', + dump_local_frames=True, + dump_local_frames_count=1) + reporter.report(crash_traceback()) + body = body_of(sent_message(connections[0])) + assert 'LAST FRAMES:' in body + assert 'FRAME #' in body + assert ' frame_local = 42' in body + + +def test_tls_and_authentication(monkeypatch): + connections = install_fake_smtp(monkeypatch) + reporter = EmailReporter(smtp_server='smtp.example.com', + from_address='crash@example.com', + error_email=['dev@example.com', 'ops@example.com'], + smtp_username='user', smtp_password='secret', + smtp_use_tls=True) + reporter.report(crash_traceback()) + assert connections[0].tls + assert connections[0].credentials == ('user', 'secret') + + +def test_incomplete_configuration_is_rejected(): + for missing in ('smtp_server', 'from_address', 'error_email'): + settings = {'smtp_server': 'smtp.example.com', + 'from_address': 'crash@example.com', + 'error_email': 'dev@example.com'} + del settings[missing] + with pytest.raises(ValueError) as error: + EmailReporter(**settings) + assert 'smtp_server, from_address and error_email' in str(error.value) diff --git a/tests/test_repr.py b/tests/test_repr.py new file mode 100644 index 0000000..46ffc39 --- /dev/null +++ b/tests/test_repr.py @@ -0,0 +1,58 @@ +"""Behavioral tests for the HTML object representations of the debugger. + +Pins the markup ``debug_repr`` produces for the types the debugger renders +most: strings, bytes, numbers, regexes, containers and broken objects. +""" +import re + +from backlash.repr import debug_repr + + +def test_text_and_bytes_are_rendered_as_string_spans(): + assert debug_repr('hi') == '\'hi\'' + assert debug_repr(b'hi') == 'b\'hi\'' + # binary payloads are decoded as utf-8 before being shown + assert debug_repr(b'pi\xc3\xb9') == 'b\'pi\u00f9\'' + + +def test_html_in_values_is_escaped(): + assert debug_repr('