Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools
pip install -e . flake8${{ matrix.flake }} six
pip install -e . flake8${{ matrix.flake }}
- name: Run tests
run: python -m unittest -v
42 changes: 4 additions & 38 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ String format parameter checker
:target: https://pypi.python.org/pypi/flake8-string-format

An extension for `Flake8 <https://pypi.python.org/pypi/flake8>`_ to check the
strings and parameters using ``str.format``. It checks all strings whether they
use numbered parameters with an implicit index which isn't support in
Python 2.6.
strings and parameters using ``str.format``.

In all instances of ``'…'.format(…)`` it will also check whether there are
enough parameters given. If the format call uses variable arguments, it'll just
Expand All @@ -32,16 +30,13 @@ is available in ``flake8``::
$ flake8 --version
3.0.2 (flake8-string-format: 0.2.3, […]

This plugin supports Flake8 2.6 as well as Flake8 3.0. Older or newer versions
may be supported too but they weren't tested.

Via ``--ignore`` it's possible to ignore unindexed parameters::

$ flake8 some_file.py
...
some_file.py:1:1: FMT101 format string does contain unindexed parameters
some_file.py:1:1: FMT201 format call index too large (5

$ flake8 --ignore FMT101 some_file.py
$ flake8 --ignore FMT201 some_file.py
...


Expand All @@ -56,14 +51,6 @@ Error codes

This plugin is using the following error codes:

+--------+---------------------------------------------------------------------+
| Presence of implicit parameters |
+--------+---------------------------------------------------------------------+
| FMT101 | format string contains unindexed parameters |
+--------+---------------------------------------------------------------------+
| FMT102 | docstring contains unindexed parameters |
+--------+---------------------------------------------------------------------+
| FMT103 | other string contains unindexed parameters |
+--------+---------------------------------------------------------------------+
| Missing values in the parameters |
+--------+---------------------------------------------------------------------+
Expand All @@ -88,19 +75,10 @@ This plugin is using the following error codes:
Operation
---------

The plugin will go through all ``bytes``, ``str`` and ``unicode`` instances. If
The plugin will go through all ``bytes``, ``str``. If
it encounters ``bytes`` instances on Python 3, it'll decode them using ASCII and
if that fails it'll skip that entry.

Depending on the usage the string is handled differently. When it is not being
formatted, it can only cause ``FMT102`` and ``FMT103``. For this plugin all
strings which are the first expression of the module or after a function or
class definition are considered docstrings.

Both ``FMT102`` and ``FMT103`` issue many false positives and should only be
used with Python 2.6 which does not support `unindexed parameters
<https://docs.python.org/3/whatsnew/2.7.html#other-language-changes>`_.

Format strings
``````````````
Every string where either the ``format`` method is called or where it is the
Expand All @@ -113,18 +91,6 @@ FMT301 and FMT302 can still be checked for any argument which is defined
statically.


Python 2.6 support
``````````````````

Python 2.6 is only partially supported as it's using Python's capability to
format a string. So if a string contains implicit parameters, it won't be
detected as a parameter on Python 2.6 and thus it won't cause any FMT1XX errors.
But it might still cause an error FMT301 when variable arguments aren't used.

So if Python 2.6 compatibility is wished and thus implicit parameters aren't
allowed, this plugin won't cause false positives.


Changes
-------
0.4.0 - 2026-06-09
Expand Down
74 changes: 13 additions & 61 deletions flake8_string_format.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
"""Extension for flake8 to test string format usage."""
from __future__ import print_function, unicode_literals

import ast
import itertools
import re
import sys

from string import Formatter

Expand All @@ -21,7 +18,7 @@ class TextVisitor(ast.NodeVisitor):
"""

def __init__(self):
super(TextVisitor, self).__init__()
super().__init__()
self.nodes = []
self.calls = {}

Expand All @@ -31,29 +28,7 @@ def _add_node(self, node):
self.nodes += [node]

def is_base_string(self, node):
# Python 3.14 removed ast.Str/ast.Bytes, but older versions still use
# or expose them, so accept modern Constant nodes first and then fall
# back to whichever legacy node types still exist on this interpreter.
if isinstance(node, ast.Constant):
return isinstance(node.value, (str, bytes))

types = []
if hasattr(ast, 'Str'):
types.append(ast.Str)
if sys.version_info[0] > 2 and hasattr(ast, 'Bytes'):
types.append(ast.Bytes)

return isinstance(node, tuple(types))

def visit_Str(self, node):
# Constant with Python 3.8 uses the value-property
node.value = node.s
self._add_node(node)

def visit_Bytes(self, node):
# Constant with Python 3.8 uses the value-property
node.value = node.s
self._add_node(node)
return isinstance(node, ast.Constant) and isinstance(node.value, (str, bytes))

def visit_Constant(self, node):
if type(node.value) in (str, bytes):
Expand Down Expand Up @@ -109,20 +84,17 @@ def visit_Call(self, node):
node.func.value.id == 'str' and node.args and
self.is_base_string(node.args[0])):
self.calls[node.args[0]] = (node, True)
super(TextVisitor, self).generic_visit(node)
super().generic_visit(node)


class StringFormatChecker(object):
class StringFormatChecker:

_FORMATTER = Formatter()
FIELD_REGEX = re.compile(r'^((?:\s|.)*?)(\..*|\[.*\])?$')

name = 'flake8-string-format'

ERRORS = {
101: 'format string contains unindexed parameters',
102: 'docstring contains unindexed parameters',
103: 'other string contains unindexed parameters',
201: 'format call index too large ({idx})',
202: 'format call uses missing keyword ({kw})',
203: 'format call uses keyword arguments but there are no keyword entries',
Expand All @@ -135,15 +107,7 @@ class StringFormatChecker(object):
def __init__(self, tree, filename):
self.tree = tree

def _generate_unindexed(self, node):
return self._generate_error(
node, 102 if node.is_docstring else 103)

def _generate_error(self, node, code, **params):
if sys.version_info[:3] == (3, 4, 2) and isinstance(node, ast.Call):
# Due to https://bugs.python.org/issue21295 we cannot use the
# Call object
node = node.func.value
msg = 'FMT{0} {1}'.format(code, self.ERRORS[code])
msg = msg.format(**params)
return node.lineno, node.col_offset, msg, type(self)
Expand Down Expand Up @@ -176,19 +140,13 @@ def run(self):
assert not (set(visitor.calls) - set(visitor.nodes))
for node in visitor.nodes:
text = node.value
if sys.version_info[0] > 2 and isinstance(text, bytes):
if isinstance(text, bytes):
try:
# TODO: Maybe decode using file encoding?
text = text.decode('ascii')
except UnicodeDecodeError as e:
continue
fields, implicit, explicit = self.get_fields(text)
if implicit:
if node in visitor.calls:
assert not node.is_docstring
yield self._generate_error(node, 101)
else:
yield self._generate_unindexed(node)

if node in visitor.calls:
call, str_args = visitor.calls[node]
Expand All @@ -212,20 +170,14 @@ def run(self):
num_args = len(call.args)
if str_args:
num_args -= 1
if sys.version_info < (3, 5):
has_kwargs = bool(call.kwargs)
has_starargs = bool(call.starargs)
else:
# With Python version 3.5 the location and number of
# kwargs/starargs has been relaxed
has_kwargs = None in keywords
has_starargs = sum(1 for arg in call.args
if isinstance(arg, ast.Starred))

if has_kwargs:
keywords.discard(None)
if has_starargs:
num_args -= has_starargs
has_kwargs = None in keywords
has_starargs = sum(1 for arg in call.args
if isinstance(arg, ast.Starred))

if has_kwargs:
keywords.discard(None)
if has_starargs:
num_args -= has_starargs

# if starargs or kwargs is not None, it can't count the
# parameters but at least check if the args are used
Expand Down
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ classifiers = [
[project.urls]
Homepage = "https://github.com/xZise/flake8-string-format"

[project.optional-dependencies]
test = ["six"]

[project.entry-points."flake8.extension"]
FMT = "flake8_string_format:StringFormatChecker"
Expand Down
1 change: 0 additions & 1 deletion requirements-dev.txt

This file was deleted.

46 changes: 11 additions & 35 deletions test_flake8_string_format.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,22 @@
from __future__ import print_function

import ast
import codecs
import itertools
import optparse
import os
import re
import sys
import tempfile

PY26 = sys.version_info[:2] == (2, 6)

if PY26:
import unittest2 as unittest
else:
import unittest
import unittest

from collections import defaultdict
from subprocess import Popen, PIPE

import six

import flake8_string_format


def generate_code():
if PY26:
working_formats = [2, 3]
else:
working_formats = [1, 2, 3]
working_formats = [1, 2, 3]
code = ['#!/usr/bin/python', '# -*- coding: utf-8 -*-', 'dummy = "line"']
positions = []
for variant in itertools.product(
['', '#', ' '], ['', 'u', 'b'], ['', '0', 'param'], ['', ':03'],
['', '#', ' '], ['', 'b'], ['', '0', 'param'], ['', ':03'],
['', 'Before'], ['', 'After']):
variant = list(variant)
indented = variant[0].startswith(' ')
Expand Down Expand Up @@ -61,12 +45,7 @@ def generate_code():
code += ['{0}{1}"{4}{{{2}{3}}}{5}"{fmt}'.format(*variant, fmt=fmt_code)]
if not variant[2] and not variant[0].strip().startswith('#') and use_format in working_formats:
column = len(variant[0])
if PY26:
expected_code = 'FMT301'
if use_format == 3:
column -= len('str.format(')
else:
expected_code = 'FMT101' if use_format > 1 else 'FMT103'
expected_code = 'FMT101' if use_format > 1 else 'FMT103'
positions += [(len(code), column, expected_code)]
return '\n'.join(code), positions

Expand Down Expand Up @@ -177,7 +156,7 @@ def __new__(cls, name, bases, dct):
assert test.__name__ not in dct
dct[test.__name__] = test

return super(ManualFileMetaClass, cls).__new__(cls, name, bases, dct)
return super().__new__(cls, name, bases, dct)

@classmethod
def _create_tests(cls, directory, filename):
Expand All @@ -192,7 +171,7 @@ def first_find(string, searched):

only_filename = filename
filename = os.path.join(directory, filename)
with codecs.open(filename, 'r', 'utf8') as f:
with open(filename, 'r', encoding='utf8') as f:
content = f.read()
all_positions = []
lines = content.splitlines()
Expand Down Expand Up @@ -248,8 +227,7 @@ def defaults(self):
return defaults


@six.add_metaclass(ManualFileMetaClass)
class TestManualFiles(SimpleImportTestCase):
class TestManualFiles(SimpleImportTestCase, metaclass=ManualFileMetaClass):

"""Test the manually created files in tests/files/."""

Expand Down Expand Up @@ -294,12 +272,11 @@ def run_test(self, positions, filename, content):
self.iterator(stdout_lines, expected_filename), positions)


@six.add_metaclass(ManualFileMetaClass)
class TestFlake8Files(Flake8CaseBase):
class TestFlake8Files(Flake8CaseBase, metaclass=ManualFileMetaClass):

def run_test(self, positions, tree, filename):
"""Test using stdin."""
super(TestFlake8Files, self).run_test(positions, filename, None)
super().run_test(positions, filename, None)


class TestFlake8StdinDynamic(Flake8CaseBase):
Expand All @@ -308,14 +285,13 @@ def test_dynamic(self):
self.run_test(dynamic_positions, None, dynamic_code.encode('utf8'))


@six.add_metaclass(ManualFileMetaClass)
class TestFlake8Stdin(Flake8CaseBase):
class TestFlake8Stdin(Flake8CaseBase, metaclass=ManualFileMetaClass):

def run_test(self, positions, tree, filename):
"""Test using stdin."""
with open(filename, 'rb') as f:
content = f.read()
super(TestFlake8Stdin, self).run_test(positions, None, content)
super().run_test(positions, None, content)


if __name__ == '__main__':
Expand Down