diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..006f8f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install --upgrade pip + - run: python -m pip install -e ".[dev]" + - run: python -m unittest discover -s tests -v + - run: python -m flake8 src tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0e9492 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.coverage +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.tox/ +build/ +dist/ +*.egg-info/ +__pycache__/ +*.py[cod] +.venv/ +venv/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5862b21 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Hacktoolkit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md index 2e8851b..42625a1 100644 --- a/README.md +++ b/README.md @@ -1 +1,70 @@ # flake8-htk-rules + +Hacktoolkit Flake8 rules for structured Python code, datetime clarity, +debugger prevention, and naming precision. + +## Installation + +```bash +pip install flake8-htk-rules +``` + +For local development: + +```bash +python -m pip install -e ".[dev]" +python -m unittest discover -s tests -v +``` + +## Rules + +| Code | Description | +| --- | --- | +| `SP100` | Functions in configured files should prefer a single return statement. | +| `SP101` | Return values in configured files should be simple variables, attributes, literals, or bare returns. | +| `DT100` | Use `import datetime` instead of `from datetime import datetime`. | +| `DT101` | Use `import datetime` instead of `from datetime import date`. | +| `DT102` | Use `import datetime` instead of `from datetime import timedelta`. | +| `DB100` | Do not commit debugger imports such as `import pdb` or `from pdb import set_trace`. | +| `DB101` | Do not commit debugger calls such as `breakpoint()` or `pdb.set_trace()`. | +| `NM100` | Avoid the vague `get_` function or method prefix; choose a more precise verb. | + +## Flake8 Configuration + +Enable the rules: + +```ini +[flake8] +select = SP,DT,DB,NM +structured-programming-files = + accounts/services.py + accounts/view_helpers.py + accounts/views.py +``` + +Or combine with existing checks: + +```ini +[flake8] +extend-select = SP,DT,DB,NM +structured-programming-files = + accounts/services.py + accounts/view_helpers.py + accounts/views.py +``` + +The `SP` rules are gated by `structured-programming-files` so teams can roll them +out on a targeted set of modules. The `DT`, `DB`, and `NM` rules are always +active when selected. + +## Development + +The plugin uses a single Flake8 entry point and delegates rule logic to +family modules under `src/flake8_htk_rules/checks/`. Add new rule families +there and cover them in `tests/`. + +Run the test suite without installing the package: + +```bash +PYTHONPATH=src python -m unittest discover -s tests -v +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e56604b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["hatchling>=1.21"] +build-backend = "hatchling.build" + +[project] +name = "flake8-htk-rules" +version = "0.1.0" +description = "Hacktoolkit Flake8 rules for structured Python, datetime clarity, debugger prevention, and naming precision." +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [ + { name = "Hacktoolkit" }, + { name = "Jonathan Tsai" } +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Framework :: Flake8", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Quality Assurance", +] +dependencies = [ + "flake8>=5", +] + +[project.optional-dependencies] +dev = [ + "build>=1.0", + "flake8>=5", + "pytest>=7", +] + +[project.urls] +Homepage = "https://github.com/hacktoolkit/flake8-htk-rules" +Issues = "https://github.com/hacktoolkit/flake8-htk-rules/issues" + +[project.entry-points."flake8.extension"] +HTK = "flake8_htk_rules:Plugin" + +[tool.hatch.build.targets.wheel] +packages = ["src/flake8_htk_rules"] + diff --git a/src/flake8_htk_rules/__init__.py b/src/flake8_htk_rules/__init__.py new file mode 100644 index 0000000..13c9b4d --- /dev/null +++ b/src/flake8_htk_rules/__init__.py @@ -0,0 +1,60 @@ +"""Flake8 entry point for Hacktoolkit rules.""" + +from __future__ import annotations + +import ast +from collections.abc import Iterator + +from .checks import HtkVisitor + +__version__ = "0.1.0" + + +class Plugin: + """Flake8 AST plugin entry point.""" + + name = "flake8-htk-rules" + version = __version__ + structured_programming_files: tuple[str, ...] = () + + def __init__(self, tree: ast.AST, filename: str = "") -> None: + self.tree = tree + self.filename = filename + + @classmethod + def add_options(cls, parser) -> None: + parser.add_option( + "--structured-programming-files", + parse_from_config=True, + comma_separated_list=True, + default=[], + help=( + "Comma-separated file globs for structured programming " + "checks." + ), + ) + + @classmethod + def parse_options(cls, options) -> None: + cls.structured_programming_files = tuple( + pattern.strip() + for pattern in getattr(options, "structured_programming_files", []) + if pattern.strip() + ) + + def run(self) -> Iterator[tuple[int, int, str, type["Plugin"]]]: + visitor = HtkVisitor( + filename=self.filename, + structured_programming_files=self.structured_programming_files, + ) + visitor.visit(self.tree) + for violation in visitor.violations: + yield ( + violation.line, + violation.column, + violation.message, + type(self), + ) + + +__all__ = ["Plugin", "__version__"] diff --git a/src/flake8_htk_rules/checks/__init__.py b/src/flake8_htk_rules/checks/__init__.py new file mode 100644 index 0000000..6951d25 --- /dev/null +++ b/src/flake8_htk_rules/checks/__init__.py @@ -0,0 +1,90 @@ +"""Check orchestration for Hacktoolkit Flake8 rules.""" + +from __future__ import annotations + +import ast + +from . import datetime as datetime_checks +from . import debugger, naming, structured +from .types import Violation + + +class HtkVisitor(ast.NodeVisitor): + """Collect Hacktoolkit rule violations from a Python AST.""" + + def __init__( + self, + *, + filename: str, + structured_programming_files: tuple[str, ...] = (), + ) -> None: + self.filename = filename + self.structured_programming_files = structured_programming_files + self.violations: list[Violation] = [] + self._debugger_state = debugger.DebuggerState() + + def visit_Import(self, node: ast.Import) -> None: + for violation_node, message in debugger.check_import( + node, + self._debugger_state, + ): + self._add(violation_node, node, message) + self.generic_visit(node) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + for violation_node, message in datetime_checks.check_import_from(node): + self._add(violation_node, node, message) + for violation_node, message in debugger.check_import_from( + node, + self._debugger_state, + ): + self._add(violation_node, node, message) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + for violation_node, message in debugger.check_call( + node, + self._debugger_state, + ): + self._add(violation_node, node, message) + self.generic_visit(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._check_function(node) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._check_function(node) + self.generic_visit(node) + + def _check_function( + self, + node: ast.FunctionDef | ast.AsyncFunctionDef, + ) -> None: + for violation_node, message in naming.check_function(node): + self._add(violation_node, node, message) + + if not structured.should_check_file( + self.filename, + self.structured_programming_files, + ): + return + + for violation_node, message in structured.check_function(node): + self._add(violation_node, node, message) + + def _add(self, node: ast.AST, fallback: ast.AST, message: str) -> None: + self.violations.append( + Violation( + line=getattr(node, "lineno", getattr(fallback, "lineno", 1)), + column=getattr( + node, + "col_offset", + getattr(fallback, "col_offset", 0), + ), + message=message, + ) + ) + + +__all__ = ["HtkVisitor", "Violation"] diff --git a/src/flake8_htk_rules/checks/datetime.py b/src/flake8_htk_rules/checks/datetime.py new file mode 100644 index 0000000..0dce7c0 --- /dev/null +++ b/src/flake8_htk_rules/checks/datetime.py @@ -0,0 +1,37 @@ +"""Datetime clarity checks.""" + +from __future__ import annotations + +import ast + + +DT100 = ( + "DT100 use 'import datetime' instead of " + "'from datetime import datetime'" +) +DT101 = ( + "DT101 use 'import datetime' instead of " + "'from datetime import date'" +) +DT102 = ( + "DT102 use 'import datetime' instead of " + "'from datetime import timedelta'" +) + +DATETIME_IMPORT_MESSAGES = { + "datetime": DT100, + "date": DT101, + "timedelta": DT102, +} + + +def check_import_from(node: ast.ImportFrom) -> list[tuple[ast.AST, str]]: + if node.module != "datetime" or node.level != 0: + return [] + + violations = [] + for alias in node.names: + message = DATETIME_IMPORT_MESSAGES.get(alias.name) + if message is not None: + violations.append((alias, message)) + return violations diff --git a/src/flake8_htk_rules/checks/debugger.py b/src/flake8_htk_rules/checks/debugger.py new file mode 100644 index 0000000..e02c1fc --- /dev/null +++ b/src/flake8_htk_rules/checks/debugger.py @@ -0,0 +1,91 @@ +"""Debugger prevention checks.""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass, field + + +DB100 = "DB100 do not commit debugger imports" +DB101 = "DB101 do not commit debugger calls" + +DEBUGGER_MODULES = {"pdb", "ipdb", "pudb", "wdb"} +DEBUGGER_METHODS = { + "set_trace", + "post_mortem", + "pm", + "runcall", + "runctx", +} + + +@dataclass +class DebuggerState: + module_aliases: set[str] = field(default_factory=set) + call_aliases: set[str] = field(default_factory=set) + + +def check_import( + node: ast.Import, + state: DebuggerState, +) -> list[tuple[ast.AST, str]]: + violations = [] + for alias in node.names: + root_module = alias.name.split(".", 1)[0] + if root_module in DEBUGGER_MODULES: + state.module_aliases.add(alias.asname or root_module) + violations.append((alias, DB100)) + return violations + + +def check_import_from( + node: ast.ImportFrom, + state: DebuggerState, +) -> list[tuple[ast.AST, str]]: + root_module = (node.module or "").split(".", 1)[0] + if root_module not in DEBUGGER_MODULES: + return [] + + violations = [] + for alias in node.names: + violations.append((alias, DB100)) + if alias.name == "*": + state.call_aliases.update(DEBUGGER_METHODS) + elif alias.name in DEBUGGER_METHODS: + state.call_aliases.add(alias.asname or alias.name) + return violations + + +def check_call( + node: ast.Call, + state: DebuggerState, +) -> list[tuple[ast.AST, str]]: + name = _call_name(node.func) + if _is_debugger_call(name, state): + return [(node, DB101)] + return [] + + +def _call_name(node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + parent_name = _call_name(node.value) + if parent_name: + return f"{parent_name}.{node.attr}" + return node.attr + return "" + + +def _is_debugger_call(name: str, state: DebuggerState) -> bool: + if name == "breakpoint" or name in state.call_aliases: + return True + if "." not in name: + return False + + module_name, method_name = name.rsplit(".", 1) + root_module = module_name.split(".", 1)[0] + return ( + method_name in DEBUGGER_METHODS + and root_module in DEBUGGER_MODULES | state.module_aliases + ) diff --git a/src/flake8_htk_rules/checks/naming.py b/src/flake8_htk_rules/checks/naming.py new file mode 100644 index 0000000..42929cb --- /dev/null +++ b/src/flake8_htk_rules/checks/naming.py @@ -0,0 +1,20 @@ +"""Naming precision checks.""" + +from __future__ import annotations + +import ast + + +NM100 = ( + "NM100 avoid get_ function prefix; choose a more precise verb " + "such as build_, calculate_, extract_, fetch_, look_up_, " + "retrieve_, format_, or transform_" +) + + +def check_function( + node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> list[tuple[ast.AST, str]]: + if node.name.startswith("get_"): + return [(node, NM100)] + return [] diff --git a/src/flake8_htk_rules/checks/structured.py b/src/flake8_htk_rules/checks/structured.py new file mode 100644 index 0000000..db7712e --- /dev/null +++ b/src/flake8_htk_rules/checks/structured.py @@ -0,0 +1,103 @@ +"""Structured programming checks.""" + +from __future__ import annotations + +import ast +import os +from fnmatch import fnmatch + + +SP100 = ( + "SP100 function '{name}' has {count} return statements; " + "prefer a single return per function" +) +SP101 = ( + "SP101 return value should be a simple variable, attribute, or literal, " + "not a complex expression" +) + + +def should_check_file( + filename: str, + structured_programming_files: tuple[str, ...], +) -> bool: + if not structured_programming_files: + return False + + relative_path = os.path.relpath(filename, os.getcwd()).replace(os.sep, "/") + return any( + fnmatch(relative_path, pattern) + for pattern in structured_programming_files + ) + + +def check_function( + node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> list[tuple[ast.AST, str]]: + violations: list[tuple[ast.AST, str]] = [] + returns = _collect_returns(node) + + if len(returns) > 1: + violations.append( + (node, SP100.format(name=node.name, count=len(returns))) + ) + + for return_node in returns: + if not _is_simple_return_value(return_node.value): + violations.append((return_node, SP101)) + + return violations + + +def _collect_returns( + function_node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> list[ast.Return]: + collector = _ReturnCollector(function_node) + collector.visit(function_node) + return collector.returns + + +def _is_simple_return_value(value: ast.expr | None) -> bool: + return ( + value is None + or isinstance(value, (ast.Name, ast.Attribute)) + or _is_literal_value(value) + ) + + +def _is_literal_value(value: ast.expr) -> bool: + if isinstance(value, ast.Constant): + return True + if isinstance(value, (ast.Tuple, ast.List, ast.Set)): + return all(_is_literal_value(element) for element in value.elts) + if isinstance(value, ast.Dict): + return all( + key is not None and _is_literal_value(key) + for key in value.keys + ) and all(_is_literal_value(item) for item in value.values) + if isinstance(value, ast.UnaryOp) and isinstance( + value.op, + (ast.UAdd, ast.USub), + ): + return _is_literal_value(value.operand) + return False + + +class _ReturnCollector(ast.NodeVisitor): + def __init__(self, root: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + self.root = root + self.returns: list[ast.Return] = [] + + def visit_Return(self, node: ast.Return) -> None: + self.returns.append(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + if node is self.root: + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + if node is self.root: + self.generic_visit(node) + + def visit_Lambda(self, node: ast.Lambda) -> None: + return diff --git a/src/flake8_htk_rules/checks/types.py b/src/flake8_htk_rules/checks/types.py new file mode 100644 index 0000000..26ef7dc --- /dev/null +++ b/src/flake8_htk_rules/checks/types.py @@ -0,0 +1,12 @@ +"""Shared types for Hacktoolkit checks.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, order=True) +class Violation: + line: int + column: int + message: str diff --git a/tests/test_flake8_integration.py b/tests/test_flake8_integration.py new file mode 100644 index 0000000..9813551 --- /dev/null +++ b/tests/test_flake8_integration.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +class Flake8IntegrationTest(unittest.TestCase): + def test_installed_flake8_entry_point_reports_rule_families(self) -> None: + repo_root = Path(__file__).resolve().parents[1] + source = """ +from datetime import datetime +import pdb + +def get_total(values): + if values: + pdb.set_trace() + return sum(values) + return 0 +""" + with tempfile.TemporaryDirectory() as tmpdir: + sample_path = Path(tmpdir) / "sample.py" + sample_path.write_text(source) + relative_path = os.path.relpath(sample_path, repo_root).replace( + os.sep, + "/", + ) + result = subprocess.run( + [ + sys.executable, + "-m", + "flake8", + str(sample_path), + "--select=SP,DT,DB,NM", + f"--structured-programming-files={relative_path}", + ], + cwd=repo_root, + check=False, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + for code in ("DT100", "DB100", "NM100", "SP100", "DB101", "SP101"): + self.assertIn(code, result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..9890afc --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +import ast +import unittest +from types import SimpleNamespace + +from flake8_htk_rules import Plugin + +SP100_SAMPLE = ( + "SP100 function 'sample' has 2 return statements; " + "prefer a single return per function" +) +SP101 = ( + "SP101 return value should be a simple variable, attribute, or literal, " + "not a complex expression" +) +DT100 = ( + "DT100 use 'import datetime' instead of " + "'from datetime import datetime'" +) +DT101 = ( + "DT101 use 'import datetime' instead of " + "'from datetime import date'" +) +DT102 = ( + "DT102 use 'import datetime' instead of " + "'from datetime import timedelta'" +) +DB100 = "DB100 do not commit debugger imports" +DB101 = "DB101 do not commit debugger calls" +NM100 = ( + "NM100 avoid get_ function prefix; choose a more precise verb " + "such as build_, calculate_, extract_, fetch_, look_up_, " + "retrieve_, format_, or transform_" +) + + +def run_plugin( + source: str, + *, + filename: str = "accounts/services.py", + structured_programming_files: list[str] | None = None, +) -> list[str]: + Plugin.parse_options( + SimpleNamespace( + structured_programming_files=structured_programming_files or [], + ) + ) + tree = ast.parse(source) + return [message for _, _, message, _ in Plugin(tree, filename).run()] + + +class PluginTest(unittest.TestCase): + def test_flags_multiple_returns_for_configured_file(self) -> None: + messages = run_plugin( + """ +def sample(value): + if value: + return value + return None +""", + structured_programming_files=["accounts/services.py"], + ) + + self.assertEqual(messages, [SP100_SAMPLE]) + + def test_flags_complex_return_expression_for_configured_file(self) -> None: + messages = run_plugin( + """ +def sample(value): + return value + 1 +""", + structured_programming_files=["accounts/services.py"], + ) + + self.assertEqual(messages, [SP101]) + + def test_allows_single_simple_return_for_configured_file(self) -> None: + messages = run_plugin( + """ +def sample(value): + result = value + 1 + return result +""", + structured_programming_files=["accounts/services.py"], + ) + + self.assertEqual(messages, []) + + def test_allows_literal_return_values_for_configured_file(self) -> None: + messages = run_plugin( + """ +def sample(): + return {"ok": [1, 2, (3, -4)]} +""", + structured_programming_files=["accounts/services.py"], + ) + + self.assertEqual(messages, []) + + def test_flags_container_return_with_nonliteral_values(self) -> None: + messages = run_plugin( + """ +def sample(value): + return [value] +""", + structured_programming_files=["accounts/services.py"], + ) + + self.assertEqual(messages, [SP101]) + + def test_skips_structured_programming_for_unconfigured_file(self) -> None: + messages = run_plugin( + """ +def sample(value): + if value: + return value + return value + 1 +""", + filename="accounts/forms.py", + structured_programming_files=["accounts/services.py"], + ) + + self.assertEqual(messages, []) + + def test_ignores_returns_inside_nested_functions(self) -> None: + messages = run_plugin( + """ +def outer(value): + def inner(): + return value + return value +""", + structured_programming_files=["accounts/services.py"], + ) + + self.assertEqual(messages, []) + + def test_flags_datetime_datetime_import(self) -> None: + messages = run_plugin("from datetime import datetime\n") + + self.assertEqual(messages, [DT100]) + + def test_flags_datetime_date_import(self) -> None: + messages = run_plugin("from datetime import date\n") + + self.assertEqual(messages, [DT101]) + + def test_flags_datetime_timedelta_import(self) -> None: + messages = run_plugin("from datetime import timedelta\n") + + self.assertEqual(messages, [DT102]) + + def test_allows_fully_qualified_datetime_import(self) -> None: + messages = run_plugin("import datetime\n") + + self.assertEqual(messages, []) + + def test_flags_multiple_datetime_imports(self) -> None: + messages = run_plugin( + "from datetime import date, datetime, timedelta\n" + ) + + self.assertEqual(messages, [DT101, DT100, DT102]) + + def test_flags_builtin_breakpoint(self) -> None: + messages = run_plugin( + """ +def handler(): + breakpoint() +""" + ) + + self.assertEqual(messages, [DB101]) + + def test_flags_debugger_module_import_and_call(self) -> None: + messages = run_plugin( + """ +import pdb +import ipdb as debugger + +def handler(): + pdb.set_trace() + debugger.set_trace() +""" + ) + + self.assertEqual(messages, [DB100, DB100, DB101, DB101]) + + def test_flags_imported_debugger_call_alias(self) -> None: + messages = run_plugin( + """ +from pdb import set_trace +from ipdb import set_trace as trace + +def handler(): + set_trace() + trace() +""" + ) + + self.assertEqual(messages, [DB100, DB100, DB101, DB101]) + + def test_flags_debugger_wildcard_imported_call(self) -> None: + messages = run_plugin( + """ +from pdb import * + +def handler(): + set_trace() +""" + ) + + self.assertEqual(messages, [DB100, DB101]) + + def test_flags_get_prefixed_function_name(self) -> None: + messages = run_plugin( + """ +def get_total(items): + return sum(items) +""" + ) + + self.assertEqual(messages, [NM100]) + + def test_flags_get_prefixed_async_function_name(self) -> None: + messages = run_plugin( + """ +async def get_story_details(story_id): + return story_id +""" + ) + + self.assertEqual(messages, [NM100]) + + def test_allows_non_get_prefixed_function_name(self) -> None: + messages = run_plugin( + """ +def fetch_story_details(story_id): + return story_id +""" + ) + + self.assertEqual(messages, []) + + +if __name__ == "__main__": + unittest.main()