Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

config options handle spaces and newlines #93

Merged
merged 4 commits into from
Dec 31, 2022
Merged
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
87 changes: 31 additions & 56 deletions flake8_trio.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@

from __future__ import annotations

import argparse
import ast
import keyword
import tokenize
from argparse import Namespace
from collections.abc import Iterable, Sequence
from argparse import ArgumentTypeError, Namespace
from collections.abc import Iterable
from fnmatch import fnmatch
from typing import Any, NamedTuple, Union, cast

Expand Down Expand Up @@ -1427,46 +1426,33 @@ def visit_Call(self, node: ast.Call):
self.error(node, key, blocking_calls[key])


class ListOfIdentifiers(argparse.Action):
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: Sequence[str] | None,
option_string: str | None = None,
):
assert values is not None
assert option_string is not None
for value in values:
if keyword.iskeyword(value) or not value.isidentifier():
raise argparse.ArgumentError(
self, f"{value!r} is not a valid method identifier"
)
setattr(namespace, self.dest, values)


class ParseDict(argparse.Action):
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: Sequence[str] | None,
option_string: str | None = None,
):
res: dict[str, str] = {}
splitter = "->" # avoid ":" because it's part of .ini file syntax
assert values is not None
for value in values:
split_values = list(map(str.strip, value.split(splitter)))
if len(split_values) != 2:
raise argparse.ArgumentError(
self,
f"Invalid number ({len(split_values)-1}) of splitter "
f"tokens {splitter!r} in {value!r}",
)
res[split_values[0]] = split_values[1]

setattr(namespace, self.dest, res)
# flake8 ignores type parameters if using comma_separated_list
# so we need to reimplement that ourselves if we want to use "type"
# to check values
def parse_trio114_identifiers(raw_value: str) -> list[str]:
values = [s.strip() for s in raw_value.split(",") if s.strip()]
for value in values:
if keyword.iskeyword(value) or not value.isidentifier():
raise ArgumentTypeError(f"{value!r} is not a valid method identifier")
return values


def parse_trio200_dict(raw_value: str) -> dict[str, str]:
res: dict[str, str] = {}
splitter = "->" # avoid ":" because it's part of .ini file syntax
values = [s.strip() for s in raw_value.split(",") if s.strip()]

for value in values:
split_values = list(map(str.strip, value.split(splitter)))
if len(split_values) != 2:
# argparse will eat this error message and spit out it's own
# if we raise it as ValueError
raise ArgumentTypeError(
f"Invalid number ({len(split_values)-1}) of splitter "
f"tokens {splitter!r} in {value!r}",
)
res[split_values[0]] = split_values[1]
return res


class Plugin:
Expand All @@ -1484,15 +1470,6 @@ def from_filename(cls, filename: str) -> Plugin:
return cls(ast.parse(source))

def run(self) -> Iterable[Error]:
# temporary workaround, since the Action does not seem to be called properly
# by flake8 when parsing from config
if isinstance(self.options.trio200_blocking_calls, list):
ParseDict([""], dest="trio200_blocking_calls")(
None, # type: ignore
self.options,
self.options.trio200_blocking_calls, # type: ignore
None,
)
yield from Flake8TrioRunner.run(self._tree, self.options)

@staticmethod
Expand All @@ -1513,11 +1490,10 @@ def add_options(option_manager: OptionManager):
)
option_manager.add_option(
"--startable-in-context-manager",
type=parse_trio114_identifiers,
default="",
parse_from_config=True,
required=False,
comma_separated_list=True,
action=ListOfIdentifiers,
help=(
"Comma-separated list of method calls to additionally enable TRIO113 "
"warnings for. Will also check for the pattern inside function calls. "
Expand All @@ -1529,11 +1505,10 @@ def add_options(option_manager: OptionManager):
)
option_manager.add_option(
"--trio200-blocking-calls",
type=parse_trio200_dict,
default={},
parse_from_config=True,
required=False,
comma_separated_list=True,
action=ParseDict,
help=(
"Comma-separated list of key:value pairs, where key is a [dotted] "
"function that if found inside an async function will raise TRIO200, "
Expand Down
52 changes: 42 additions & 10 deletions tests/test_flake8_trio.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ def test_eval(test: str, path: str):
lines = file.readlines()

for lineno, line in enumerate(lines, start=1):
# interpret '\n' in comments as actual newlines
line = line.replace("\\n", "\n")

line = line.strip()

# add other error codes to check if #INCLUDE is specified
Expand All @@ -90,9 +93,7 @@ def test_eval(test: str, path: str):

# add command-line args if specified with #ARGS
elif reg_match := re.search(r"(?<=ARGS).*", line):
for arg in reg_match.group().split(" "):
if arg.strip():
parsed_args.append(arg.strip())
parsed_args.append(reg_match.group().strip())
Comment on lines -93 to +96
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed this to handle spaces in a command line arg - this means that the name ARGS is misleading but that is renamed in #91 (rip me bundling changes again).
This leads to some minor merge conflicts, but I'll likely rebase #91 on top of this one to fix those.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good - I'll next have internet around Jan 9th, so don't panic if review is a bit delayed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gtk, I'll rebase and fix merge conflicts in it. May also split it up if I start messing with stuff in other PR's that might lead to conflicts.


# skip commented out lines
if not line or line[0] == "#":
Expand Down Expand Up @@ -444,16 +445,17 @@ def test_200_options(capsys: pytest.CaptureFixture[str]):
om.parse_args(args=[f"--trio200-blocking-calls={arg}"])
)
out, err = capsys.readouterr()
assert not out
assert not out, out
assert all(word in err for word in (str(i), arg, "->"))


def test_from_config_file(tmp_path: Path):
def _test_trio200_from_config_common(tmp_path: Path) -> str:
tmp_path.joinpath(".flake8").write_text(
"""
[flake8]
trio200-blocking-calls =
sync_fns.*->the_async_equivalent,
other -> async,
sync_fns.* -> the_async_equivalent,
select = TRIO200
"""
)
Expand All @@ -465,12 +467,42 @@ async def foo():
sync_fns.takes_a_long_time()
"""
)
return (
"./example.py:5:5: TRIO200 User-configured blocking sync call sync_fns.* "
"in async function, consider replacing with the_async_equivalent.\n"
)


def test_200_from_config_flake8_internals(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
):
# abuse flake8 internals to avoid having to use subprocess
# which breaks breakpoints and hinders debugging
# TODO: fixture (?) to change working directory

err_msg = _test_trio200_from_config_common(tmp_path)
# replace ./ with tmp_path/
err_msg = str(tmp_path) + err_msg[1:]

from flake8.main.cli import main

main(
argv=[
str(tmp_path / "example.py"),
"--append-config",
str(tmp_path / ".flake8"),
]
)
out, err = capsys.readouterr()
assert not err
assert err_msg == out
Comment on lines +476 to +498
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

almost funny after the fact how massively the subprocess call broke my debugging workflow and made finding out why tests broke take ages (a trailing comma in the list of config options).

I broke this out as a separate test in case flake8 internals break, but feels like there should be a cleaner solution that doesn't rely on any internal structure at all and just emulates calling it with python -m. Something like runpy.run_module but with also setting sys.argv and working dir. I didn't find anything that didn't require writing a bunch of extra code, but if you have anything I'd love to hear of it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No specific advice sorry, but I appreciate having this test broken out!



def test_200_from_config_subprocess(tmp_path: Path):
err_msg = _test_trio200_from_config_common(tmp_path)
res = subprocess.run(["flake8"], cwd=tmp_path, capture_output=True)
assert not res.stderr
assert res.stdout == (
b"./example.py:5:5: TRIO200 User-configured blocking sync call sync_fns.* "
b"in async function, consider replacing with the_async_equivalent.\n"
)
assert res.stdout == err_msg.encode("ascii")


@pytest.mark.fuzz
Expand Down
4 changes: 3 additions & 1 deletion tests/trio200.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# specify command-line arguments to be used when testing this file.
# ARGS --trio200-blocking-calls=bar->BAR,bee->SHOULD_NOT_BE_PRINTED,bonnet->SHOULD_NOT_BE_PRINTED,bee.bonnet->BEEBONNET,*.postwild->POSTWILD,prewild.*->PREWILD,*.*.*->TRIPLEDOT
# Test spaces in options, and trailing comma
# Cannot test newlines, since argparse splits on those if passed on the CLI
# ARGS --trio200-blocking-calls=bar -> BAR, bee-> SHOULD_NOT_BE_PRINTED,bonnet ->SHOULD_NOT_BE_PRINTED,bee.bonnet->BEEBONNET,*.postwild->POSTWILD,prewild.*->PREWILD,*.*.*->TRIPLEDOT,

# don't error in sync function
def foo():
Expand Down
18 changes: 18 additions & 0 deletions typings/flake8/main/cli.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
This type stub file was generated by pyright.
"""

from collections.abc import Sequence

"""Command-line implementation of flake8."""

def main(argv: Sequence[str] | None = ...) -> int:
"""Execute the main bit of the application.

This handles the creation of an instance of :class:`Application`, runs it,
and then exits the application.

:param argv:
The arguments to be passed to the application for parsing.
"""
...