Add C300 hacking check for set literal in logging calls
Add a local flake8 AST check that detects set literals passed where
a dict is expected in logging calls with %-style named placeholders.
For example, LOG.info("%(key)s", {"key", val}) uses a comma instead
of a colon, creating a set instead of a dict, which causes TypeError
at runtime.
This re-adds the cyborg/hacking/ local plugin directory (removed in
8eb3a5a during the ruff migration) with a new C-prefix code range.
The old M-prefix checks are not restored as they are now covered by
ruff. The check is registered via [flake8:local-plugins] in tox.ini
and runs as part of the existing pre-commit hacking hook.
Also adds pycodestyle to test-requirements.txt, needed by the test
to exercise the check through pycodestyle's checker infrastructure.
Related-Bug: #2157892
Assisted-by: Claude claude-opus-4-6 claude-code/2.1.196
Change-Id: Ib88b17d7087f475b741a3f0ce349654f3ae20d22
Signed-off-by: melanie witt <melwittt@gmail.com>
This commit is contained in:
1 parent
cec1b79ea2
commit
05e0280884
5 files changed
+260
-1
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,101 @@
|
||||
# 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
|
||||
#
|
||||
# http://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.
|
||||
|
||||
import ast
|
||||
import re
|
||||
|
||||
|
||||
NAMED_PLACEHOLDER_RE = re.compile(r"%\([^)]+\)[a-zA-Z%]")
|
||||
|
||||
LOG_METHODS = frozenset(
|
||||
(
|
||||
"debug",
|
||||
"info",
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"exception",
|
||||
"log",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _flatten_str_chain(node):
|
||||
"""Extract a string from an AST node or Add-concatenation chain.
|
||||
|
||||
Returns the string value if the node is a string constant or a
|
||||
chain of string constants joined with ``+``. Returns ``None``
|
||||
for anything else (variables, f-strings, etc.).
|
||||
"""
|
||||
parts = []
|
||||
|
||||
def _visit(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
parts.append(node.value)
|
||||
return True
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||||
return _visit(node.left) and _visit(node.right)
|
||||
return False
|
||||
|
||||
if not _visit(node):
|
||||
return None
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
class CheckSetLiteralInLogging(ast.NodeVisitor):
|
||||
"""C300: set literal passed where dict expected in logging call.
|
||||
|
||||
Detects ``LOG.info("%(key)s", {"key", val})`` where a set literal
|
||||
(comma) was used instead of a dict literal (colon). This causes
|
||||
``TypeError: format requires a mapping`` at runtime.
|
||||
"""
|
||||
|
||||
name = "check_set_literal_in_logging"
|
||||
version = "0.1"
|
||||
|
||||
CHECK_DESC = (
|
||||
"C300 set literal passed where dict expected for %-format logging"
|
||||
)
|
||||
|
||||
def __init__(self, tree, filename):
|
||||
self._tree = tree
|
||||
self._errors = []
|
||||
|
||||
def run(self):
|
||||
self.visit(self._tree)
|
||||
return self._errors
|
||||
|
||||
def add_error(self, node, message=None):
|
||||
message = message or self.CHECK_DESC
|
||||
self._errors.append(
|
||||
(node.lineno, node.col_offset, message, type(self))
|
||||
)
|
||||
|
||||
def visit_Call(self, node):
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr in LOG_METHODS
|
||||
):
|
||||
# .log(level, msg, *args) vs .info(msg, *args)
|
||||
if node.func.attr == "log":
|
||||
fmt_idx, dict_idx = 1, 2
|
||||
else:
|
||||
fmt_idx, dict_idx = 0, 1
|
||||
|
||||
if len(node.args) >= dict_idx + 1 and isinstance(
|
||||
node.args[dict_idx], ast.Set
|
||||
):
|
||||
fmt_str = _flatten_str_chain(node.args[fmt_idx])
|
||||
if fmt_str and NAMED_PLACEHOLDER_RE.search(fmt_str):
|
||||
self.add_error(node.args[dict_idx])
|
||||
|
||||
self.generic_visit(node)
|
||||
@@ -0,0 +1,151 @@
|
||||
# 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
|
||||
#
|
||||
# http://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.
|
||||
|
||||
import textwrap
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pycodestyle
|
||||
|
||||
from cyborg.hacking import checks
|
||||
from cyborg.tests import base
|
||||
|
||||
|
||||
class HackingTestCase(base.TestCase):
|
||||
@mock.patch(
|
||||
'pycodestyle._checks',
|
||||
{'physical_line': {}, 'logical_line': {}, 'tree': {}},
|
||||
)
|
||||
def _run_check(self, code, checker, filename=None):
|
||||
pycodestyle.register_check(checker)
|
||||
lines = textwrap.dedent(code).strip().splitlines(True)
|
||||
checker = pycodestyle.Checker(filename=filename, lines=lines)
|
||||
with mock.patch('pycodestyle.StandardReport.get_file_results'):
|
||||
checker.check_all()
|
||||
checker.report._deferred_print.sort()
|
||||
return checker.report._deferred_print
|
||||
|
||||
def _assert_has_errors(
|
||||
self, code, checker, expected_errors=None, filename=None
|
||||
):
|
||||
actual_errors = [
|
||||
e[:3] for e in self._run_check(code, checker, filename)
|
||||
]
|
||||
self.assertEqual(expected_errors or [], actual_errors)
|
||||
|
||||
def _assert_has_no_errors(self, code, checker, filename=None):
|
||||
self._assert_has_errors(code, checker, filename=filename)
|
||||
|
||||
def test_set_literal_attr_call(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
LOG = logging.getLogger(__name__)
|
||||
LOG.info("%(fruit)s is ready", {"fruit", fruit})
|
||||
"""
|
||||
self._assert_has_errors(
|
||||
code, checker, expected_errors=[(3, 31, "C300")]
|
||||
)
|
||||
|
||||
def test_set_literal_log_method(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
LOG = logging.getLogger(__name__)
|
||||
LOG.log(logging.INFO, "%(fruit)s", {"fruit", fruit})
|
||||
"""
|
||||
self._assert_has_errors(
|
||||
code, checker, expected_errors=[(3, 35, "C300")]
|
||||
)
|
||||
|
||||
def test_set_literal_module_call(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
logging.info("%(fruit)s is ready", {"fruit", fruit})
|
||||
"""
|
||||
self._assert_has_errors(
|
||||
code, checker, expected_errors=[(2, 35, "C300")]
|
||||
)
|
||||
|
||||
def test_set_literal_multiple_placeholders(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
LOG = logging.getLogger(__name__)
|
||||
LOG.info("%(a)s %(b)s", {"a", a, "b", b})
|
||||
"""
|
||||
self._assert_has_errors(
|
||||
code, checker, expected_errors=[(3, 24, "C300")]
|
||||
)
|
||||
|
||||
def test_set_literal_concat_format_string(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
LOG = logging.getLogger(__name__)
|
||||
LOG.info("%(fruit)s" + " is ready", {"fruit", fruit})
|
||||
"""
|
||||
self._assert_has_errors(
|
||||
code, checker, expected_errors=[(3, 36, "C300")]
|
||||
)
|
||||
|
||||
def test_set_literal_multiline(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
LOG = logging.getLogger(__name__)
|
||||
LOG.info(
|
||||
"%(fruit)s is ready",
|
||||
{"fruit", fruit},
|
||||
)
|
||||
"""
|
||||
self._assert_has_errors(
|
||||
code, checker, expected_errors=[(5, 4, "C300")]
|
||||
)
|
||||
|
||||
def test_dict_literal_no_error(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
LOG = logging.getLogger(__name__)
|
||||
LOG.info("%(fruit)s is ready", {"fruit": fruit})
|
||||
"""
|
||||
self._assert_has_no_errors(code, checker)
|
||||
|
||||
def test_positional_placeholder_no_error(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
LOG = logging.getLogger(__name__)
|
||||
LOG.info("items: %s", {a, b})
|
||||
"""
|
||||
self._assert_has_no_errors(code, checker)
|
||||
|
||||
def test_no_placeholders_no_error(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
LOG = logging.getLogger(__name__)
|
||||
LOG.info("no placeholders", {"a", b})
|
||||
"""
|
||||
self._assert_has_no_errors(code, checker)
|
||||
|
||||
def test_variable_format_string_no_error(self):
|
||||
checker = checks.CheckSetLiteralInLogging
|
||||
code = """
|
||||
import logging
|
||||
LOG = logging.getLogger(__name__)
|
||||
msg = "%(fruit)s is ready"
|
||||
LOG.info(msg, {"fruit", fruit})
|
||||
"""
|
||||
self._assert_has_no_errors(code, checker)
|
||||
@@ -6,3 +6,4 @@ testtools>=2.4.0 # MIT
|
||||
doc8>=0.6.0 # Apache-2.0
|
||||
Pygments>=2.2.0 # BSD license
|
||||
WebTest>=2.0.27 # MIT
|
||||
pycodestyle>=2.11.0 # MIT
|
||||
@@ -109,7 +109,7 @@ allowlist_externals = rm
|
||||
|
||||
[flake8]
|
||||
# We only enable the hacking (H) checks; ruff handles everything else
|
||||
select = H
|
||||
select = H,C
|
||||
# H301 ruff-I enforces one-symbol-per-line where needed
|
||||
# H306 ruff-I (rule I001) handles import ordering; its sort key (by
|
||||
# from-module path) differs from H306's (fully-qualified name),
|
||||
@@ -119,3 +119,9 @@ select = H
|
||||
ignore = H301,H306,H404,H405
|
||||
show-source = True
|
||||
exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build,releasenotes,*sqlalchemy/alembic/versions/*
|
||||
|
||||
[flake8:local-plugins]
|
||||
extension =
|
||||
C300 = checks:CheckSetLiteralInLogging
|
||||
paths =
|
||||
./cyborg/hacking
|
||||
Reference in new issue
Block a user