Skip to content

Commit 88b2798

Browse files
authored
Lint: Add a check for indentation being 4N spaces. (#5772)
* Add a check for indentation being 4N spaces. Make 'url' key optional in check specification. Document pyproject.toml fields. * added changelog; fixed tests * test indentations * fix test to use real url * allow -n to set 'S012'. * response to review * remove unwanted import * review response: Move whitespace/newlines into rst only section * refix mypy import issue
1 parent d6cc772 commit 88b2798

File tree

3 files changed

+138
-31
lines changed

3 files changed

+138
-31
lines changed

changes.d/5772.feat.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a check for indentation being 4N spaces.

cylc/flow/scripts/lint.py

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
# NOTE: docstring needed for `cylc help all` output
2323
# (if editing check this still comes out as expected)
2424

25+
LINT_SECTIONS = ['cylc-lint', 'cylclint', 'cylc_lint']
26+
2527
COP_DOC = """cylc lint [OPTIONS] ARGS
2628
2729
Check .cylc and .rc files for code style, deprecated syntax and other issues.
@@ -33,13 +35,18 @@
3335
3436
A non-zero return code will be returned if any issues are identified.
3537
This can be overridden by providing the "--exit-zero" flag.
38+
"""
3639

37-
Configurations for Cylc lint can also be set in a pyproject.toml file.
38-
40+
TOMLDOC = """
41+
pyproject.toml configuration:{}
42+
[cylc-lint] # any of {}
43+
ignore = ['S001', 'S002] # List of rules to ignore
44+
exclude = ['etc/foo.cylc'] # List of files to ignore
45+
rulesets = ['style', '728'] # Sets default rulesets to check
46+
max-line-length = 130 # Max line length for linting
3947
"""
4048
from colorama import Fore
4149
import functools
42-
from optparse import Values
4350
from pathlib import Path
4451
import re
4552
import sys
@@ -59,7 +66,7 @@
5966
loads as toml_loads,
6067
TOMLDecodeError,
6168
)
62-
from typing import Callable, Dict, Iterator, List, Union
69+
from typing import TYPE_CHECKING, Callable, Dict, Iterator, List, Union
6370

6471
from cylc.flow import LOG
6572
from cylc.flow.exceptions import CylcError
@@ -73,6 +80,10 @@
7380
from cylc.flow.scripts.cylc import DEAD_ENDS
7481
from cylc.flow.terminal import cli_function
7582

83+
if TYPE_CHECKING:
84+
from optparse import Values
85+
86+
7687
DEPRECATED_ENV_VARS = {
7788
'CYLC_SUITE_HOST': 'CYLC_WORKFLOW_HOST',
7889
'CYLC_SUITE_OWNER': 'CYLC_WORKFLOW_OWNER',
@@ -222,13 +233,44 @@ def check_for_obsolete_environment_variables(line: str) -> List[str]:
222233
return [i for i in OBSOLETE_ENV_VARS if i in line]
223234

224235

236+
INDENTATION = re.compile(r'^(\s*)(.*)')
237+
238+
239+
def check_indentation(line: str) -> bool:
240+
"""The key value pair is not indented 4*X spaces
241+
242+
n.b. We test for trailing whitespace and incorrect section indenting
243+
elsewhere
244+
245+
Examples:
246+
247+
>>> check_indentation('')
248+
False
249+
>>> check_indentation(' ')
250+
False
251+
>>> check_indentation(' [')
252+
False
253+
>>> check_indentation('baz')
254+
False
255+
>>> check_indentation(' qux')
256+
False
257+
>>> check_indentation(' foo')
258+
True
259+
>>> check_indentation(' bar')
260+
True
261+
"""
262+
match = INDENTATION.findall(line)[0]
263+
if not match[0] or not match[1] or match[1].startswith('['):
264+
return False
265+
return bool(len(match[0]) % 4 != 0)
266+
267+
225268
FUNCTION = 'function'
226269

227270
STYLE_GUIDE = (
228271
'https://cylc.github.io/cylc-doc/stable/html/workflow-design-guide/'
229272
'style-guide.html#'
230273
)
231-
URL_STUB = "https://cylc.github.io/cylc-doc/stable/html/7-to-8/"
232274
SECTION2 = r'\[\[\s*{}\s*\]\]'
233275
SECTION3 = r'\[\[\[\s*{}\s*\]\]\]'
234276
FILEGLOBS = ['*.rc', '*.cylc']
@@ -268,7 +310,6 @@ def check_for_obsolete_environment_variables(line: str) -> List[str]:
268310
# - short: A short description of the issue.
269311
# - url: A link to a fuller description.
270312
# - function: A function to use to run the check.
271-
# - fallback: A second function(The first function might want to call this?)
272313
# - kwargs: We want to pass a set of common kwargs to the check function.
273314
# - evaluate commented lines: Run this check on commented lines.
274315
# - rst: An rst description, for use in the Cylc docs.
@@ -398,21 +439,32 @@ def check_for_obsolete_environment_variables(line: str) -> List[str]:
398439
check_if_jinja2,
399440
function=re.compile(r'(?<!{)#.*?{[{%]').findall
400441
)
442+
},
443+
'S012': {
444+
'short': 'This number is reserved for line length checks',
445+
},
446+
'S013': {
447+
'short': 'Items should be indented in 4 space blocks.',
448+
FUNCTION: check_indentation
401449
}
402450
}
403451
# Subset of deprecations which are tricky (impossible?) to scrape from the
404452
# upgrader.
405453
MANUAL_DEPRECATIONS = {
406454
"U001": {
407-
'short': DEPENDENCY_SECTION_MSG['text'],
455+
'short': (
456+
DEPENDENCY_SECTION_MSG['text'] + ' (``[dependencies]`` detected)'
457+
),
408458
'url': '',
409-
'rst': DEPENDENCY_SECTION_MSG['rst'],
459+
'rst': (
460+
DEPENDENCY_SECTION_MSG['rst'] + ' (``[dependencies]`` detected)'
461+
),
410462
FUNCTION: re.compile(SECTION2.format('dependencies')).findall,
411463
},
412464
"U002": {
413-
'short': DEPENDENCY_SECTION_MSG['text'],
465+
'short': DEPENDENCY_SECTION_MSG['text'] + ' (``graph =`` detected)',
414466
'url': '',
415-
'rst': DEPENDENCY_SECTION_MSG['rst'],
467+
'rst': DEPENDENCY_SECTION_MSG['rst'] + ' (``graph =`` detected)',
416468
FUNCTION: re.compile(r'graph\s*=\s*').findall,
417469
},
418470
"U003": {
@@ -541,6 +593,31 @@ def check_for_obsolete_environment_variables(line: str) -> List[str]:
541593
}
542594

543595

596+
def get_url(check_meta: Dict) -> str:
597+
"""Get URL from check data.
598+
599+
If the URL doesn't start with http then prepend with address
600+
of the 7-to-8 upgrade guide.
601+
602+
Examples:
603+
>>> get_url({'no': 'url key'})
604+
''
605+
>>> get_url({'url': ''})
606+
''
607+
>>> get_url({'url': 'https://www.h2g2.com/'})
608+
'https://www.h2g2.com/'
609+
>>> get_url({'url': 'cheat-sheet.html'})
610+
'https://cylc.github.io/cylc-doc/stable/html/7-to-8/cheat-sheet.html'
611+
"""
612+
url = check_meta.get('url', '')
613+
if url and not url.startswith('http'):
614+
url = (
615+
"https://cylc.github.io/cylc-doc/stable/html/7-to-8/"
616+
+ check_meta['url']
617+
)
618+
return url
619+
620+
544621
def validate_toml_items(tomldata):
545622
"""Check that all tomldata items are lists of strings
546623
@@ -590,7 +667,7 @@ def get_pyproject_toml(dir_):
590667
raise CylcError(f'pyproject.toml did not load: {exc}')
591668

592669
if any(
593-
i in loadeddata for i in ['cylc-lint', 'cylclint', 'cylc_lint']
670+
i in loadeddata for i in LINT_SECTIONS
594671
):
595672
for key in keys:
596673
tomldata[key] = loadeddata.get('cylc-lint').get(key, [])
@@ -906,10 +983,7 @@ def lint(
906983
counter[check_meta['purpose']] += 1
907984
if modify:
908985
# insert a command to help the user
909-
if check_meta['url'].startswith('http'):
910-
url = check_meta['url']
911-
else:
912-
url = URL_STUB + check_meta['url']
986+
url = get_url(check_meta)
913987

914988
yield (
915989
f'# [{get_index_str(check_meta, index)}]: '
@@ -983,10 +1057,7 @@ def get_reference_rst(checks):
9831057
template = (
9841058
'{check}\n^^^^\n{summary}\n\n'
9851059
)
986-
if meta['url'].startswith('http'):
987-
url = meta['url']
988-
else:
989-
url = URL_STUB + meta['url']
1060+
url = get_url(meta)
9901061
summary = meta.get("rst", meta['short'])
9911062
msg = template.format(
9921063
check=get_index_str(meta, index),
@@ -1027,10 +1098,7 @@ def get_reference_text(checks):
10271098
template = (
10281099
'{check}:\n {summary}\n\n'
10291100
)
1030-
if meta['url'].startswith('http'):
1031-
url = meta['url']
1032-
else:
1033-
url = URL_STUB + meta['url']
1101+
url = get_url(meta)
10341102
msg = template.format(
10351103
title=index,
10361104
check=get_index_str(meta, index),
@@ -1044,7 +1112,7 @@ def get_reference_text(checks):
10441112

10451113
def get_option_parser() -> COP:
10461114
parser = COP(
1047-
COP_DOC,
1115+
COP_DOC + TOMLDOC.format('', str(LINT_SECTIONS)),
10481116
argdoc=[
10491117
COP.optional(WORKFLOW_ID_OR_PATH_ARG_DOC)
10501118
],
@@ -1086,7 +1154,7 @@ def get_option_parser() -> COP:
10861154
default=[],
10871155
dest='ignores',
10881156
metavar="CODE",
1089-
choices=tuple(STYLE_CHECKS)
1157+
choices=list(STYLE_CHECKS.keys()) + [LINE_LEN_NO]
10901158
)
10911159
parser.add_option(
10921160
'--exit-zero',
@@ -1204,4 +1272,6 @@ def main(parser: COP, options: 'Values', target=None) -> None:
12041272

12051273
# NOTE: use += so that this works with __import__
12061274
# (docstring needed for `cylc help all` output)
1207-
__doc__ += get_reference_rst(parse_checks(['728', 'style'], reference=True))
1275+
__doc__ += TOMLDOC.format(
1276+
'\n\n.. code-block:: toml\n', str(LINT_SECTIONS)) + get_reference_rst(
1277+
parse_checks(['728', 'style'], reference=True))

tests/unit/scripts/test_lint.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@
142142
[[and_another_thing]]
143143
[[[remote]]]
144144
host = `rose host-select thingy`
145-
146145
"""
147146

148147

@@ -159,6 +158,9 @@
159158
# {{quix}}
160159
161160
[runtime]
161+
[[this_is_ok]]
162+
script = echo "this is incorrectly indented"
163+
162164
[[foo]]
163165
inherit = hello
164166
[[[job]]]
@@ -572,11 +574,45 @@ def test_invalid_tomlfile(tmp_path):
572574
'ref, expect',
573575
[
574576
[True, 'line > ``<max_line_len>`` characters'],
575-
[False, 'line > 130 characters']
577+
[False, 'line > 42 characters']
576578
]
577579
)
578580
def test_parse_checks_reference_mode(ref, expect):
579-
result = parse_checks(['style'], reference=ref)
580-
key = list(result.keys())[-1]
581-
value = result[key]
581+
"""Add extra explanation of max line legth setting in reference mode.
582+
"""
583+
result = parse_checks(['style'], reference=ref, max_line_len=42)
584+
value = result['S012']
582585
assert expect in value['short']
586+
587+
588+
@pytest.mark.parametrize(
589+
'spaces, expect',
590+
(
591+
(0, 'S002'),
592+
(1, 'S013'),
593+
(2, 'S013'),
594+
(3, 'S013'),
595+
(4, None),
596+
(5, 'S013'),
597+
(6, 'S013'),
598+
(7, 'S013'),
599+
(8, None),
600+
(9, 'S013')
601+
)
602+
)
603+
def test_indents(spaces, expect):
604+
"""Test different wrong indentations
605+
606+
Parameterization deliberately over-obvious to avoid replicating
607+
arithmetic logic from code. Dangerously close to re-testing ``%``
608+
builtin.
609+
"""
610+
result = lint_text(
611+
f"{' ' * spaces}foo = 42",
612+
['style']
613+
)
614+
result = ''.join(result.messages)
615+
if expect:
616+
assert expect in result
617+
else:
618+
assert not result

0 commit comments

Comments
 (0)