Skip to content

Commit b780922

Browse files
authored
Merge pull request #2265 from strictdoc-project/stanislaw/merge_nodes
Code climate: add custom fixit linting check: StrictDoc_CheckDocstringsRule
2 parents 61df6db + e626cdf commit b780922

File tree

10 files changed

+184
-16
lines changed

10 files changed

+184
-16
lines changed

developer/fixit/check_docstrings.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from typing import Optional
2+
3+
from fixit import LintRule
4+
from libcst import SimpleString, Expr
5+
from libcst.metadata import ParentNodeProvider, PositionProvider
6+
7+
RESERVED_STRINGS = ["@relation", "FIXME"]
8+
9+
10+
def find_first_significant_char_index(text: str) -> Optional[int]:
11+
for i, char in enumerate(text):
12+
if char not in {'"', " ", "\t", "#", "\n"}:
13+
return i
14+
return None
15+
16+
17+
def should_check_string(string: str) -> bool:
18+
return not any(rs in string for rs in RESERVED_STRINGS)
19+
20+
21+
def find_last_significant_char_index(text: str) -> Optional[int]:
22+
for i in range(len(text) - 1, -1, -1):
23+
if text[i] not in {'"', " ", "\t", "#", "\n"}:
24+
return i
25+
return None
26+
27+
28+
class StrictDoc_CheckDocStringsRule(LintRule):
29+
METADATA_DEPENDENCIES = (
30+
ParentNodeProvider,
31+
PositionProvider,
32+
)
33+
34+
def visit_SimpleString(self, node: "SimpleString") -> Optional[bool]:
35+
parent = self.get_metadata(ParentNodeProvider, node)
36+
37+
if isinstance(parent, Expr):
38+
node_strings = node.value.splitlines(keepends=False)
39+
if len(node_strings) == 0:
40+
return True
41+
42+
assert node_strings[0].startswith('"""')
43+
44+
position = self.get_metadata(PositionProvider, node)
45+
column = position.start.column
46+
47+
if node_strings[0] != '"""':
48+
node_strings[0] = " " * column + node_strings[0][3:]
49+
node_strings.insert(0, '"""')
50+
51+
if node_strings[-1].strip(" ") != '"""':
52+
node_strings[-1] = node_strings[-1][:-3]
53+
node_strings.append(" " * column + '"""')
54+
55+
if (
56+
node_strings[2].strip() not in ('"""', "")
57+
and "FIXME" not in node_strings[1]
58+
):
59+
self.report(
60+
node,
61+
"The first line of a docstring must be separated "
62+
"with an empty line from the rest of the docstring comment.",
63+
)
64+
return True
65+
66+
if (should_check_string(node_strings[1])) and not node_strings[
67+
1
68+
].endswith("."):
69+
node_strings[1] += "."
70+
71+
first_index = find_first_significant_char_index(node_strings[0])
72+
if first_index is not None:
73+
if node.value[first_index].islower():
74+
node_strings[0] = (
75+
node_strings[0][:first_index]
76+
+ node_strings[0][first_index].upper()
77+
+ node_strings[0][first_index + 1 :]
78+
)
79+
80+
if len(node_strings) > 3 and should_check_string(node_strings[-2]):
81+
last_index = find_last_significant_char_index(node_strings[-2])
82+
if last_index is not None:
83+
if node_strings[-2].rstrip()[last_index] != ".":
84+
node_strings[-2] = (
85+
node_strings[-2][:last_index]
86+
+ node_strings[-2][last_index]
87+
+ "."
88+
+ node_strings[-2][last_index + 1 :]
89+
)
90+
91+
patched_value = "\n".join(node_strings)
92+
93+
if patched_value != node.value:
94+
self.report(
95+
node,
96+
(
97+
"The docstring comment's first sentence must start with "
98+
"a capital letter and end with a dot. Additionally, "
99+
"and extended comment that follows the first line must "
100+
"be separated from the first sentence with an empty line. "
101+
"The last sentence must end with a dot."
102+
),
103+
replacement=SimpleString(patched_value),
104+
)
105+
106+
return True

pyproject.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,13 @@ addopts = "--import-mode=importlib"
122122
pythonpath = [
123123
"."
124124
]
125+
126+
[tool.fixit]
127+
enable = [
128+
".developer.fixit.check_docstrings",
129+
]
130+
131+
# FIXME: Remove disable.
132+
disable = [
133+
"fixit.rules"
134+
]

requirements.check.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ toml
33
# Lint
44
mypy>=0.910
55
ruff>=0.9
6+
fixit
67

78
# Unit tests
89
pytest>=6.2.2

strictdoc/backend/excel/import_/excel_sheet_proxy.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ class ExcelLibType(Enum):
1414

1515
@auto_described
1616
class ExcelSheetProxy:
17-
"""This proxy class allows to open either xls(xlrd) or xlsx(openpyxl) with the same interface"""
17+
"""
18+
This proxy class allows to open either xls(xlrd) or xlsx(openpyxl) with the same interface.
19+
"""
1820

1921
def __init__(self, file: str):
2022
self.file = file
@@ -35,7 +37,9 @@ def __init__(self, file: str):
3537

3638
@property
3739
def name(self) -> str:
38-
"""Returns the name of the first sheet"""
40+
"""
41+
Returns the name of the first sheet.
42+
"""
3943
if self.lib == ExcelLibType.OPENPYXL:
4044
return str(self.sheet.title)
4145
elif self.lib == ExcelLibType.XLRD:
@@ -44,7 +48,9 @@ def name(self) -> str:
4448

4549
@property
4650
def ncols(self) -> int:
47-
"""The number of columns"""
51+
"""
52+
The number of columns.
53+
"""
4854
ncols: int = 0
4955
if self.lib == ExcelLibType.OPENPYXL:
5056
ncols = self.sheet.max_column
@@ -54,7 +60,9 @@ def ncols(self) -> int:
5460

5561
@property
5662
def nrows(self) -> int:
57-
"""The number of rows"""
63+
"""
64+
The number of rows.
65+
"""
5866
nrows: int = 0
5967
if self.lib == ExcelLibType.OPENPYXL:
6068
nrows = self.sheet.max_row
@@ -63,7 +71,9 @@ def nrows(self) -> int:
6371
return nrows
6472

6573
def get_cell_value(self, row: int, col: int) -> str:
66-
"""Returns the value at row/col as string"""
74+
"""
75+
Returns the value at row/col as string.
76+
"""
6777
cell_value: str = ""
6878
if self.lib == ExcelLibType.OPENPYXL:
6979
cell_value = (
@@ -74,7 +84,9 @@ def get_cell_value(self, row: int, col: int) -> str:
7484
return cell_value
7585

7686
def row_values(self, row: int) -> List[str]:
77-
"""Returns a full row as list"""
87+
"""
88+
Returns a full row as list.
89+
"""
7890
row_values: List[str] = []
7991
if self.lib == ExcelLibType.OPENPYXL:
8092
row_values = [(cell.value or "") for cell in self.sheet[row + 1]]

strictdoc/backend/sdoc_source_code/reader_c.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,9 @@ def _get_function_name_node(function_declarator_node: Node):
389389

390390
@staticmethod
391391
def get_node_ns(node: Node) -> Sequence[str]:
392-
"""Walk up the tree and find parent classes"""
392+
"""
393+
Walk up the tree and find parent classes.
394+
"""
393395
parent_scopes = []
394396
cursor: Optional[Node] = node
395397
while cursor is not None:

strictdoc/backend/sdoc_source_code/reader_python.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,9 @@ def read_from_file(self, file_path: str) -> SourceFileTraceabilityInfo:
280280

281281
@staticmethod
282282
def get_node_ns(node: Node) -> Sequence[str]:
283-
"""Walk up the tree and find parent classes"""
283+
"""
284+
Walk up the tree and find parent classes.
285+
"""
284286
parent_scopes = []
285287
cursor: Optional[Node] = node
286288
while cursor:

strictdoc/backend/sdoc_source_code/reader_robot.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,27 @@ def __init__(
5959
self.parse_context = parse_context
6060

6161
def visit_Comment(self, node: Comment) -> None:
62-
"""Create non-function Marker from Comment outside TestCases."""
62+
"""
63+
Create non-function Marker from Comment outside TestCases.
64+
"""
6365
self._visit_possibly_marked_node(node)
6466

6567
def visit_Documentation(self, node: Documentation) -> None:
66-
"""Create non-function Marker from Documentation outside TestCases."""
68+
"""
69+
Create non-function Marker from Documentation outside TestCases.
70+
"""
6771
self._visit_possibly_marked_node(node)
6872

6973
def visit_Tags(self, node: Tags) -> None:
70-
"""Create non-function Marker from Tags outside TestCases."""
74+
"""
75+
Create non-function Marker from Tags outside TestCases.
76+
"""
7177
self._visit_possibly_marked_node(node)
7278

7379
def visit_TestCase(self, node: TestCase) -> None:
74-
"""Create function and non-function Marker from TestCases."""
80+
"""
81+
Create function and non-function Marker from TestCases.
82+
"""
7583
trailing_empty_lines = 0
7684
tc_markers = []
7785
for stmt in node.body:

strictdoc/backend/sdoc_source_code/test_reports/robot_xml_reader.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ def __init__(self, project_config: ProjectConfig):
2222
self.document: Optional[SDocDocument] = None
2323

2424
def visit_suite(self, suite: robot.result.TestSuite) -> None:
25-
"""Create document for top level suite and sections for nested suites."""
25+
"""
26+
Create document for top level suite and sections for nested suites.
27+
"""
2628
assert suite.full_name not in self.suites
2729

2830
if suite.parent is None:
@@ -67,7 +69,9 @@ def visit_suite(self, suite: robot.result.TestSuite) -> None:
6769
super().visit_suite(suite)
6870

6971
def visit_test(self, test: robot.result.TestCase) -> None:
70-
"""Create TEST_RESULT node for each test case."""
72+
"""
73+
Create TEST_RESULT node for each test case.
74+
"""
7175
assert self.document
7276
assert test.parent and test.parent.full_name in self.suites, (
7377
"depth-first traversal expected"

strictdoc/git/project_diff_analyzer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ def get_changes_requirements_changed(self) -> Optional[int]:
229229

230230
def get_changes_sections_stats_string(self) -> str:
231231
"""
232-
Example: 2 removed, 1 modified, 2 added
232+
Example: 2 removed, 1 modified, 2 added.
233233
"""
234234
change_components = []
235235
removed = self._change_counters.get(ChangeType.SECTION_REMOVED)
@@ -246,7 +246,7 @@ def get_changes_sections_stats_string(self) -> str:
246246

247247
def get_changes_requirements_stats_string(self) -> str:
248248
"""
249-
Example: 2 removed, 1 modified, 2 added
249+
Example: 2 removed, 1 modified, 2 added.
250250
"""
251251
change_components = []
252252
removed = self._change_counters.get(ChangeType.REQUIREMENT_REMOVED)

tasks.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def run_invoke_with_tox(
6666
environment_type: ToxEnvironment,
6767
command: str,
6868
environment: Optional[Dict] = None,
69+
pty: bool = False,
6970
) -> invoke.runners.Result:
7071
assert isinstance(environment_type, ToxEnvironment)
7172
assert isinstance(command, str)
@@ -80,6 +81,7 @@ def run_invoke_with_tox(
8081
{command}
8182
""",
8283
environment=environment,
84+
pty=pty,
8385
)
8486

8587

@@ -592,6 +594,27 @@ def lint_mypy(context):
592594
# # @sdoc[/SDOC-SRS-43]
593595

594596

597+
@task(aliases=["lf"])
598+
def lint_fixit(context, fix=False):
599+
if fix:
600+
run_invoke_with_tox(
601+
context,
602+
ToxEnvironment.CHECK,
603+
"""
604+
fixit fix strictdoc/
605+
""",
606+
pty=True,
607+
)
608+
else:
609+
run_invoke_with_tox(
610+
context,
611+
ToxEnvironment.CHECK,
612+
"""
613+
fixit lint --diff strictdoc/
614+
""",
615+
)
616+
617+
595618
@task(aliases=["l"])
596619
def lint(context):
597620
lint_ruff_format(context)

0 commit comments

Comments
 (0)