├── tests ├── __init__.py ├── test_checker.py ├── fixture.py └── test_parser.py ├── flake8_length ├── __main__.py ├── __init__.py ├── _cli.py ├── _checker.py └── _parser.py ├── .gitignore ├── setup.cfg ├── LICENSE ├── pyproject.toml ├── Taskfile.yml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flake8_length/__main__.py: -------------------------------------------------------------------------------- 1 | # app 2 | from ._cli import entrypoint 3 | 4 | 5 | entrypoint() 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | /.*_cache/ 3 | /.task/ 4 | /venvs/ 5 | /dist/ 6 | /htmlcov/ 7 | /.coverage 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | ignore = C408 4 | exclude = 5 | setup.py 6 | venvs/ 7 | -------------------------------------------------------------------------------- /flake8_length/__init__.py: -------------------------------------------------------------------------------- 1 | """Flake8 plugin for a smart line length validation. 2 | """ 3 | 4 | # app 5 | from ._checker import Checker 6 | 7 | 8 | __version__ = '0.3.1' 9 | __all__ = ['Checker'] 10 | -------------------------------------------------------------------------------- /tests/test_checker.py: -------------------------------------------------------------------------------- 1 | from tokenize import tokenize 2 | from flake8_length import Checker 3 | 4 | 5 | def test_checker(): 6 | lines = [ 7 | b'# hello world', 8 | b'# ' + b'ab cd' * 40, 9 | ] 10 | tokens = tokenize(iter(lines).__next__) 11 | checker = Checker(None, tokens) 12 | res = list(checker.run()) 13 | assert res == [ 14 | (2, 90, 'LN002 doc/comment line is too long (202 > 90)', Checker) 15 | ] 16 | -------------------------------------------------------------------------------- /tests/fixture.py: -------------------------------------------------------------------------------- 1 | print(1 + 2 * 3) # L15 2 | print("hello") # L5 3 | print("hello world") # L5 4 | print("really-really-long-word") # L5 5 | print(""" # L9 6 | hello world # L15 7 | """) # L3 8 | print( # L5 9 | """ # L7 10 | hello world # L19 11 | """ # L7 12 | ) 13 | print( # L5 14 | """hello world # L18 15 | """ # L7 16 | ) 17 | 18 | # see also: https://github.com/life4/deal # L22 19 | # https://github.com/life4/deal # L12 20 | "SELECT * FROM table_with_very_long_name" # L25 21 | q = "SELECT * FROM table_with_very_long_name" # L29 22 | print(q) # L7 23 | print( # L5 24 | "SELECT * FROM table_with_very_long_name" # L29 25 | ) 26 | print( # L5 27 | "SELECT * FROM table_with_very_long_name", # L29 28 | ) 29 | print("see also https://github.com/life4/deal") # L5 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2021 Gram 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.isort] 6 | line_length = 90 7 | combine_as_imports = true 8 | balanced_wrapping = true 9 | lines_after_imports = 2 10 | skip = "venvs/" 11 | not_skip = "__init__.py" 12 | multi_line_output = 5 13 | include_trailing_comma = true 14 | 15 | import_heading_stdlib = "built-in" 16 | import_heading_thirdparty = "external" 17 | import_heading_firstparty = "project" 18 | import_heading_localfolder = "app" 19 | 20 | 21 | [tool.flit.metadata] 22 | module = "flake8_length" 23 | dist-name = "flake8-length" 24 | license = "MIT" 25 | author = "Gram" 26 | author-email = "gram@orsinium.dev" 27 | home-page = "https://github.com/orsinium-labs/flake8-length" 28 | description-file = "README.md" 29 | requires-python = ">=3.6" 30 | keywords = "flake8,plugins,introspection,linter" 31 | requires = ["flake8"] 32 | 33 | classifiers=[ 34 | "Development Status :: 5 - Production/Stable", 35 | "Environment :: Plugins", 36 | "Intended Audience :: Developers", 37 | "License :: OSI Approved :: MIT License", 38 | "Programming Language :: Python", 39 | "Topic :: Software Development", 40 | "Topic :: Software Development :: Quality Assurance", 41 | ] 42 | 43 | [tool.flit.metadata.requires-extra] 44 | dev = [ 45 | "isort", 46 | "mypy", 47 | "pytest", 48 | ] 49 | 50 | [tool.flit.entrypoints."flake8.extension"] 51 | LN00 = "flake8_length:Checker" 52 | -------------------------------------------------------------------------------- /flake8_length/_cli.py: -------------------------------------------------------------------------------- 1 | # built-in 2 | import sys 3 | import tokenize 4 | from argparse import ArgumentParser 5 | from pathlib import Path 6 | from typing import Sequence, TextIO 7 | 8 | # app 9 | from ._checker import Checker 10 | 11 | 12 | TEMPLATE = "{path}:{vl.row}: {vl.length} > {vl.limit}" 13 | 14 | 15 | def main(argv: Sequence[str], stream: TextIO = sys.stdout) -> int: 16 | parser = ArgumentParser() 17 | parser.add_argument('--max', type=int, default=90) 18 | parser.add_argument('--max-code', type=int, default=None) 19 | parser.add_argument('--max-doc', type=int, default=None) 20 | parser.add_argument('--show', action='store_true') 21 | parser.add_argument('paths', nargs='+', type=Path) 22 | args = parser.parse_args(argv) 23 | 24 | violations = 0 25 | for path in args.paths: 26 | with path.open('rb') as file_stream: 27 | tokens = list(tokenize.tokenize(file_stream.__next__)) 28 | checker = Checker(None, tokens) 29 | checker._code_limit = args.max_code or args.max 30 | checker._doc_limit = args.max_doc or args.max 31 | for vl in checker.get_violations(): 32 | violations += 1 33 | msg = TEMPLATE.format(path=path, vl=vl) 34 | print(msg, file=stream) 35 | if args.show: 36 | print(' ', vl.line.strip(), file=stream) 37 | return violations 38 | 39 | 40 | def entrypoint(): 41 | sys.exit(main(argv=sys.argv[1:])) 42 | -------------------------------------------------------------------------------- /flake8_length/_checker.py: -------------------------------------------------------------------------------- 1 | # built-in 2 | import tokenize 3 | from typing import Iterator, NamedTuple, Sequence, Tuple 4 | 5 | # app 6 | from ._parser import get_lines_info, Message 7 | 8 | 9 | Tokens = Sequence[tokenize.TokenInfo] 10 | TEMPLATE = '{v.code} {v.message.value} ({v.length} > {v.limit})' 11 | 12 | 13 | class Violation(NamedTuple): 14 | message: Message 15 | row: int 16 | length: int 17 | limit: int 18 | line: str 19 | 20 | @property 21 | def code(self) -> str: 22 | return self.message.name 23 | 24 | def as_tuple(self) -> Tuple[int, int, str]: 25 | msg = TEMPLATE.format(v=self) 26 | return self.row, self.limit, msg 27 | 28 | 29 | class Checker: 30 | name = 'flake8-length' 31 | version = '0.0.1' 32 | _tokens: Tokens 33 | _code_limit = 90 34 | _doc_limit = 90 35 | 36 | def __init__(self, tree, file_tokens: Tokens, filename=None) -> None: 37 | self._tokens = file_tokens 38 | 39 | @classmethod 40 | def parse_options(cls, options) -> None: 41 | cls._code_limit = options.max_line_length 42 | if options.max_doc_length: 43 | cls._doc_limit = options.max_doc_length 44 | else: 45 | cls._doc_limit = options.max_line_length 46 | 47 | def run(self) -> Iterator[tuple]: 48 | for violation in self.get_violations(): 49 | yield violation.as_tuple() + (type(self),) 50 | 51 | def get_violations(self) -> Iterator[Violation]: 52 | for token in self._tokens: 53 | for line_info in get_lines_info(token=token): 54 | if line_info.message == Message.LN002: 55 | limit = self._doc_limit 56 | else: 57 | limit = self._code_limit 58 | if line_info.length <= limit: 59 | continue 60 | yield Violation( 61 | message=line_info.message, 62 | row=line_info.row, 63 | length=line_info.length, 64 | limit=limit, 65 | line=line_info.line, 66 | ) 67 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev/ 2 | version: "3" 3 | 4 | vars: 5 | PYTHON_BIN: python3.7 6 | VENVS: ./venvs/ 7 | FLAKE8_ENV: "{{.VENVS}}flake8" 8 | MYPY_ENV: "{{.VENVS}}mypy" 9 | FLIT_ENV: "{{.VENVS}}flit" 10 | PYTEST_ENV: "{{.VENVS}}pytest" 11 | ISORT_ENV: "{{.VENVS}}isort" 12 | 13 | env: 14 | FLIT_ROOT_INSTALL: "1" 15 | 16 | tasks: 17 | venv:create: 18 | status: 19 | - "test -f {{.ENV}}/bin/activate" 20 | cmds: 21 | - "{{.PYTHON_BIN}} -m venv {{.ENV}}" 22 | - "{{.ENV}}/bin/python3 -m pip install -U pip setuptools wheel" 23 | flit:init: 24 | status: 25 | - "test -f {{.FLIT_ENV}}/bin/flit" 26 | deps: 27 | - task: venv:create 28 | vars: 29 | ENV: "{{.FLIT_ENV}}" 30 | cmds: 31 | - "{{.FLIT_ENV}}/bin/python3 -m pip install flit" 32 | flit:install: 33 | sources: 34 | - pyproject.toml 35 | - "{{.ENV}}/bin/activate" 36 | deps: 37 | - flit:init 38 | - task: venv:create 39 | vars: 40 | ENV: "{{.ENV}}" 41 | cmds: 42 | - > 43 | {{.FLIT_ENV}}/bin/flit install 44 | --python={{.ENV}}/bin/python3 45 | --deps=production 46 | --extras={{.EXTRA}} 47 | flit:build: 48 | deps: 49 | - flit:init 50 | cmds: 51 | - "{{.FLIT_ENV}}/bin/flit build" 52 | flit:upload: 53 | deps: 54 | - flit:build 55 | cmds: 56 | - "{{.FLIT_ENV}}/bin/flit publish" 57 | 58 | flake8:run: 59 | sources: 60 | - "**/*.py" 61 | deps: 62 | - task: flit:install 63 | vars: 64 | ENV: "{{.FLAKE8_ENV}}" 65 | EXTRA: dev 66 | cmds: 67 | - "{{.FLAKE8_ENV}}/bin/flake8 ." 68 | 69 | pytest:run: 70 | deps: 71 | - task: flit:install 72 | vars: 73 | ENV: "{{.PYTEST_ENV}}" 74 | EXTRA: dev 75 | cmds: 76 | - "{{.PYTEST_ENV}}/bin/pytest {{.CLI_ARGS}}" 77 | 78 | mypy:run: 79 | deps: 80 | - task: flit:install 81 | vars: 82 | ENV: "{{.MYPY_ENV}}" 83 | EXTRA: dev 84 | cmds: 85 | - "{{.MYPY_ENV}}/bin/mypy --ignore-missing-imports --allow-redefinition flake8_length/" 86 | 87 | isort:run: 88 | sources: 89 | - "**/*.py" 90 | deps: 91 | - task: flit:install 92 | vars: 93 | ENV: "{{.ISORT_ENV}}" 94 | EXTRA: dev 95 | cmds: 96 | - "{{.ISORT_ENV}}/bin/isort ." 97 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | # built-in 2 | import re 3 | import tokenize 4 | from pathlib import Path 5 | 6 | # external 7 | import pytest 8 | from typing import Union, List 9 | # project 10 | from flake8_length._parser import TRUNCATE_TO, get_lines_info 11 | 12 | 13 | def to_tokens(lines: List[str]): 14 | readline = (line.encode() for line in lines).__next__ 15 | return list(tokenize.tokenize(readline)) 16 | 17 | 18 | @pytest.mark.parametrize('given, expected', [ 19 | # regular code 20 | ('123', [3]), 21 | ('12367', [5]), 22 | 23 | # comments 24 | ('# hello', [7]), 25 | ('# hello world', [13]), 26 | ('# https://github.com/life4/deal', [TRUNCATE_TO + 2]), 27 | ('# see also: https://github.com/life4/deal', [TRUNCATE_TO + 12]), 28 | 29 | # strings 30 | ('"SELECT * FROM table"', [21]), 31 | ('"SELECT * FROM table_with_very_long_name"', [TRUNCATE_TO + 15]), 32 | 33 | # ignored endline markers 34 | ('123', [3]), 35 | ('123,', [3]), 36 | ('[123]', [4]), 37 | ('(123)', [4]), 38 | ('{123}', [4]), 39 | ('(123,)', [4]), 40 | ('(123,);', [4]), 41 | ('if 0:0', [2, 4, 6]), 42 | 43 | # multiline strings 44 | ( 45 | "'''\n hello world\n'''", 46 | [3, 13, 3], 47 | ), 48 | ( 49 | "'''\n https://github.com/life4/deal\n'''", 50 | [3, TRUNCATE_TO + 2, 3], 51 | ), 52 | ( 53 | ("print('''\n", ' 1' * 39 + '\n', "''')\n"), 54 | # 5, 6, 9 = three tokens in print(''' 55 | # 78 = length of ' 1'*39 56 | # 3, 4 = two tokens in ''') 57 | [5, 9, 78, 3] 58 | ), 59 | ]) 60 | def test_get_lines_info(given: Union[str, List[str]], expected: int): 61 | if isinstance(given, str): 62 | given = [given] 63 | 64 | tokens = to_tokens(given) 65 | print(*tokens, sep='\n') 66 | infos: list = [] 67 | for token in tokens: 68 | infos.extend(get_lines_info(token)) 69 | assert [info.length for info in infos] == expected 70 | 71 | 72 | @pytest.mark.parametrize('given', [ 73 | '#!/usr/bin/env python3', 74 | '# noqa: D12', 75 | '# pragma: no cover', 76 | '# E: Incompatible types in assignment', 77 | '"hello"', 78 | ]) 79 | def test_skip(given: str): 80 | tokens = to_tokens([given]) 81 | infos = list(get_lines_info(tokens[1])) 82 | assert len(infos) == 0 83 | 84 | 85 | def test_fixture(): 86 | path = Path(__file__).parent / 'fixture.py' 87 | lines = path.read_text().splitlines() 88 | 89 | rex = re.compile(r' # L(\d+)') 90 | cleaned = [] 91 | expected = {} 92 | for i, line in enumerate(lines, start=1): 93 | match = rex.search(line) 94 | if match: 95 | line = line.replace(match.group(0), ' ') 96 | expected[i] = int(match.group(1)) 97 | cleaned.append(line.rstrip(' ') + '\n') 98 | assert len(expected) > 5 99 | 100 | actual = {} 101 | for token in to_tokens(cleaned): 102 | for info in get_lines_info(token): 103 | actual[info.row] = max( 104 | info.length, 105 | actual.get(info.row, 0), 106 | ) 107 | assert actual == expected 108 | -------------------------------------------------------------------------------- /flake8_length/_parser.py: -------------------------------------------------------------------------------- 1 | # built-in 2 | import tokenize 3 | from enum import Enum 4 | from typing import Iterator, NamedTuple 5 | 6 | 7 | class Message(str, Enum): 8 | LN001 = 'code line is too long' 9 | LN002 = 'doc/comment line is too long' 10 | 11 | 12 | SKIP_PREFIXES = ('noqa', 'n:', 'w:', 'e:', 'r:', 'pragma:') 13 | SQL_PREFIXES = ('SELECT ', 'UPDATE', 'DELETE ') 14 | TRUNCATE_TO = 10 15 | EXCLUDED_TOKENS = frozenset({ 16 | tokenize.NEWLINE, 17 | tokenize.NL, 18 | tokenize.ENCODING, 19 | tokenize.ENDMARKER, 20 | tokenize.ERRORTOKEN, 21 | tokenize.COMMA, 22 | tokenize.LBRACE, 23 | tokenize.RBRACE, 24 | tokenize.COLON, 25 | }) 26 | EXCLUDED_PAIRS = frozenset({ 27 | (tokenize.OP, '('), 28 | (tokenize.OP, ')'), 29 | (tokenize.OP, '['), 30 | (tokenize.OP, ']'), 31 | (tokenize.OP, '{'), 32 | (tokenize.OP, '}'), 33 | (tokenize.OP, ','), 34 | (tokenize.OP, ';'), 35 | (tokenize.OP, ':'), 36 | }) 37 | 38 | 39 | class LineInfo(NamedTuple): 40 | message: Message 41 | row: int 42 | length: int 43 | line: str 44 | 45 | 46 | def get_line_length(line: str) -> int: 47 | chunks = line.split() 48 | if not chunks: 49 | return len(line) 50 | last_chunk_size = len(chunks[-1]) 51 | if last_chunk_size < TRUNCATE_TO: 52 | return len(line) 53 | return len(line) - last_chunk_size + TRUNCATE_TO 54 | 55 | 56 | def get_lines_info(token: tokenize.TokenInfo) -> Iterator[LineInfo]: 57 | if token.type in EXCLUDED_TOKENS: 58 | return 59 | if (token.type, token.string) in EXCLUDED_PAIRS: 60 | return 61 | 62 | if token.type not in {tokenize.COMMENT, tokenize.STRING}: 63 | if token.end[1] > token.start[1]: 64 | yield LineInfo( 65 | message=Message.LN001, 66 | row=token.end[0], 67 | length=token.end[1], 68 | line=token.line, 69 | ) 70 | else: 71 | yield LineInfo( 72 | message=Message.LN001, 73 | row=token.start[0], 74 | length=token.start[1], 75 | line=token.line, 76 | ) 77 | return 78 | 79 | if token.type == tokenize.COMMENT: 80 | # skip shebang 81 | if token.string.startswith('#!'): 82 | return 83 | # skip noqa, pragma, and other special tokens 84 | if token.string.lower()[1:].lstrip().startswith(SKIP_PREFIXES): 85 | return 86 | 87 | # skip single-line strings 88 | if token.type == tokenize.STRING and '\n' not in token.string: 89 | # do not skip SQL queries 90 | if token.string.lstrip('brfu').lstrip('"\'').startswith(SQL_PREFIXES): 91 | yield LineInfo( 92 | message=Message.LN001, 93 | row=token.start[0], 94 | length=token.start[1] + get_line_length(token.string), 95 | line=token.line, 96 | ) 97 | return 98 | 99 | # analyze every line of comments and multiline strings 100 | lines = token.string.splitlines() 101 | for offset, line in enumerate(lines): 102 | line_length = get_line_length(line) 103 | if offset == 0: 104 | line_length += token.start[1] 105 | yield LineInfo( 106 | message=Message.LN002, 107 | row=token.start[0] + offset, 108 | length=line_length, 109 | line=line, 110 | ) 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flake8-length 2 | 3 | [Flake8](https://gitlab.com/pycqa/flake8) plugin for a smart line length validation. 4 | 5 | [pycodestyle](https://github.com/PyCQA/pycodestyle) linter (used in Flake8 under the hood by default) already has `E501` and `W505` rules to validate the line length. flake8-length provides an alternative check that is smarter and more forgiving. 6 | 7 | What is allowed: 8 | 9 | + Long string literals. 10 | + Long URLs in strings and comments. 11 | + When the last word in a text doesn't fit a bit. 12 | 13 | ## Motivation 14 | 15 | From [linux code style](https://github.com/torvalds/linux/blob/master/Documentation/process/coding-style.rst#2-breaking-long-lines-and-strings): 16 | 17 | > Statements longer than 80 columns will be broken into sensible chunks, unless exceeding 80 columns significantly increases readability and does not hide information. <...> However, never break user-visible strings such as printk messages, because that breaks the ability to grep for them. 18 | 19 | I see a lot of Python code that does some awful breaks to fit long text messages into the project's line limit just because. However, it creates a lot of difficulties: 20 | 21 | 1. Difficult to grep. 22 | 1. Easy to miss a space on the string breaks. 23 | 1. It doesn't make code more readable at all, even decreases readability. In most cases, I don't care if the ending of an error message goes outside of my screen. 24 | 25 | Some modern languages even don't have this limitation: 26 | 27 | > Go has no line length limit. Don't worry about overflowing a punched card. 28 | 29 | However, it makes sense to keep some limit to guide developers and keep the alignment reasonable. 30 | 31 | [Uncle Bob analyzed line length in some popular Java project](https://youtu.be/2a_ytyt9sf8?t=2792). The conclusion is it is usually about 45 on average, more than 97 is too much and exceptional. 32 | 33 | [Raymond Hettinger advises to keep it 90ish](https://youtu.be/wf-BqAjZb8M?t=260). The limit should be about 90 but with reasonable exceptions for when breaking the line would negatively affect the readability. 34 | 35 | [Kevlin Henney says even 80 is too generous](https://youtu.be/ZsHMHukIlJY?t=716). People read the code following one up-down flow, and breaking the flow with long lines makes the code harder to read. 36 | 37 | If you ever had to break a text message to fit in the limit, you know why the plugin exists. 38 | 39 | If you're about having as strict limits as possible, flake8-length is on your side. It's better to set 90 chars limit with a few reasonable exceptions rather than have 120 or more chars limit for everything. 40 | 41 | ## Installation 42 | 43 | Install: 44 | 45 | ```bash 46 | python3 -m pip install --user flake8-length 47 | ``` 48 | 49 | And check if the plugin is detected by flake8: 50 | 51 | ```bash 52 | flake8 --version 53 | ``` 54 | 55 | If it doesn't, flake8-length was installed in another python interpreter rather than flake8. You can find the right one: 56 | 57 | ```bash 58 | head -1 $(which flake8) 59 | ``` 60 | 61 | ## Usage 62 | 63 | + If you're installed flake8-length and flake8 in the same environment, when you run flake8 it will run the plugin. Just give it a try. 64 | + pycodestyle has a few hard limits on lime length (`E501` and `W505`), so these checks should be disabled to avoid conflicts with flake8-length. 65 | + The default soft limit is set using [max-line-length](https://flake8.pycqa.org/en/latest/user/options.html#cmdoption-flake8-max-line-length) option. It is 79 by default. 66 | 67 | Configuration example (`setup.cfg`): 68 | 69 | ```ini 70 | [flake8] 71 | extend-ignore = 72 | E501, 73 | W505 74 | max-line-length = 90 75 | ``` 76 | 77 | What the limit you should use? I'd say, as small as possible. Try to start with the default one (79) and if you feel it's not enough, extend it to 90. More is too generous. 78 | --------------------------------------------------------------------------------