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:
melanie witt committed 2026-06-30 14:40:43 -07:00
1 parent cec1b79ea2
commit 05e0280884
5 files changed
+260 -1

No files matched your search

View File
Whitespace-only changes.
+101
View File
@@ -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)
+151
View File
@@ -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)
+1
View File
@@ -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
+7 -1
View File
@@ -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