Skip to content

Commit 18fb1fb

Browse files
BNMetricsvsajip
authored andcommitted
bpo-34844: logging.Formatter enhancement - Ensure style and format string matches in logging.Formatter (GH-9703)
1 parent e890421 commit 18fb1fb

File tree

6 files changed

+403
-22
lines changed

6 files changed

+403
-22
lines changed

Doc/library/logging.config.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ otherwise, the context is used to determine what to instantiate.
226226
(with defaults of ``None``) and these are used to construct a
227227
:class:`~logging.Formatter` instance.
228228

229+
.. versionchanged:: 3.8
230+
a ``validate`` key (with default of ``True``) can be added into
231+
the ``formatters`` section of the configuring dict, this is to
232+
validate the format.
233+
229234
* *filters* - the corresponding value will be a dict in which each key
230235
is a filter id and each value is a dict describing how to configure
231236
the corresponding Filter instance.

Doc/library/logging.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,10 @@ The useful mapping keys in a :class:`LogRecord` are given in the section on
544544
.. versionchanged:: 3.2
545545
The *style* parameter was added.
546546

547+
.. versionchanged:: 3.8
548+
The *validate* parameter was added. Incorrect or mismatched style and fmt
549+
will raise a ``ValueError``.
550+
For example: ``logging.Formatter('%(asctime)s - %(message)s', style='{')``.
547551

548552
.. method:: format(record)
549553

Lib/logging/__init__.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
To use, simply 'import logging' and log away!
2424
"""
2525

26-
import sys, os, time, io, traceback, warnings, weakref, collections.abc
26+
import sys, os, time, io, re, traceback, warnings, weakref, collections.abc
2727

2828
from string import Template
29+
from string import Formatter as StrFormatter
30+
2931

3032
__all__ = ['BASIC_FORMAT', 'BufferingFormatter', 'CRITICAL', 'DEBUG', 'ERROR',
3133
'FATAL', 'FileHandler', 'Filter', 'Formatter', 'Handler', 'INFO',
@@ -413,33 +415,71 @@ def makeLogRecord(dict):
413415
rv.__dict__.update(dict)
414416
return rv
415417

418+
416419
#---------------------------------------------------------------------------
417420
# Formatter classes and functions
418421
#---------------------------------------------------------------------------
422+
_str_formatter = StrFormatter()
423+
del StrFormatter
424+
419425

420426
class PercentStyle(object):
421427

422428
default_format = '%(message)s'
423429
asctime_format = '%(asctime)s'
424430
asctime_search = '%(asctime)'
431+
validation_pattern = re.compile(r'%\(\w+\)[#0+ -]*(\*|\d+)?(\.(\*|\d+))?[diouxefgcrsa%]', re.I)
425432

426433
def __init__(self, fmt):
427434
self._fmt = fmt or self.default_format
428435

429436
def usesTime(self):
430437
return self._fmt.find(self.asctime_search) >= 0
431438

432-
def format(self, record):
439+
def validate(self):
440+
"""Validate the input format, ensure it matches the correct style"""
441+
if not self.validation_pattern.search(self._fmt):
442+
raise ValueError("Invalid format '%s' for '%s' style" % (self._fmt, self.default_format[0]))
443+
444+
def _format(self, record):
433445
return self._fmt % record.__dict__
434446

447+
def format(self, record):
448+
try:
449+
return self._format(record)
450+
except KeyError as e:
451+
raise ValueError('Formatting field not found in record: %s' % e)
452+
453+
435454
class StrFormatStyle(PercentStyle):
436455
default_format = '{message}'
437456
asctime_format = '{asctime}'
438457
asctime_search = '{asctime'
439458

440-
def format(self, record):
459+
fmt_spec = re.compile(r'^(.?[<>=^])?[+ -]?#?0?(\d+|{\w+})?[,_]?(\.(\d+|{\w+}))?[bcdefgnosx%]?$', re.I)
460+
field_spec = re.compile(r'^(\d+|\w+)(\.\w+|\[[^]]+\])*$')
461+
462+
def _format(self, record):
441463
return self._fmt.format(**record.__dict__)
442464

465+
def validate(self):
466+
"""Validate the input format, ensure it is the correct string formatting style"""
467+
fields = set()
468+
try:
469+
for _, fieldname, spec, conversion in _str_formatter.parse(self._fmt):
470+
if fieldname:
471+
if not self.field_spec.match(fieldname):
472+
raise ValueError('invalid field name/expression: %r' % fieldname)
473+
fields.add(fieldname)
474+
if conversion and conversion not in 'rsa':
475+
raise ValueError('invalid conversion: %r' % conversion)
476+
if spec and not self.fmt_spec.match(spec):
477+
raise ValueError('bad specifier: %r' % spec)
478+
except ValueError as e:
479+
raise ValueError('invalid format: %s' % e)
480+
if not fields:
481+
raise ValueError('invalid format: no fields')
482+
443483

444484
class StringTemplateStyle(PercentStyle):
445485
default_format = '${message}'
@@ -454,9 +494,24 @@ def usesTime(self):
454494
fmt = self._fmt
455495
return fmt.find('$asctime') >= 0 or fmt.find(self.asctime_format) >= 0
456496

457-
def format(self, record):
497+
def validate(self):
498+
pattern = Template.pattern
499+
fields = set()
500+
for m in pattern.finditer(self._fmt):
501+
d = m.groupdict()
502+
if d['named']:
503+
fields.add(d['named'])
504+
elif d['braced']:
505+
fields.add(d['braced'])
506+
elif m.group(0) == '$':
507+
raise ValueError('invalid format: bare \'$\' not allowed')
508+
if not fields:
509+
raise ValueError('invalid format: no fields')
510+
511+
def _format(self, record):
458512
return self._tpl.substitute(**record.__dict__)
459513

514+
460515
BASIC_FORMAT = "%(levelname)s:%(name)s:%(message)s"
461516

462517
_STYLES = {
@@ -510,7 +565,7 @@ class Formatter(object):
510565

511566
converter = time.localtime
512567

513-
def __init__(self, fmt=None, datefmt=None, style='%'):
568+
def __init__(self, fmt=None, datefmt=None, style='%', validate=True):
514569
"""
515570
Initialize the formatter with specified format strings.
516571
@@ -530,6 +585,9 @@ def __init__(self, fmt=None, datefmt=None, style='%'):
530585
raise ValueError('Style must be one of: %s' % ','.join(
531586
_STYLES.keys()))
532587
self._style = _STYLES[style][0](fmt)
588+
if validate:
589+
self._style.validate()
590+
533591
self._fmt = self._style._fmt
534592
self.datefmt = datefmt
535593

Lib/logging/config.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,11 +666,19 @@ def configure_formatter(self, config):
666666
dfmt = config.get('datefmt', None)
667667
style = config.get('style', '%')
668668
cname = config.get('class', None)
669+
669670
if not cname:
670671
c = logging.Formatter
671672
else:
672673
c = _resolve(cname)
673-
result = c(fmt, dfmt, style)
674+
675+
# A TypeError would be raised if "validate" key is passed in with a formatter callable
676+
# that does not accept "validate" as a parameter
677+
if 'validate' in config: # if user hasn't mentioned it, the default will be fine
678+
result = c(fmt, dfmt, style, config['validate'])
679+
else:
680+
result = c(fmt, dfmt, style)
681+
674682
return result
675683

676684
def configure_filter(self, config):

0 commit comments

Comments
 (0)