Skip to content

Commit e025fda

Browse files
authored
Add static code analysis to CI (#613)
* Add static code analysis to CI * Limit mypy CI check * Fix issues with mypy * Use script to improve error reporting * Clean up code * Fix issue with mypy * Fix broken imports in pydantic * Solve review comments * Pin the latest version of mypy and flake8 * Exclude run for python 3.8 * Simplify script
1 parent d427fae commit e025fda

8 files changed

Lines changed: 147 additions & 10 deletions

File tree

.github/workflows/build.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ jobs:
2525
pip install setuptools==69.5.1 wheel
2626
pip install -r requirements.txt
2727
28+
# Static analysis tools
29+
- name: Static Code Analysis
30+
if: runner.os == 'Linux' && matrix.python-version != '3.8'
31+
run: |
32+
pip install mypy==1.19.1 flake8==7.3.0
33+
python static_analysis.py
34+
2835
- name: Run tests
2936
run: python -m pytest -s -rs
3037

lean/commands/lean.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
13-
from typing import Optional
1413

1514
from click import group, option, Context, pass_context, echo
1615

lean/commands/live/deploy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# limitations under the License.
1313

1414
from pathlib import Path
15-
from typing import Any, Dict, List, Optional, Tuple
15+
from typing import List, Optional, Tuple
1616
from click import option, argument, Choice
1717
from lean.click import LeanCommand, PathParameter
1818
from lean.components.util.name_rename import rename_internal_config_to_user_friendly_format

lean/components/api/live_client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,10 @@
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
1313

14-
from datetime import datetime
1514
from typing import List, Optional
1615

1716
from lean.components.api.api_client import *
18-
from lean.models.api import QCFullLiveAlgorithm, QCLiveAlgorithmStatus, QCMinimalLiveAlgorithm, QCNotificationMethod, QCRestResponse
17+
from lean.models.api import QCFullLiveAlgorithm, QCMinimalLiveAlgorithm, QCNotificationMethod, QCRestResponse
1918

2019

2120
class LiveClient:

lean/components/util/compiler.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,14 @@ def _compile() -> Dict[str, Any]:
111111
"mounts": [],
112112
"volumes": {}
113113
}
114-
lean_runner.mount_project_and_library_directories(project_dir, run_options)
115-
lean_runner.setup_language_specific_run_options(run_options, project_dir, algorithm_file, False, False)
116114

117115
project_config = project_config_manager.get_project_config(project_dir)
118116
engine_image = cli_config_manager.get_engine_image(
119117
project_config.get("engine-image", None))
120118

119+
lean_runner.mount_project_and_library_directories(project_dir, run_options)
120+
lean_runner.setup_language_specific_run_options(run_options, project_dir, algorithm_file, False, False, engine_image)
121+
121122
message["result"] = docker_manager.run_image(engine_image, **run_options)
122123
temp_manager.delete_temporary_directories_when_done = False
123124
return message
@@ -153,8 +154,7 @@ def _parse_python_errors(python_output: str, color_coding_required: bool) -> lis
153154
errors.append(f"{bcolors.FAIL}Build Error File: {match[0]} Line {match[1]} Column {match[2]} - {match[3]}{bcolors.ENDC}\n")
154155
else:
155156
errors.append(f"Build Error File: {match[0]} Line {match[1]} Column {match[2]} - {match[3]}\n")
156-
157-
for match in re.findall(r"\*\*\* Sorry: ([^(]+) \(([^,]+), line (\d+)\)", python_output):
157+
for match in findall(r"\*\*\* Sorry: ([^(]+) \(([^,]+), line (\d+)\)", python_output):
158158
if color_coding_required:
159159
errors.append(f"{bcolors.FAIL}Build Error File: {match[1]} Line {match[2]} Column 0 - {match[0]}{bcolors.ENDC}\n")
160160
else:

lean/components/util/project_manager.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,6 @@ def restore_csharp_project(self, csproj_file: Path, no_local: bool) -> None:
366366
"""
367367
from shutil import which
368368
from subprocess import run, STDOUT, PIPE
369-
from lean.models.errors import MoreInfoError
370369

371370
if no_local:
372371
return

lean/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def main() -> None:
9797
if temp_manager.delete_temporary_directories_when_done:
9898
temp_manager.delete_temporary_directories()
9999
except Exception as exception:
100-
from traceback import format_exc, print_exc
100+
from traceback import format_exc
101101
from click import UsageError, Abort
102102
from requests import exceptions
103103
from io import StringIO

static_analysis.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
2+
# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
import subprocess
15+
import sys
16+
17+
MYPY_IGNORE_PATTERNS = [
18+
'click.', 'subprocess.', 'Module "',
19+
'has incompatible type "Optional',
20+
'validator', 'pydantic', '__call__',
21+
'OnlyValueValidator', 'V1Validator',
22+
'QCParameter', 'QCBacktest'
23+
]
24+
25+
def run_mypy_check():
26+
result = subprocess.run(
27+
["python", "-m", "mypy", "lean/",
28+
"--show-error-codes",
29+
"--no-error-summary",
30+
"--ignore-missing-imports",
31+
"--check-untyped-defs"],
32+
capture_output=True,
33+
text=True
34+
)
35+
36+
errors = []
37+
for line in result.stdout.splitlines() + result.stderr.splitlines():
38+
if not line.strip() or '[call-arg]' not in line:
39+
continue
40+
41+
# Skip false positives
42+
if any(pattern in line for pattern in MYPY_IGNORE_PATTERNS):
43+
continue
44+
45+
errors.append(line.strip())
46+
47+
return errors
48+
49+
def run_flake8_check(select_code):
50+
result = subprocess.run(
51+
["python", "-m", "flake8", "lean/",
52+
f"--select={select_code}",
53+
"--ignore=ALL",
54+
"--exit-zero"],
55+
capture_output=True,
56+
text=True
57+
)
58+
59+
errors = [line.strip() for line in result.stdout.splitlines() if line.strip()]
60+
return errors, len(errors)
61+
62+
def display_errors(title, errors, is_critical = True):
63+
level = "CRITICAL" if is_critical else "WARNING"
64+
if errors:
65+
print(f"{level}: {len(errors)} {title} found:")
66+
for error in errors:
67+
# Clean path for better display
68+
clean_error = error.replace('/home/runner/work/lean-cli/lean-cli/', '')
69+
print(f" {clean_error}")
70+
else:
71+
print(f"No {title} found")
72+
73+
def display_warning_summary(unused_count):
74+
print("\nWarnings:")
75+
if unused_count > 0:
76+
print(f" - Unused imports: {unused_count}")
77+
print(" Consider addressing warnings in future updates.")
78+
79+
def run_analysis() -> int:
80+
print("Running static analysis...")
81+
print("=" * 60)
82+
83+
critical_error_count = 0
84+
warning_count = 0
85+
86+
# Check for missing function arguments with mypy
87+
print("\n1. Checking for missing function arguments...")
88+
print("-" * 40)
89+
90+
call_arg_errors = run_mypy_check()
91+
display_errors("function call argument mismatch(es)", call_arg_errors)
92+
critical_error_count += len(call_arg_errors)
93+
94+
# Check for undefined variables with flake8
95+
print("\n2. Checking for undefined variables...")
96+
print("-" * 40)
97+
98+
undefined_errors, undefined_count = run_flake8_check("F821")
99+
display_errors("undefined variable(s)", undefined_errors)
100+
critical_error_count += undefined_count
101+
102+
# Check for unused imports with flake8
103+
print("\n3. Checking for unused imports...")
104+
print("-" * 40)
105+
106+
unused_imports, unused_count = run_flake8_check("F401")
107+
display_errors("unused import(s)", unused_imports, is_critical=False)
108+
warning_count += unused_count
109+
110+
# Summary
111+
print("\n" + "=" * 60)
112+
113+
if critical_error_count > 0:
114+
print(f"BUILD FAILED: Found {critical_error_count} critical error(s)")
115+
print("\nSummary of critical errors:")
116+
print(f" - Function call argument mismatches: {len(call_arg_errors)}")
117+
print(f" - Undefined variables: {undefined_count}")
118+
119+
if warning_count > 0:
120+
display_warning_summary(unused_count)
121+
122+
return 1
123+
124+
if warning_count > 0:
125+
print(f"BUILD PASSED with {warning_count} warning(s)")
126+
display_warning_summary(unused_count)
127+
return 0
128+
129+
print("SUCCESS: All checks passed with no warnings")
130+
return 0
131+
132+
if __name__ == "__main__":
133+
sys.exit(run_analysis())

0 commit comments

Comments
 (0)