├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── flake8_multiline_containers.py ├── requirements ├── lint.txt └── tests.txt ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── dummy │ ├── callable │ │ ├── class_def.py │ │ ├── function_call.py │ │ └── function_def.py │ ├── comments.py │ ├── conditional_block.py │ ├── conditional_expression.py │ ├── dict │ │ ├── dict.py │ │ ├── dict_assignment.py │ │ └── nested_dict.py │ ├── docstrings.py │ ├── equality.py │ ├── list │ │ ├── list.py │ │ ├── list_comprehension.py │ │ └── nested_list.py │ ├── multiple_opening.py │ ├── no_newline.py │ ├── pound_sign_in_string.py │ ├── set │ │ ├── nested_set.py │ │ └── set.py │ ├── string │ │ ├── multiline.py │ │ ├── raw_strings.py │ │ ├── string.py │ │ └── string_brackets.py │ └── tuple │ │ ├── nested_tuple.py │ │ └── tuple.py ├── test_checks.py ├── test_class_definition.py ├── test_comments_ignore.py ├── test_conditional_block.py ├── test_conditional_expression.py ├── test_dict_assignment.py ├── test_docstring_ignore.py ├── test_equality.py ├── test_function_calls.py ├── test_function_definition.py ├── test_get_left_pad.py ├── test_list_comprehension.py ├── test_multiline.py ├── test_multiple_opening.py ├── test_nested.py ├── test_no_newline.py ├── test_pound_sign_in_string.py ├── test_raw_strings.py └── test_string_ignore.py └── tox.ini /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | # Controls when the action will run. 4 | on: 5 | # Triggers the workflow on push or pull request events but only for the master branch 6 | push: 7 | branches: [ master ] 8 | pull_request: 9 | branches: [ master ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | 17 | # This workflow contains a single job called "build" 18 | build: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | matrix: 24 | include: 25 | - PY_VER: py38 26 | python-version: 3.8 27 | - PY_VER: py39 28 | python-version: 3.9 29 | - PY_VER: py310 30 | python-version: '3.10' 31 | - PY_VER: py311 32 | python-version: '3.11' 33 | - PY_VER: py312 34 | python-version: '3.12' 35 | 36 | # Steps represent a sequence of tasks that will be executed as part of the job 37 | steps: 38 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 39 | - uses: actions/checkout@v4 40 | 41 | - uses: actions/setup-python@v5 42 | with: 43 | python-version: ${{matrix.python-version}} 44 | 45 | - name: Install test dependencies 46 | run: pip install tox coveralls 47 | 48 | # Runs a single command using the runners shell 49 | - name: Run tests 50 | run: | 51 | tox -e flake8; 52 | tox -e ${{matrix.PY_VER}}; 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [0.0.19] - 2022-04-16 5 | 6 | ## Fixed 7 | - False positive on multiline conditional expressions 8 | 9 | ## [0.0.18] - 2021-03-25 10 | 11 | ## Fixed 12 | - Handle scenario where function call is inside nested container 13 | 14 | ## Changed 15 | 16 | - Only scan each line once for each container type instead of twice (once for opening, once for closing) 17 | 18 | ## [0.0.17] - 2020-10-26 19 | 20 | ## Fixed 21 | 22 | - Issue introduced in 0.0.16 where variable assignment was incorrectly parsed 23 | 24 | ## [0.0.16] - 2020-10-23 25 | 26 | ## Fixed 27 | 28 | - Ensure comments on lines where a multiline container starts are ignored 29 | 30 | ## [0.0.15] - 2020-08-17 31 | 32 | ## Fixed 33 | 34 | - Ensure lines that are only comments are ignored completely 35 | - Handle cases where containers have function calls with keyword arguments inside them 36 | 37 | ## [0.0.14] - 2020-08-13 38 | 39 | ### Fixed 40 | 41 | - Handle cases where lists have nested lists with function calls 42 | 43 | ## [0.0.13] - 2020-08-13 44 | 45 | ### Fixed 46 | 47 | - No false positive when a list comprehension has an equality comparison 48 | 49 | ## [0.0.12] - 2020-08-13 50 | 51 | ### Fixed 52 | 53 | - No false positive when assigning a multiline container to a dict key 54 | 55 | ## [0.0.11] - 2020-06-10 56 | 57 | ### Changed 58 | 59 | - Small speed improvement by removing left padding calculation from loop 60 | 61 | ### Fixed 62 | 63 | - No false positive on closing parenthesis-wrapped expression inside a call 64 | - No false positive on closing index check inside blocks 65 | 66 | ## [0.0.10] - 2020-03-11 67 | 68 | ### Fixed 69 | 70 | - Pound sign in a string shouldn't be detected as start of comment block 71 | 72 | ## [0.0.9] - 2020-03-09 73 | 74 | ### Fixed 75 | - Only check for function calls when checking lunula brackets 76 | 77 | ## [0.0.8] - 2020-03-06 78 | 79 | ### Fixed 80 | - Handle nested function calls 81 | - Ignore conditional blocks 82 | - Ignore function calls with strange whitespace 83 | 84 | ## [0.0.7] - 2019-09-21 85 | 86 | ### Added 87 | - Tuples are now also validated as part of 101 and 102 checks 88 | 89 | ### Fixed 90 | - False positive on type annotation and regex 91 | 92 | ## [0.0.6] - 2019-07-29 93 | 94 | ### Fixed 95 | - Handle situation where end character is at EOF 96 | - Display correct error if line has multiple opening characters without any closing characters 97 | 98 | ## [0.0.5] - 2019-07-15 99 | 100 | ### Fixed 101 | - Handle situations where a line has multiple closing characters 102 | 103 | ## [0.0.4] - 2019-06-07 104 | 105 | ### Fixed 106 | - Escaped characters are ignored 107 | 108 | ## [0.0.3] - 2019-06-05 109 | 110 | ### Fixed 111 | - Strings with only closing characters are ignored 112 | 113 | ## [0.0.2] - 2019-05-31 114 | 115 | ### Fixed 116 | - Handle situations where there are uneven numbers of opening and closing characters on the same line 117 | - Ensure opening and closing characters inside strings are ignored 118 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Joshua Fehler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | flake8-multiline-containers 3 | =========================== 4 | 5 | .. image:: https://img.shields.io/pypi/v/flake8-multiline-containers.svg 6 | :target: https://pypi.org/project/flake8-multiline-containers 7 | :alt: PyPI 8 | 9 | .. image:: https://img.shields.io/pypi/pyversions/flake8-multiline-containers.svg 10 | :alt: PyPI - Python Version 11 | :target: https://github.com/jsfehler/flake8-multiline-containers 12 | 13 | .. image:: https://img.shields.io/github/license/jsfehler/flake8-multiline-containers.svg 14 | :alt: GitHub 15 | :target: https://github.com/jsfehler/flake8-multiline-containers/blob/master/LICENSE 16 | 17 | .. image:: https://pyup.io/repos/github/jsfehler/flake8-multiline-containers/shield.svg 18 | :target: https://pyup.io/repos/github/jsfehler/flake8-multiline-containers 19 | :alt: Updates 20 | 21 | .. image:: https://github.com/jsfehler/flake8-multiline-containers/actions/workflows/main.yml/badge.svg 22 | :target: https://github.com/jsfehler/flake8-multiline-containers/actions/workflows/main.yml 23 | :alt: Build status 24 | 25 | A `Flake8 `_ plugin to ensure a consistent format for multiline containers. 26 | 27 | Installation 28 | ------------ 29 | 30 | Install from ``pip`` with: 31 | 32 | .. code-block:: sh 33 | 34 | pip install flake8-multiline-containers 35 | 36 | Rules 37 | ----- 38 | 39 | ===== ==== 40 | Code Rule 41 | ===== ==== 42 | JS101 Multi-line container not broken after opening character 43 | JS102 Multi-line container does not close on same column as opening 44 | ===== ==== 45 | 46 | Examples 47 | -------- 48 | 49 | .. code-block:: python 50 | 51 | # Right: Opens and closes on same line 52 | foo = {'a': 'hello', 'b': 'world'} 53 | 54 | 55 | # Right: Line break after parenthesis, closes on same column as opening 56 | foo = { 57 | 'a': 'hello', 58 | 'b': 'world', 59 | } 60 | 61 | # Right: Line break after parenthesis, closes on same column as opening 62 | foo = [ 63 | 'hello', 'world', 64 | ] 65 | 66 | 67 | # Wrong: JS101 68 | foo = {'a': 'hello', 69 | 'b': 'world', 70 | } 71 | 72 | 73 | # Wrong: JS101, JS102 74 | foo = {'a': 'hello', 75 | 'b': 'world'} 76 | 77 | 78 | # Wrong: JS101, JS102 79 | foo = {'hello', 80 | 'world' 81 | } 82 | -------------------------------------------------------------------------------- /flake8_multiline_containers.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import re 3 | from typing import List, Tuple 4 | 5 | import attr 6 | 7 | 8 | # Matches anything inside a string. 9 | STRING_REGEX = re.compile( 10 | r'"([^"\\]*(\\.[^"\\]*)*)"|\'([^\'\\]*(\\.[^\'\\]*)*)\'', 11 | ) 12 | 13 | MULTILINE_STRING_REGEX = re.compile( 14 | r'"""|\'\'\'', 15 | ) 16 | 17 | # Matches anything that looks like a: 18 | # function call, function definition, or class definition with inheritance 19 | # Actual tuples should be ignored 20 | FUNCTION_CALL_REGEX = r'\w+\s*[(]' 21 | 22 | # Matches anything that looks like a conditional block 23 | CONDITIONAL_BLOCK_REGEX = re.compile( 24 | r'if\s*[(]|elif\s*[(]|or\s*[(]*[(]|and\s*[(]|not\s*[(]') 25 | 26 | # Matches anything that looks like a variable assignment, ie: foo = [1, 2, 3] 27 | # Ignores equality comparison, ie: foo == [1, 2, 3] 28 | ASSIGNMENT_REGEX = re.compile(r'(^\s*[A-Za-z_]\w+|\])\s*=\s*([^=]*$)') 29 | # When a line contains only comments, this string is returned instead 30 | ONLY_COMMENTS_STRING = '__only_comments__' 31 | 32 | # When a line contains a function call, replace it with this string 33 | FUNCTION_STRING = '__func__' 34 | 35 | 36 | class ErrorCodes(enum.Enum): 37 | JS101 = "Multi-line container not broken after opening character" 38 | JS102 = "Multi-line container does not close on same column as opening" 39 | 40 | 41 | def _error( 42 | line_number: int, 43 | column: int, 44 | error_code: ErrorCodes, 45 | ) -> Tuple[int, int, str, None]: 46 | """Format error report such that it's usable by flake8's reporting.""" 47 | return (line_number, column, f'{error_code.name} {error_code.value}', None) 48 | 49 | 50 | def get_left_pad(line: str) -> int: 51 | """Get the amount of whitespace before the first character in a line.""" 52 | return len(line) - len(line.lstrip(' ')) 53 | 54 | 55 | @attr.s(hash=False) 56 | class MultilineContainers: 57 | """Ensure the consistency of multiline dict and list style.""" 58 | 59 | name = 'flake8_multiline_containers' 60 | version = '0.0.19' 61 | 62 | tree = attr.ib(default=None) 63 | filename = attr.ib(default="(none)") 64 | lines = attr.ib(default=None) 65 | 66 | errors = attr.ib(factory=list, type=List[Tuple[int, int, str, None]]) 67 | 68 | # The column where the last line that opened started. 69 | last_starts_at = attr.ib(factory=list, type=List[int]) 70 | 71 | # The number of functions deep we currently are in. 72 | function_depth = attr.ib(default=0) 73 | 74 | inside_conditional_block = attr.ib(default=0) 75 | 76 | inside_multiline_string = False 77 | 78 | def _number_of_matches_in_line( 79 | self, 80 | open_character: str, 81 | close_character: str, 82 | line: str) -> Tuple[int, int, str]: 83 | """Scan line and check how many times each character appears. 84 | 85 | Characters inside strings are ignored. 86 | 87 | Arguments: 88 | open_character: Opening character for the container. 89 | close_character: Closing character for the container. 90 | line: The line to check. 91 | 92 | Returns: 93 | tuple 94 | 95 | """ 96 | # Whole line is a comment, so ignore it 97 | if re.search(r'^\s*#', line): 98 | return 0, 0, ONLY_COMMENTS_STRING 99 | 100 | # Multiline strings should be ignored. 101 | # If a line has only 1 triple quote, assume it's multiline 102 | matches = MULTILINE_STRING_REGEX.findall(line) 103 | if len(matches) == 1: 104 | self.inside_multiline_string = not self.inside_multiline_string 105 | 106 | if self.inside_multiline_string: 107 | return 0, 0, line 108 | 109 | # Remove strings from the line. Strings are always ignored. 110 | temp_line = STRING_REGEX.sub('', line) 111 | 112 | # Find comments and make sure they're ignored 113 | # Remove comments from temp_line 114 | last_line = temp_line 115 | for match in re.finditer(r'#.*', temp_line): 116 | i = match.group(0) 117 | if i is not None: 118 | last_line = last_line.replace(i, '') 119 | 120 | line = last_line.replace(' ', '') 121 | 122 | # Only scan the part of the line after assignment 123 | # ie: in we only want 124 | matched = ASSIGNMENT_REGEX.search(line) 125 | if matched: 126 | line = matched.group(2) 127 | 128 | open_times = line.count(open_character) 129 | close_times = line.count(close_character) 130 | 131 | return open_times, close_times, line 132 | 133 | def _check_opening( 134 | self, 135 | open_character: str, 136 | close_character: str, 137 | matches: Tuple[int, int, str], 138 | line_number: int, 139 | line: str, 140 | error_code: ErrorCodes, 141 | ): 142 | """Implementation for JS101. 143 | 144 | If open_character and close_character don't appear the same number of 145 | times on the line, then open_character should be last character in the 146 | line. 147 | 148 | Arguments: 149 | open_character: Opening character for the container. 150 | close_character: Closing character for the container. 151 | matches: Numers of open and closing characters found. 152 | line_number: The number of the line. Reported back to flake8. 153 | line: The line to check. 154 | error_code: The error to report if the validation fails. 155 | 156 | """ 157 | open_times, close_times, parsed_line = matches 158 | if parsed_line == ONLY_COMMENTS_STRING: 159 | return 160 | 161 | # Tuples, functions, and classes all use lunula brackets. 162 | # Ensure only tuples are caught by JS101. 163 | if open_character == '(' and open_times != close_times: 164 | for _ in re.finditer(FUNCTION_CALL_REGEX, line): 165 | # When inside a function with multiline arguments, 166 | # ignore the opening bracket 167 | self.function_depth += 1 168 | 169 | # If detected a conditional block, ignore it 170 | if CONDITIONAL_BLOCK_REGEX.search(line): 171 | self.inside_conditional_block += 1 172 | 173 | if open_times != close_times: 174 | open_times -= self.inside_conditional_block 175 | open_times -= self.function_depth 176 | 177 | # Multiline container detected 178 | if open_times >= 1 and open_times != close_times: 179 | self.last_starts_at.extend( 180 | [get_left_pad(line)] * open_times, 181 | ) 182 | 183 | # Multiple opening characters with no closing 184 | # There can only be one hanging opening 185 | if (open_times - close_times) > 1: 186 | e = _error(line_number + 1, 0, error_code) 187 | self.errors.append(e) 188 | 189 | # One opening character, but content after it. 190 | else: 191 | # Last character on a line is newline (\n). Get second to last. 192 | last_index = len(parsed_line) - 2 193 | if parsed_line[last_index] != open_character: 194 | e = _error(line_number + 1, last_index, error_code) 195 | self.errors.append(e) 196 | 197 | def _get_closing_index(self, line: str, close_character: str) -> int: 198 | """Get the line index for a closing character. 199 | 200 | The last, second to last, or third to last character on the line should 201 | be the closing character. Depends if there was a comma and/or newline. 202 | 203 | Arguments: 204 | line: The line to check. 205 | close_character: Closing character for the container. 206 | 207 | Returns: 208 | int 209 | 210 | """ 211 | slices = [-1, -2, -3] 212 | index = get_left_pad(line) 213 | 214 | for s in slices: 215 | if line[s] == close_character: 216 | index = len(line) + s 217 | break 218 | 219 | return index 220 | 221 | def _check_closing( 222 | self, 223 | open_character: str, 224 | close_character: str, 225 | matches: Tuple[int, int, str], 226 | line_number: int, 227 | line: str, 228 | error_code: ErrorCodes, 229 | ): 230 | """Implementation for JS102. 231 | 232 | If open_character and close_character are not on the same line, 233 | then close_character should be aligned to the opening line. 234 | 235 | Arguments: 236 | open_character: Opening character for the container. 237 | close_character: Closing character for the container. 238 | matches: Numers of open and closing characters found. 239 | line_number: The number of the line. Reported back to flake8. 240 | line: The line to check. 241 | error_code: The error to report if the validation fails. 242 | 243 | """ 244 | open_times, close_times, parsed_line = matches 245 | if parsed_line == ONLY_COMMENTS_STRING: 246 | return 247 | 248 | if close_times > 0 and self.inside_conditional_block: 249 | close_times -= 1 250 | self.inside_conditional_block -= 1 251 | 252 | # When inside a function call, 253 | # Then if a closing bracket is found and tuples are closed, 254 | # Assume it's the closing bracket for the call. 255 | if open_character == '(' and self.function_depth > 0: 256 | if close_times >= 1 and len(self.last_starts_at) == 0: 257 | close_times -= 1 258 | self.function_depth -= 1 259 | 260 | elif close_times > 0 and open_times == 0 and self.last_starts_at: 261 | index = self._get_closing_index(line, close_character) 262 | 263 | if index != self.last_starts_at[-1]: 264 | e = _error(line_number + 1, index, error_code) 265 | self.errors.append(e) 266 | 267 | # Remove the last start location 268 | self.last_starts_at.pop() 269 | 270 | def check_for_js101( 271 | self, 272 | line_number: int, 273 | line: str, 274 | curly_matches: Tuple[int, int, str], 275 | square_matches: Tuple[int, int, str], 276 | lunula_matches: Tuple[int, int, str], 277 | ): 278 | """Validate JS101 for a single line. 279 | 280 | When a line opens a container 281 | And the container isn't closed on the same line 282 | Then the line should break after the opening brackets 283 | """ 284 | self._check_opening('{', '}', curly_matches, line_number, line, ErrorCodes.JS101) 285 | self._check_opening('[', ']', square_matches, line_number, line, ErrorCodes.JS101) 286 | self._check_opening('(', ')', lunula_matches, line_number, line, ErrorCodes.JS101) 287 | 288 | def check_for_js102( 289 | self, 290 | line_number: int, 291 | line: str, 292 | curly_matches: Tuple[int, int, str], 293 | square_matches: Tuple[int, int, str], 294 | lunula_matches: Tuple[int, int, str], 295 | ): 296 | """Validate JS102 for a single line. 297 | 298 | When a line closes a container 299 | And the container isn't closed on the opening line 300 | Then the closing character must be on the same column as the 301 | opening line 302 | """ 303 | self._check_closing('{', '}', curly_matches, line_number, line, ErrorCodes.JS102) 304 | self._check_closing('[', ']', square_matches, line_number, line, ErrorCodes.JS102) 305 | self._check_closing('(', ')', lunula_matches, line_number, line, ErrorCodes.JS102) 306 | 307 | def docstring_status(self, line: str, quote: str, last_status: int) -> int: 308 | """Check if a line is part of a docstring. 309 | 310 | Arguments: 311 | line: The line to scan 312 | quote: The kind of quotation mark to check 313 | last_status: The state of the previous line scanned 314 | 315 | Returns: 316 | 0 if outside a docstring 317 | 1 if inside 318 | 2 if exiting, next line should be outside 319 | 320 | """ 321 | new_status = last_status 322 | if last_status == 2: 323 | new_status = 0 324 | 325 | strip = line.strip() 326 | 327 | # If a line starts with a triple quotation mark, it's either: 328 | if strip.startswith(quote): 329 | # A single line docstring 330 | if strip.endswith(quote) and len(strip) > 3: 331 | new_status = 2 332 | 333 | # Entering multiline docstring 334 | elif last_status != 1: 335 | new_status = 1 336 | 337 | # Exiting docstring where closing is on separate line. 338 | elif last_status == 1: 339 | new_status = 2 340 | 341 | # Exiting multiline docstring where closing is on same line as text. 342 | elif strip.endswith(quote) and last_status == 1: 343 | new_status = 2 344 | 345 | return new_status 346 | 347 | def run(self): 348 | """Entry point for the plugin.""" 349 | single_quote_status = 0 350 | double_quote_status = 0 351 | 352 | for index, line in enumerate(self.lines): 353 | # Ensure docstrings are ignored 354 | single_quote_status = self.docstring_status( 355 | line, "'''", single_quote_status, 356 | ) 357 | double_quote_status = self.docstring_status( 358 | line, '"""', double_quote_status, 359 | ) 360 | 361 | if single_quote_status == 0 and double_quote_status == 0: 362 | curly_matches = self._number_of_matches_in_line( 363 | '{', '}', line, 364 | ) 365 | 366 | square_matches = self._number_of_matches_in_line( 367 | '[', ']', line, 368 | ) 369 | 370 | lunula_matches = self._number_of_matches_in_line( 371 | '(', ')', line, 372 | ) 373 | 374 | self.check_for_js101( 375 | index, 376 | line, 377 | curly_matches, 378 | square_matches, 379 | lunula_matches, 380 | ) 381 | self.check_for_js102( 382 | index, 383 | line, 384 | curly_matches, 385 | square_matches, 386 | lunula_matches, 387 | ) 388 | 389 | for e in self.errors: 390 | yield e 391 | -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | pydocstyle==6.3.0 2 | flake8==7.1.2 3 | flake8-broken-line==1.0.0 4 | flake8-builtins==2.5.0 5 | flake8-bugbear==24.12.12 6 | flake8-commas==2.1.0 7 | flake8-docstrings==1.7.0 8 | flake8-eradicate==1.5.0 9 | flake8-import-order==0.18.2 10 | flake8-mutable==1.2.0 11 | pep8-naming==0.14.1 12 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | pytest==8.3.5 2 | pytest-cov==5.0.0 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import setuptools 4 | 5 | 6 | def read(filename): 7 | path = os.path.join(os.path.dirname(__file__), filename) 8 | with open(path, 'r') as f: 9 | return f.read() 10 | 11 | 12 | setuptools.setup( 13 | name="flake8-multiline-containers", 14 | license="MIT", 15 | version="0.0.19", 16 | description="Ensure a consistent format for multiline containers.", 17 | long_description=read('README.rst'), 18 | author="Joshua Fehler", 19 | author_email="jsfehler@gmail.com", 20 | url="https://github.com/jsfehler/flake8-multiline-containers", 21 | py_modules=["flake8_multiline_containers"], 22 | install_requires=[ 23 | "flake8 >= 3.7.9", 24 | "attrs >= 19.3.0", 25 | ], 26 | entry_points={ 27 | 'flake8.extension': [ 28 | 'JS = flake8_multiline_containers:MultilineContainers', 29 | ], 30 | }, 31 | classifiers=[ 32 | "Framework :: Flake8", 33 | "Environment :: Console", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: MIT License", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 3.8", 38 | "Programming Language :: Python :: 3.9", 39 | "Programming Language :: Python :: 3.10", 40 | "Programming Language :: Python :: 3.11", 41 | "Programming Language :: Python :: 3.12", 42 | "Topic :: Software Development :: Libraries :: Python Modules", 43 | "Topic :: Software Development :: Quality Assurance", 44 | ], 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsfehler/flake8-multiline-containers/99dbf3ca83357ec4fe220271418d92807528dfda/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from flake8_multiline_containers import MultilineContainers 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def linter(): 8 | m = MultilineContainers() 9 | m.errors = [] 10 | return m 11 | 12 | 13 | @pytest.fixture 14 | def dummy_file_path(): 15 | return 'tests/dummy' 16 | -------------------------------------------------------------------------------- /tests/dummy/callable/class_def.py: -------------------------------------------------------------------------------- 1 | class Foobar: 2 | pass 3 | 4 | 5 | class Foobar(A): 6 | pass 7 | 8 | 9 | class Foobar(A, B, C): 10 | pass 11 | 12 | 13 | class Foobar( 14 | A, 15 | B, 16 | C, 17 | ): 18 | pass 19 | 20 | 21 | class Foobar(A, 22 | B, 23 | C, 24 | ): 25 | pass 26 | -------------------------------------------------------------------------------- /tests/dummy/callable/function_call.py: -------------------------------------------------------------------------------- 1 | # Right: Extra whitespace doesn't cause function to be registered as a tuple 2 | bizbat ( "Hello", 3 | "World") 4 | 5 | bizbat ( "Hello", 6 | "World") 7 | 8 | # Right 9 | # Function call containing parens around a long string 10 | func( 11 | ( 12 | "long string such that we use parens" 13 | ), 14 | ) 15 | 16 | # Right: Nested function call is ignored 17 | bizbat(bazbin(""" 18 | """)) 19 | 20 | 21 | # Right: Nested function call is ignored 22 | bizbat(bazbin('a', 23 | 'b')) 24 | 25 | 26 | # Right: Opens and closes on same line 27 | foo = bizbat('hello', 'world') 28 | 29 | # Right: Line break after parenthesis, closes on same column 30 | foo = bizbat( 31 | 'hello', 32 | 'world', 33 | ) 34 | 35 | 36 | # Right: Line break after parenthesis, closes on same column 37 | foo = bizbat( 38 | 'hello', 'world', 39 | ) 40 | 41 | 42 | # Wrong: JS103 43 | foo = bizbat('hello', 44 | 'world', 45 | ) 46 | 47 | # Wrong: JS103 48 | foo = bizbat( 49 | 'hello', 'world') 50 | 51 | 52 | # Wrong: JS103, JS104 53 | foo = bizbat('hello', 54 | 'world') 55 | 56 | 57 | # Wrong: JS103, JS104 58 | foo = bizbat('hello', 59 | 'world', 60 | ) 61 | 62 | 63 | # Right 64 | # Function call with tuple inside 65 | foo = bizbat( 66 | ( 67 | 'hello', 68 | 'world', 69 | ) 70 | ) 71 | 72 | 73 | # Wrong: JS103 74 | # Function call with tuple inside 75 | foo = bizbat(( 76 | 'hello', 77 | 'world', 78 | ) 79 | ) -------------------------------------------------------------------------------- /tests/dummy/callable/function_def.py: -------------------------------------------------------------------------------- 1 | # Right: Extra whitespace doesn't cause function to be registered as a tuple 2 | def foo (a, 3 | b, 4 | c, 5 | ): pass 6 | 7 | def foo (a, 8 | b, 9 | c, 10 | ): pass 11 | 12 | # Right: Function without any arguments. 13 | def foo(): 14 | pass 15 | 16 | 17 | # Right: Function with arguments, ends on opening line. 18 | def bar(a, b, c): 19 | pass 20 | 21 | 22 | # Function with keyword argument that is a tuple. 23 | # Right 24 | def barb(a, b, c=('Hello', 'World')): 25 | pass 26 | 27 | 28 | # Function with keyword argument that is a tuple. 29 | # Right 30 | def baro(a, b, c=( 31 | 'Hello', 'World', 32 | ), 33 | ): 34 | pass 35 | 36 | 37 | # Right: Function with arguments, break after lunula 38 | def biz( 39 | a, 40 | b, 41 | c, 42 | ): 43 | pass 44 | 45 | 46 | # Wrong: Function with arguments, break after first argument 47 | def baz(a, 48 | b, 49 | c, 50 | ): 51 | pass 52 | 53 | 54 | # Wrong: Function with arguments, break after first argument, 55 | # closing bracket after last argument 56 | def bal(a, 57 | b, 58 | c): 59 | pass 60 | -------------------------------------------------------------------------------- /tests/dummy/comments.py: -------------------------------------------------------------------------------- 1 | # Comments should be ignored entirely 2 | 3 | # Technically correct, but would fail JS102 if detected 4 | # 'foobar': { 5 | # 'foo': 'Hello', 6 | # 'bar': 'World', 7 | # }, 8 | 9 | # JS101, but ignored 10 | # 'foobar': { 'foo': 'Hello', 11 | # 'bar': 'World', 12 | # }, 13 | 14 | 15 | # I'd slap you if you did this in real code, but it's... fine in a comment. 16 | 17 | # 'foobar': { 18 | # 'foo': 'Hello', 19 | # 'bar': 'World', 20 | # }, 21 | 22 | # 'foobar': { 'foo': 'Hello', 23 | # 'bar': 'World', 24 | # }, 25 | 26 | 27 | # Why do you do this? Still, ignored. 28 | a = b # a = { 'foobar': 'Hello', 29 | x = y # 'barfoo': 'World' } 30 | 31 | 32 | # Comments at the start of multiline containers should be ignored 33 | foo = [ # comment 34 | 1, 35 | 2, 36 | 3, 37 | ] 38 | -------------------------------------------------------------------------------- /tests/dummy/conditional_block.py: -------------------------------------------------------------------------------- 1 | # One line 2 | if (False or True): 3 | pass 4 | 5 | elif (False or True): 6 | pass 7 | 8 | 9 | # Multiple lines 10 | if (False 11 | or True): 12 | pass 13 | 14 | elif (False 15 | or True): 16 | pass 17 | 18 | 19 | if ( 20 | False or True 21 | ): 22 | pass 23 | 24 | elif ( 25 | False or True 26 | ): 27 | pass 28 | 29 | 30 | # Nested 31 | if (True or (False or True)): pass 32 | 33 | 34 | if ( 35 | True or (False or True) 36 | ): pass 37 | 38 | 39 | if ( 40 | True or ( 41 | False 42 | or True) 43 | ): pass 44 | -------------------------------------------------------------------------------- /tests/dummy/conditional_expression.py: -------------------------------------------------------------------------------- 1 | # Conditional expression where the third part of the expression is a 2 | # multiline container 3 | bar = 0 4 | 5 | 6 | foo = {} if bar == 1 else { 7 | "foo": 42, 8 | } 9 | 10 | 11 | foo = [] if bar == 1 else [ 12 | 42, 13 | } 14 | 15 | 16 | foo = () if bar == 1 else ( 17 | 42, 42, 18 | } 19 | -------------------------------------------------------------------------------- /tests/dummy/dict/dict.py: -------------------------------------------------------------------------------- 1 | # Right: Opens and closes on same line 2 | foo = {'a': 'hello', 'b': 'world'} 3 | 4 | 5 | # Right: Line break after parenthesis, closes on same column 6 | foo = { 7 | 'a': 'hello', 8 | 'b': 'world', 9 | } 10 | 11 | # Right: Line break after parenthesis, closes on same column 12 | foo = { 13 | 'a': 'hello', 'b': 'world', 14 | } 15 | 16 | # Right: Index after creation 17 | foo = { 18 | 'a': 'hello', 19 | 'b': 'world', 20 | }['a'] 21 | 22 | # Right: Index after creation 23 | try: 24 | foo = { 25 | 'a': 'hello', 26 | 'b': 'world', 27 | }['a'] 28 | 29 | except KeyError: 30 | pass 31 | 32 | 33 | # Wrong: JS101 34 | foo = {'a': 'hello', 35 | 'b': 'world', 36 | } 37 | 38 | 39 | # Wrong: JS102 40 | foo = { 41 | 'a': 'hello', 'b': 'world'} 42 | 43 | 44 | # Wrong: JS101, JS102 45 | foo = {'a': 'hello', 46 | 'b': 'world'} 47 | 48 | 49 | # Wrong: JS101, JS102 50 | foo = {'a': 'hello', 51 | 'b': 'world', 52 | } 53 | 54 | 55 | # Function call with dict inside 56 | 57 | # Right 58 | foo = bizbat({'a': 'Hello', 'b': 'World'}, True) 59 | 60 | # Right 61 | foo = bizbat( 62 | {'a': 'Hello', 'b': 'World'}, 63 | True, 64 | ) 65 | 66 | # Right 67 | foo = bizbat( 68 | { 69 | 'a': 'Hello', 70 | 'b': 'World' 71 | }, 72 | True, 73 | ) 74 | 75 | # Wrong: JS101, JS102 76 | foo = bizbat( 77 | {'a': 'Hello', 78 | 'b': 'World'}, 79 | True, 80 | ) 81 | -------------------------------------------------------------------------------- /tests/dummy/dict/dict_assignment.py: -------------------------------------------------------------------------------- 1 | # Right: Opens and closes on same line 2 | foo = {} 3 | 4 | # Assign list as value 5 | foo['a'] = ['b', 'c'] 6 | 7 | # Assign dict as value 8 | foo['a'] = {'b': 'hello', 'c': 'world'} 9 | 10 | # Assign set as value 11 | foo['a'] = {'b', 'c'} 12 | 13 | # Assign tuple as value 14 | foo['a'] = ('b', 'c',) 15 | 16 | # Right: Line break after parenthesis, closes on same column 17 | 18 | # Assign list as value 19 | foo['a'] = [ 20 | 'b', 21 | 'c', 22 | 'd', 23 | 'e', 24 | ] 25 | 26 | # Assign dict as value 27 | foo['a'] = { 28 | 'b': 'hello', 29 | 'c': 'world', 30 | 'd': 'goodbye', 31 | 'e': 'sky', 32 | } 33 | 34 | # Assign set as value 35 | foo['a'] = { 36 | 'b', 37 | 'c', 38 | 'd', 39 | 'e', 40 | } 41 | 42 | # Assign tuple as value 43 | foo['a'] = ( 44 | 'b', 45 | 'c', 46 | 'd', 47 | 'e', 48 | ) 49 | 50 | # Ridiculous 51 | foo['a'][42] = [ 52 | 'You', 53 | 'Do', 54 | 'You', 55 | ] 56 | 57 | bar = foo['a'] = [ 58 | 'Technically', 59 | 'Valid', 60 | 'But', 61 | 'Still', 62 | 'Bizarre', 63 | ] 64 | 65 | # Function calls in containers should be ignored 66 | foo['a'] = [ 67 | { 68 | 'hello': baz, 69 | 'world': baz, 70 | }, 71 | { 72 | 'goodbye': baz, 73 | 'moon': baz, 74 | }, 75 | ] 76 | 77 | 78 | foo['a'] = [ 79 | { 80 | 'hello': baz(x=True), 81 | 'world': baz(foobar='barfoo'), 82 | }, 83 | { 84 | 'goodbye': baz(z=[1,2,3]), 85 | 'moon': baz(z=(1, 2, 3)), 86 | }, 87 | ] 88 | 89 | 90 | # Wrong 91 | foo['a'] = [ 92 | { 93 | 'hello': {1, 94 | 2, 3}, 95 | 'world': {}, 96 | }, 97 | { 98 | 'goodbye': {}, 99 | 'moon': {}, 100 | }, 101 | ] 102 | -------------------------------------------------------------------------------- /tests/dummy/dict/nested_dict.py: -------------------------------------------------------------------------------- 1 | # Right: Opens and closes on same line 2 | foo = {'a': {'x': 'hello', 'y': 'world'}, 'b': {'x': 'hello', 'y': 'world'}} 3 | 4 | # Right 5 | foo = { 6 | 'a': {'x': 'hello', 'y': 'world'}, 7 | 'b': { 8 | 'x': 'hello', 9 | 'y': 'world', 10 | }, 11 | } 12 | 13 | 14 | # Dict has child with a child dict 15 | # Wrong: JS101 16 | foo = {'a': {'one': 'hello'}, 'b': {'two': 'world'}, 17 | 'c': {'three': 'hello'}, 'd': {'four': 'world'}, 18 | } 19 | 20 | 21 | # Wrong: JS101, JS102 22 | foo = { 23 | 'a': {'x': 'hello', 'y': 'world'}, 24 | 'b': {'x': 'hello', 25 | 'y': 'world'}, 26 | } 27 | 28 | 29 | # Wrong: JS102 30 | foo = { 31 | 'a': {'x': 'hello', 'y': 'world'}, 32 | 'b': {'x': 'hello', 'y': 'world'}, 33 | } 34 | 35 | 36 | # Right: Function call in dict that's in a tuple 37 | foo = ({'a': object()}) 38 | -------------------------------------------------------------------------------- /tests/dummy/docstrings.py: -------------------------------------------------------------------------------- 1 | """Docstring should be ignored. 2 | 3 | foo = {'a': 'hello', 4 | } 5 | 6 | """ 7 | 8 | foo = {'a': 'hello', 9 | } 10 | 11 | """Docstring should be ignored. 12 | """ 13 | 14 | foo = {'a': 'hello', 15 | } 16 | 17 | """Docstring should be ignored.""" 18 | 19 | foo = {'a': 'hello', 20 | } 21 | 22 | """ 23 | Docstring should be ignored.""" 24 | 25 | foo = {'a': 'hello', 26 | } 27 | 28 | '''Docstring should be ignored. 29 | ''' 30 | 31 | foo = {'a': 'hello', 32 | } 33 | 34 | '''Docstring should be ignored.''' 35 | 36 | foo = {'a': 'hello', 37 | } 38 | 39 | ''' 40 | Docstring should be ignored.''' 41 | -------------------------------------------------------------------------------- /tests/dummy/equality.py: -------------------------------------------------------------------------------- 1 | # Equality checks should behave the same way as assignment 2 | 3 | # Variable equality check 4 | bar == {'a': 1, 'b':2} 5 | 6 | bar == { 7 | 'a': 1, 8 | 'b': 2, 9 | } 10 | 11 | # Function equality check 12 | foo() == { 13 | 'a': 1, 14 | 'b': 2, 15 | 'c': 3, 16 | } 17 | 18 | 19 | foo(1, 2, 3) == { 20 | 'a': 1, 21 | 'b': 2, 22 | 'c': 3, 23 | } 24 | -------------------------------------------------------------------------------- /tests/dummy/list/list.py: -------------------------------------------------------------------------------- 1 | # Right: Opens and closes on same line 2 | foo = ['hello', 'world'] 3 | 4 | 5 | # Right: Line break after parenthesis, closes on same column 6 | foo = [ 7 | 'hello', 8 | 'world', 9 | ] 10 | 11 | 12 | # Right: Line break after parenthesis, closes on same column 13 | foo = [ 14 | 'hello', 'world', 15 | ] 16 | 17 | 18 | # Wrong: JS101 19 | foo = ['hello', 20 | 'world', 21 | ] 22 | 23 | # Wrong: JS102 24 | foo = [ 25 | 'hello', 'world'] 26 | 27 | 28 | # Wrong: JS101, JS102 29 | foo = ['hello', 30 | 'world'] 31 | 32 | 33 | # Wrong: JS101, JS102 34 | foo = ['hello', 35 | 'world', 36 | ] 37 | 38 | 39 | # Right: List with callables that have optional arguments is ignored 40 | foo = [foobar(baz=False), foobar(baz=True)] 41 | 42 | # Right: Multiline list with callables that have optional arguments is ignored 43 | foo = [ 44 | foobar(baz=False), 45 | foobar(baz=True), 46 | ] 47 | 48 | # Right: Multiline list with callables with optional arguments with callables is ignored 49 | foo = [ 50 | foobar(baz=[bazbin(a=True)]), 51 | foobar(baz=[bazbin(a=False)]), 52 | ] 53 | 54 | # Function calls in containers should be ignored 55 | foo['a'] = [ 56 | [ 57 | baz, 58 | baz, 59 | ], 60 | [ 61 | baz, 62 | baz, 63 | ], 64 | ] 65 | 66 | 67 | foo['a'] = [ 68 | [ 69 | baz(x=True), 70 | baz(foobar='barfoo'), 71 | ], 72 | [ 73 | baz(z=[1,2,3]), 74 | baz(z=(1, 2, 3)), 75 | ], 76 | ] 77 | -------------------------------------------------------------------------------- /tests/dummy/list/list_comprehension.py: -------------------------------------------------------------------------------- 1 | # Right: Opens and closes on same line 2 | 3 | foo = ['a', 'b', 'a', 'c'] 4 | x = [i for i in foo if i == 'a'] 5 | 6 | # Right: Equality comparison isn't misidentified as an assignment 7 | x = [i for i in foo['bar'] if i['baz'] == ex['baz']] 8 | -------------------------------------------------------------------------------- /tests/dummy/list/nested_list.py: -------------------------------------------------------------------------------- 1 | # Right 2 | foo = [['hello', 'world'], ['sun', 'moon']] 3 | 4 | # Right 5 | foo = [ 6 | ['hello', 'world'], 7 | [ 8 | 'hello', 9 | 'world', 10 | ], 11 | ] 12 | 13 | 14 | # list has child list that ends on same line as opening 15 | # Wrong: JS101 16 | foo = [['earth', 'mars'], 17 | ['sun', 'moon'], 18 | ] 19 | 20 | 21 | # list has child with a child list that ends on same line as opening 22 | # Wrong: JS101 23 | foo = [{'a': 'hello', 'b': ['earth', 'mars']}, 24 | {'c': 'good night', 'd': 'moon'}, 25 | ] 26 | 27 | 28 | # Nested list contains a container that doesn't break on the opening 29 | # Wrong: JS101, JS102 30 | foo = [ 31 | ['hello', 'world'], 32 | ['hello', 33 | 'world'], 34 | ] 35 | 36 | 37 | # Nested list closes on wrong column 38 | # Wrong: JS102 39 | foo = [ 40 | ['hello', 'world'], 41 | ['hello', 'world'], 42 | ] 43 | 44 | 45 | # Right: Function call in list that's in a tuple 46 | foo = ([object()]) 47 | -------------------------------------------------------------------------------- /tests/dummy/multiple_opening.py: -------------------------------------------------------------------------------- 1 | # Extremely Wrong: JS101 2 | foo = {'a': { 3 | 'b': 1 4 | } 5 | } 6 | 7 | # Extremely Wrong: JS101, JS102 8 | foo = {'a': { 9 | 'b': 1} 10 | } 11 | -------------------------------------------------------------------------------- /tests/dummy/no_newline.py: -------------------------------------------------------------------------------- 1 | { 2 | 'a': {} 3 | } -------------------------------------------------------------------------------- /tests/dummy/pound_sign_in_string.py: -------------------------------------------------------------------------------- 1 | # A pound sign in a string shouldn't be detected as the start of a comment 2 | {'foobar': '#/definitions/Empty'} 3 | -------------------------------------------------------------------------------- /tests/dummy/set/nested_set.py: -------------------------------------------------------------------------------- 1 | # Right 2 | foo = { 3 | {'hello', 'world'}, 4 | { 5 | 'hello', 6 | 'world', 7 | }, 8 | } 9 | 10 | 11 | # Wrong: JS101, JS102 12 | foo = { 13 | {'hello', 'world'}, 14 | {'hello', 15 | 'world'}, 16 | } 17 | 18 | 19 | # Wrong: JS102 20 | foo = { 21 | {'hello', 'world'}, 22 | {'hello', 'world'}, 23 | } 24 | 25 | 26 | # Right: Function call in set that's in a tuple 27 | foo = ({object()}) 28 | -------------------------------------------------------------------------------- /tests/dummy/set/set.py: -------------------------------------------------------------------------------- 1 | # Right: Opens and closes on same line 2 | foo = {'hello', 'world'} 3 | 4 | 5 | # Right: Line break after parenthesis, closes on same column 6 | foo = { 7 | 'hello', 8 | 'world', 9 | } 10 | 11 | 12 | # Right: Line break after parenthesis, closes on same column 13 | foo = { 14 | 'hello', 'world', 15 | } 16 | 17 | 18 | # Wrong: JS101 19 | foo = {'hello', 20 | 'world', 21 | } 22 | 23 | # Wrong: JS102 24 | foo = { 25 | 'hello', 'world'} 26 | 27 | 28 | # Wrong: JS101, JS102 29 | foo = {'hello', 30 | 'world'} 31 | 32 | 33 | # Wrong: JS101, JS102 34 | foo = {'hello', 35 | 'world', 36 | } 37 | -------------------------------------------------------------------------------- /tests/dummy/string/multiline.py: -------------------------------------------------------------------------------- 1 | # Triple quote strings should be ignored 2 | 3 | foo = """(a=10""" 4 | 5 | foo = """(a=10 6 | ) 7 | """ 8 | 9 | foo = """ 10 | (a=10 11 | ) 12 | """ 13 | 14 | foo = '''(a=10''' 15 | 16 | foo = '''(a=10 17 | ) 18 | ''' 19 | 20 | foo = ''' 21 | (a=10 22 | ) 23 | ''' 24 | -------------------------------------------------------------------------------- /tests/dummy/string/raw_strings.py: -------------------------------------------------------------------------------- 1 | foo['bar'] = r'[\d]' 2 | 3 | foo['bar'] = r'[d]' 4 | 5 | foo = r'[\d]' 6 | -------------------------------------------------------------------------------- /tests/dummy/string/string.py: -------------------------------------------------------------------------------- 1 | s = '{', '}', "foo={a\n", 'bar' 2 | s = '{', '}', "foo=}a\n", 'bar' 3 | 4 | string_only_opens_sq = '[foobar' 5 | string_only_closes_sq = 'foobar]' 6 | 7 | string_only_opens_dq = "[foobar" 8 | string_only_closes_dq = "foobar]" 9 | 10 | has_escape_characters = '[The cold echo of his master\'s laugh]' 11 | -------------------------------------------------------------------------------- /tests/dummy/string/string_brackets.py: -------------------------------------------------------------------------------- 1 | container_with_strings = { 2 | 'a': '{hello}', 'b': '{world}', 3 | } 4 | 5 | container_with_uneven_strings = { 6 | 'a': '{hello', 'b': '{{world}', 7 | } 8 | 9 | container_with_strings_and_error = { 10 | 'a': '{hello}', 'b': '{world}'} 11 | -------------------------------------------------------------------------------- /tests/dummy/tuple/nested_tuple.py: -------------------------------------------------------------------------------- 1 | # Right 2 | foo = (('hello', 'world'), ('sun', 'moon')) 3 | 4 | # Right 5 | foo = ( 6 | ('hello', 'world'), 7 | ( 8 | 'hello', 9 | 'world', 10 | ), 11 | ) 12 | 13 | # tuple has child list that ends on same line as opening 14 | # Wrong: JS101 15 | foo = (('earth', 'mars'), 16 | ('sun', 'moon'), 17 | ) 18 | 19 | 20 | # tuple has child with a child tuple that ends on same line as opening 21 | # Wrong: JS101 22 | foo = ({'a': 'hello', 'b': ('earth', 'mars')}, 23 | {'c': 'good night', 'd': 'moon'}, 24 | ) 25 | 26 | 27 | # Nested tuple contains a container that doesn't break on the opening 28 | # Wrong: JS101, JS102 29 | foo = ( 30 | ('hello', 'world'), 31 | ('hello', 32 | 'world'), 33 | ) 34 | 35 | 36 | # Nested tuple closes on wrong column 37 | # Wrong: JS102 38 | foo = ( 39 | ('hello', 'world'), 40 | ('hello', 'world'), 41 | ) 42 | 43 | 44 | # Right: Function call in tuple that's in a tuple 45 | foo = ((object())) 46 | 47 | foo = ((object(1, 2, 3))) 48 | 49 | foo = ((object((1, 2, 3), 4, 5))) 50 | -------------------------------------------------------------------------------- /tests/dummy/tuple/tuple.py: -------------------------------------------------------------------------------- 1 | # Right: Opens and closes on same line 2 | foo = ('hello', 'world') 3 | 4 | # Right: Line break after parenthesis, closes on same column 5 | foo = ( 6 | 'hello', 7 | 'world', 8 | ) 9 | 10 | 11 | # Right: Line break after parenthesis, closes on same column 12 | foo = ( 13 | 'hello', 'world', 14 | ) 15 | 16 | 17 | # Wrong: JS101 18 | foo = ('hello', 19 | 'world', 20 | ) 21 | 22 | # Wrong: JS102 23 | foo = ( 24 | 'hello', 'world') 25 | 26 | 27 | # Wrong: JS101, JS102 28 | foo = ('hello', 29 | 'world') 30 | 31 | 32 | # Wrong: JS101, JS102 33 | foo = ('hello', 34 | 'world', 35 | ) 36 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from flake8_multiline_containers import ErrorCodes 2 | 3 | 4 | def test_check_opening_contains_error(linter): 5 | line = "foo={a\n" 6 | 7 | curly_matches = linter._number_of_matches_in_line( 8 | '{', '}', line, 9 | ) 10 | 11 | linter._check_opening('{', '}', curly_matches, 0, line, ErrorCodes.JS101) 12 | assert 1 == len(linter.errors) 13 | 14 | 15 | def test_check_opening_no_error(linter): 16 | line = "foo={\n" 17 | 18 | curly_matches = linter._number_of_matches_in_line( 19 | '{', '}', line, 20 | ) 21 | 22 | linter._check_opening('{', '}', curly_matches, 0, line, ErrorCodes.JS101) 23 | assert 0 == len(linter.errors) 24 | -------------------------------------------------------------------------------- /tests/test_class_definition.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def class_def_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/callable/class_def.py' 11 | 12 | 13 | def test_js101_class_def(class_def_file_path): 14 | """Class definition should not trigger JS101.""" 15 | style_guide = flake8.get_style_guide( 16 | select=['JS101'], 17 | ) 18 | 19 | p = os.path.abspath(class_def_file_path) 20 | r = style_guide.check_files([p]) 21 | 22 | assert 0 == r.total_errors 23 | 24 | 25 | def test_js102_class_def(class_def_file_path): 26 | """Class definition should not trigger JS102.""" 27 | style_guide = flake8.get_style_guide( 28 | select=['JS102'], 29 | ) 30 | 31 | p = os.path.abspath(class_def_file_path) 32 | r = style_guide.check_files([p]) 33 | 34 | assert 0 == r.total_errors 35 | -------------------------------------------------------------------------------- /tests/test_comments_ignore.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def comments_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/comments.py' 11 | 12 | 13 | def test_js101_comments_ignore(comments_file_path): 14 | """Comment lines should not trigger JS101.""" 15 | style_guide = flake8.get_style_guide( 16 | select=['JS101'], 17 | ) 18 | 19 | p = os.path.abspath(comments_file_path) 20 | r = style_guide.check_files([p]) 21 | 22 | assert 0 == r.total_errors 23 | 24 | 25 | def test_js102_comments_ignored(comments_file_path): 26 | """Comment lines should not trigger JS102.""" 27 | style_guide = flake8.get_style_guide( 28 | select=['JS102'], 29 | ) 30 | 31 | p = os.path.abspath(comments_file_path) 32 | r = style_guide.check_files([p]) 33 | 34 | assert 0 == r.total_errors 35 | -------------------------------------------------------------------------------- /tests/test_conditional_block.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def conditional_block_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/conditional_block.py' 11 | 12 | 13 | def test_js101_conditional_block(conditional_block_file_path): 14 | """Conditional blocks should not trigger JS101.""" 15 | style_guide = flake8.get_style_guide( 16 | select=['JS101'], 17 | ) 18 | 19 | p = os.path.abspath(conditional_block_file_path) 20 | r = style_guide.check_files([p]) 21 | 22 | assert 0 == r.total_errors 23 | 24 | 25 | def test_js102_conditional_block(conditional_block_file_path): 26 | """Conditional blocks should not trigger JS102.""" 27 | style_guide = flake8.get_style_guide( 28 | select=['JS102'], 29 | ) 30 | 31 | p = os.path.abspath(conditional_block_file_path) 32 | r = style_guide.check_files([p]) 33 | 34 | assert 0 == r.total_errors 35 | -------------------------------------------------------------------------------- /tests/test_conditional_expression.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def conditional_expression_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/conditional_expression.py' 11 | 12 | 13 | @pytest.mark.doit1 14 | def test_js101_conditional_block(conditional_expression_file_path): 15 | """Conditional blocks should not trigger JS101.""" 16 | style_guide = flake8.get_style_guide( 17 | select=['JS101'], 18 | ) 19 | 20 | p = os.path.abspath(conditional_expression_file_path) 21 | r = style_guide.check_files([p]) 22 | 23 | assert 0 == r.total_errors 24 | 25 | 26 | def test_js102_conditional_block(conditional_expression_file_path): 27 | """Conditional blocks should not trigger JS102.""" 28 | style_guide = flake8.get_style_guide( 29 | select=['JS102'], 30 | ) 31 | 32 | p = os.path.abspath(conditional_expression_file_path) 33 | r = style_guide.check_files([p]) 34 | 35 | assert 0 == r.total_errors 36 | -------------------------------------------------------------------------------- /tests/test_dict_assignment.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def dict_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/dict/dict_assignment.py' 11 | 12 | 13 | def test_js101_dict(dict_file_path): 14 | style_guide = flake8.get_style_guide( 15 | select=['JS101'], 16 | ) 17 | 18 | p = os.path.abspath(dict_file_path) 19 | r = style_guide.check_files([p]) 20 | 21 | assert 1 == r.total_errors 22 | 23 | 24 | def test_js102_dict(dict_file_path): 25 | style_guide = flake8.get_style_guide( 26 | select=['JS102'], 27 | ) 28 | 29 | p = os.path.abspath(dict_file_path) 30 | r = style_guide.check_files([p]) 31 | 32 | assert 1 == r.total_errors 33 | -------------------------------------------------------------------------------- /tests/test_docstring_ignore.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def docstring_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/docstrings.py' 11 | 12 | 13 | def test_docstring_ignore(docstring_file_path): 14 | """Docstrings should be ignored. 15 | 16 | Code after a docstring should not be ignored. 17 | """ 18 | style_guide = flake8.get_style_guide( 19 | select=['JS101'], 20 | ) 21 | 22 | p = os.path.abspath(docstring_file_path) 23 | r = style_guide.check_files([p]) 24 | 25 | assert 6 == r.total_errors 26 | -------------------------------------------------------------------------------- /tests/test_equality.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def eq_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/equality.py' 11 | 12 | 13 | def test_js101_eq(eq_file_path): 14 | style_guide = flake8.get_style_guide( 15 | select=['JS101'], 16 | ) 17 | 18 | p = os.path.abspath(eq_file_path) 19 | r = style_guide.check_files([p]) 20 | 21 | assert 0 == r.total_errors 22 | 23 | 24 | def test_js102_eq(eq_file_path): 25 | style_guide = flake8.get_style_guide( 26 | select=['JS102'], 27 | ) 28 | 29 | p = os.path.abspath(eq_file_path) 30 | r = style_guide.check_files([p]) 31 | 32 | assert 0 == r.total_errors 33 | -------------------------------------------------------------------------------- /tests/test_function_calls.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def function_calls_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/callable/function_call.py' 11 | 12 | 13 | def test_js101_function_calls_ignored(function_calls_file_path): 14 | """Function calls should not trigger JS101.""" 15 | style_guide = flake8.get_style_guide( 16 | select=['JS101'], 17 | ) 18 | 19 | p = os.path.abspath(function_calls_file_path) 20 | r = style_guide.check_files([p]) 21 | 22 | assert 0 == r.total_errors 23 | 24 | 25 | def test_js102_function_calls_ignored(function_calls_file_path): 26 | """Function calls should not trigger JS102.""" 27 | style_guide = flake8.get_style_guide( 28 | select=['JS102'], 29 | ) 30 | 31 | p = os.path.abspath(function_calls_file_path) 32 | r = style_guide.check_files([p]) 33 | 34 | assert 0 == r.total_errors 35 | -------------------------------------------------------------------------------- /tests/test_function_definition.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def function_def_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/callable/function_def.py' 11 | 12 | 13 | def test_js101_function_def_ignored(function_def_file_path): 14 | """Function definitions should not trigger JS101.""" 15 | style_guide = flake8.get_style_guide( 16 | select=['JS101'], 17 | ) 18 | 19 | p = os.path.abspath(function_def_file_path) 20 | r = style_guide.check_files([p]) 21 | 22 | assert 0 == r.total_errors 23 | 24 | 25 | def test_js102_function_def_ignored(function_def_file_path): 26 | """Function definitions should not trigger JS102.""" 27 | style_guide = flake8.get_style_guide( 28 | select=['JS102'], 29 | ) 30 | 31 | p = os.path.abspath(function_def_file_path) 32 | r = style_guide.check_files([p]) 33 | 34 | assert 0 == r.total_errors 35 | -------------------------------------------------------------------------------- /tests/test_get_left_pad.py: -------------------------------------------------------------------------------- 1 | from flake8_multiline_containers import get_left_pad 2 | 3 | 4 | def test_get_left_pad(): 5 | amount = get_left_pad(" test") 6 | 7 | assert 10 == amount 8 | -------------------------------------------------------------------------------- /tests/test_list_comprehension.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def list_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/list/list_comprehension.py' 11 | 12 | 13 | def test_js101_list_comprehension(list_file_path): 14 | style_guide = flake8.get_style_guide( 15 | select=['JS101'], 16 | ) 17 | 18 | p = os.path.abspath(list_file_path) 19 | r = style_guide.check_files([p]) 20 | 21 | assert 0 == r.total_errors 22 | 23 | 24 | def test_js102_list_comprehension(list_file_path): 25 | style_guide = flake8.get_style_guide( 26 | select=['JS102'], 27 | ) 28 | 29 | p = os.path.abspath(list_file_path) 30 | r = style_guide.check_files([p]) 31 | 32 | assert 0 == r.total_errors 33 | -------------------------------------------------------------------------------- /tests/test_multiline.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def dict_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/dict/dict.py' 11 | 12 | 13 | @pytest.fixture 14 | def list_file_path(dummy_file_path): 15 | return f'{dummy_file_path}/list/list.py' 16 | 17 | 18 | @pytest.fixture 19 | def set_file_path(dummy_file_path): 20 | return f'{dummy_file_path}/set/set.py' 21 | 22 | 23 | @pytest.fixture 24 | def tuple_file_path(dummy_file_path): 25 | return f'{dummy_file_path}/tuple/tuple.py' 26 | 27 | 28 | def test_js101_dict(dict_file_path): 29 | style_guide = flake8.get_style_guide( 30 | select=['JS101'], 31 | ) 32 | 33 | p = os.path.abspath(dict_file_path) 34 | r = style_guide.check_files([p]) 35 | 36 | assert 4 == r.total_errors 37 | 38 | 39 | def test_js102_dict(dict_file_path): 40 | style_guide = flake8.get_style_guide( 41 | select=['JS102'], 42 | ) 43 | 44 | p = os.path.abspath(dict_file_path) 45 | r = style_guide.check_files([p]) 46 | 47 | assert 4 == r.total_errors 48 | 49 | 50 | def test_js101_list(list_file_path): 51 | style_guide = flake8.get_style_guide( 52 | select=['JS101'], 53 | ) 54 | 55 | p = os.path.abspath(list_file_path) 56 | r = style_guide.check_files([p]) 57 | 58 | assert 3 == r.total_errors 59 | 60 | 61 | def test_js102_list(list_file_path): 62 | style_guide = flake8.get_style_guide( 63 | select=['JS102'], 64 | ) 65 | 66 | p = os.path.abspath(list_file_path) 67 | r = style_guide.check_files([p]) 68 | 69 | assert 3 == r.total_errors 70 | 71 | 72 | def test_js101_set(set_file_path): 73 | style_guide = flake8.get_style_guide( 74 | select=['JS101'], 75 | ) 76 | 77 | p = os.path.abspath(set_file_path) 78 | r = style_guide.check_files([p]) 79 | 80 | assert 3 == r.total_errors 81 | 82 | 83 | def test_js102_set(set_file_path): 84 | style_guide = flake8.get_style_guide( 85 | select=['JS102'], 86 | ) 87 | 88 | p = os.path.abspath(set_file_path) 89 | r = style_guide.check_files([p]) 90 | 91 | assert 3 == r.total_errors 92 | 93 | 94 | def test_js101_tuple(tuple_file_path): 95 | style_guide = flake8.get_style_guide( 96 | select=['JS101'], 97 | ) 98 | 99 | p = os.path.abspath(tuple_file_path) 100 | r = style_guide.check_files([p]) 101 | 102 | assert 3 == r.total_errors 103 | 104 | 105 | def test_js102_tuple(tuple_file_path): 106 | style_guide = flake8.get_style_guide( 107 | select=['JS102'], 108 | ) 109 | 110 | p = os.path.abspath(tuple_file_path) 111 | r = style_guide.check_files([p]) 112 | 113 | assert 3 == r.total_errors 114 | -------------------------------------------------------------------------------- /tests/test_multiple_opening.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def multiple_opening_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/multiple_opening.py' 11 | 12 | 13 | def test_js101_multiple_opening(multiple_opening_file_path): 14 | style_guide = flake8.get_style_guide( 15 | select=['JS101'], 16 | ) 17 | 18 | p = os.path.abspath(multiple_opening_file_path) 19 | r = style_guide.check_files([p]) 20 | 21 | assert 2 == r.total_errors 22 | 23 | 24 | def test_js102_multiple_opening(multiple_opening_file_path): 25 | style_guide = flake8.get_style_guide( 26 | select=['JS102'], 27 | ) 28 | 29 | p = os.path.abspath(multiple_opening_file_path) 30 | r = style_guide.check_files([p]) 31 | 32 | assert 1 == r.total_errors 33 | -------------------------------------------------------------------------------- /tests/test_nested.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def dict_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/dict/nested_dict.py' 11 | 12 | 13 | @pytest.fixture 14 | def list_file_path(dummy_file_path): 15 | return f'{dummy_file_path}/list/nested_list.py' 16 | 17 | 18 | @pytest.fixture 19 | def set_file_path(dummy_file_path): 20 | return f'{dummy_file_path}/set/nested_set.py' 21 | 22 | 23 | @pytest.fixture 24 | def tuple_file_path(dummy_file_path): 25 | return f'{dummy_file_path}/tuple/nested_tuple.py' 26 | 27 | 28 | def test_js101_dict(dict_file_path): 29 | style_guide = flake8.get_style_guide( 30 | select=['JS101'], 31 | ) 32 | 33 | p = os.path.abspath(dict_file_path) 34 | r = style_guide.check_files([p]) 35 | 36 | assert 2 == r.total_errors 37 | 38 | 39 | def test_js102_dict(dict_file_path): 40 | style_guide = flake8.get_style_guide( 41 | select=['JS102'], 42 | ) 43 | 44 | p = os.path.abspath(dict_file_path) 45 | r = style_guide.check_files([p]) 46 | 47 | assert 2 == r.total_errors 48 | 49 | 50 | def test_js101_list(list_file_path): 51 | style_guide = flake8.get_style_guide( 52 | select=['JS101'], 53 | ) 54 | 55 | p = os.path.abspath(list_file_path) 56 | r = style_guide.check_files([p]) 57 | 58 | assert 3 == r.total_errors 59 | 60 | 61 | def test_js102_list(list_file_path): 62 | style_guide = flake8.get_style_guide( 63 | select=['JS102'], 64 | ) 65 | 66 | p = os.path.abspath(list_file_path) 67 | r = style_guide.check_files([p]) 68 | 69 | assert 2 == r.total_errors 70 | 71 | 72 | def test_js101_set(set_file_path): 73 | style_guide = flake8.get_style_guide( 74 | select=['JS101'], 75 | ) 76 | 77 | p = os.path.abspath(set_file_path) 78 | r = style_guide.check_files([p]) 79 | 80 | assert 1 == r.total_errors 81 | 82 | 83 | def test_js102_set(set_file_path): 84 | style_guide = flake8.get_style_guide( 85 | select=['JS102'], 86 | ) 87 | 88 | p = os.path.abspath(set_file_path) 89 | r = style_guide.check_files([p]) 90 | 91 | assert 2 == r.total_errors 92 | 93 | 94 | def test_js101_tuple(tuple_file_path): 95 | style_guide = flake8.get_style_guide( 96 | select=['JS101'], 97 | ) 98 | 99 | p = os.path.abspath(tuple_file_path) 100 | r = style_guide.check_files([p]) 101 | 102 | assert 3 == r.total_errors 103 | 104 | 105 | def test_js102_tuple(tuple_file_path): 106 | style_guide = flake8.get_style_guide( 107 | select=['JS102'], 108 | ) 109 | 110 | p = os.path.abspath(tuple_file_path) 111 | r = style_guide.check_files([p]) 112 | 113 | assert 2 == r.total_errors 114 | -------------------------------------------------------------------------------- /tests/test_no_newline.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def no_newline_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/no_newline.py' 11 | 12 | 13 | def test_js101_no_newline(no_newline_file_path): 14 | """When a file has no blank line at EOF 15 | And a container ends correctly on the last line 16 | Then the linter should not detect an error. 17 | """ 18 | style_guide = flake8.get_style_guide( 19 | select=['JS101'], 20 | ) 21 | 22 | p = os.path.abspath(no_newline_file_path) 23 | r = style_guide.check_files([p]) 24 | 25 | assert 0 == r.total_errors 26 | 27 | 28 | def test_js102_no_newline(no_newline_file_path): 29 | """When a file has no blank line at EOF 30 | And a container ends correctly on the last line 31 | Then the linter should not detect an error. 32 | """ 33 | style_guide = flake8.get_style_guide( 34 | select=['JS102'], 35 | ) 36 | 37 | p = os.path.abspath(no_newline_file_path) 38 | r = style_guide.check_files([p]) 39 | 40 | assert 0 == r.total_errors 41 | -------------------------------------------------------------------------------- /tests/test_pound_sign_in_string.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def pound_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/mixed.py' 11 | 12 | 13 | def test_js101_pound(pound_file_path): 14 | """Pound signs in strings shouldn't be considered the start of comments.""" 15 | style_guide = flake8.get_style_guide( 16 | select=['JS101'], 17 | ) 18 | 19 | p = os.path.abspath(pound_file_path) 20 | r = style_guide.check_files([p]) 21 | 22 | assert 0 == r.total_errors 23 | 24 | 25 | def test_js102_pound(pound_file_path): 26 | """Pound signs in strings shouldn't be considered the start of comments.""" 27 | style_guide = flake8.get_style_guide( 28 | select=['JS102'], 29 | ) 30 | 31 | p = os.path.abspath(pound_file_path) 32 | r = style_guide.check_files([p]) 33 | 34 | assert 0 == r.total_errors 35 | -------------------------------------------------------------------------------- /tests/test_raw_strings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def raw_strings_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/string/raw_strings.py' 11 | 12 | 13 | def test_js101_raw_strings(raw_strings_file_path): 14 | """Ensure raw strings don't mess up the string finding regex.""" 15 | style_guide = flake8.get_style_guide( 16 | select=['JS101'], 17 | ) 18 | 19 | p = os.path.abspath(raw_strings_file_path) 20 | r = style_guide.check_files([p]) 21 | 22 | assert 0 == r.total_errors 23 | 24 | 25 | def test_js102_raw_strings(raw_strings_file_path): 26 | """Ensure raw strings don't mess up the string finding regex.""" 27 | style_guide = flake8.get_style_guide( 28 | select=['JS102'], 29 | ) 30 | 31 | p = os.path.abspath(raw_strings_file_path) 32 | r = style_guide.check_files([p]) 33 | 34 | assert 0 == r.total_errors 35 | -------------------------------------------------------------------------------- /tests/test_string_ignore.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flake8.api import legacy as flake8 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def string_file_path(dummy_file_path): 10 | return f'{dummy_file_path}/string/string.py' 11 | 12 | 13 | @pytest.fixture 14 | def string_brackets_file_path(dummy_file_path): 15 | return f'{dummy_file_path}/string/string_brackets.py' 16 | 17 | 18 | @pytest.fixture 19 | def multiline_string_file_path(dummy_file_path): 20 | return f'{dummy_file_path}/string/multiline.py' 21 | 22 | 23 | def test_js101_string_ignore(string_file_path): 24 | """When opening and closing characters are in a string 25 | Then the linter should not detect them. 26 | """ 27 | style_guide = flake8.get_style_guide( 28 | select=['JS101'], 29 | ) 30 | 31 | p = os.path.abspath(string_file_path) 32 | r = style_guide.check_files([p]) 33 | 34 | assert 0 == r.total_errors 35 | 36 | 37 | def test_js102_string_ignore(string_file_path): 38 | """When opening and closing characters are in a string 39 | Then the linter should not detect them. 40 | """ 41 | style_guide = flake8.get_style_guide( 42 | select=['JS102'], 43 | ) 44 | 45 | p = os.path.abspath(string_file_path) 46 | r = style_guide.check_files([p]) 47 | 48 | assert 0 == r.total_errors 49 | 50 | 51 | def test_js101_string_brackets_ignore(string_brackets_file_path): 52 | """When opening and closing characters are in a string 53 | Then the linter should not detect them. 54 | """ 55 | style_guide = flake8.get_style_guide( 56 | select=['JS101'], 57 | ) 58 | 59 | p = os.path.abspath(string_brackets_file_path) 60 | r = style_guide.check_files([p]) 61 | 62 | assert 0 == r.total_errors 63 | 64 | 65 | def test_js102_string_brackets_ignore(string_brackets_file_path): 66 | """When opening and closing characters are in a string 67 | Then the linter should not detect them. 68 | """ 69 | style_guide = flake8.get_style_guide( 70 | select=['JS102'], 71 | ) 72 | 73 | p = os.path.abspath(string_brackets_file_path) 74 | r = style_guide.check_files([p]) 75 | 76 | assert 1 == r.total_errors 77 | 78 | 79 | def test_js101_multiline_string_ignore(multiline_string_file_path): 80 | """When opening and closing characters are in a string 81 | Then the linter should not detect them. 82 | """ 83 | style_guide = flake8.get_style_guide( 84 | select=['JS101'], 85 | ) 86 | 87 | p = os.path.abspath(multiline_string_file_path) 88 | r = style_guide.check_files([p]) 89 | 90 | assert 0 == r.total_errors 91 | 92 | 93 | def test_js102_multiline_string_ignore(multiline_string_file_path): 94 | """When opening and closing characters are in a string 95 | Then the linter should not detect them. 96 | """ 97 | style_guide = flake8.get_style_guide( 98 | select=['JS102'], 99 | ) 100 | 101 | p = os.path.abspath(multiline_string_file_path) 102 | r = style_guide.check_files([p]) 103 | 104 | assert 0 == r.total_errors 105 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-complexity = 10 3 | max-line-length = 99 4 | exclude = 5 | .svn, 6 | CVS, 7 | .bzr, 8 | .hg, 9 | .git, 10 | __pycache__, 11 | .DS_Store, 12 | .tox, 13 | .idea, 14 | .pytest_cache, 15 | venv, 16 | tests/dummy # Dummy files have lint errors on purpose 17 | ignore = D100,D101,D102,D104,D401 18 | per-file-ignores = 19 | tests/*:D103,D205,D400 20 | setup.py:D103 21 | 22 | [tox] 23 | envlist = py38,py39,py310,py311,py312,flake8 24 | 25 | [testenv] 26 | deps = -rrequirements/tests.txt 27 | commands = 28 | py.test tests --cov=flake8_multiline_containers {posargs} 29 | 30 | [testenv:flake8] 31 | deps = -rrequirements/lint.txt 32 | commands = flake8 {posargs} 33 | --------------------------------------------------------------------------------