reflectsonar 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ """
2
+ ReflectSonar - PDF Report Generator for SonarQube Analysis
3
+
4
+ ReflectSonar is a Python tool for generating PDF reports from SonarQube analysis data.
5
+ It reads data via the SonarQube API and generates comprehensive PDF reports for
6
+ general metrics, issues, and security hotspots.
7
+
8
+ Author: Ata Seren
9
+ License: MIT
10
+ """
11
+
12
+ __version__ = "1.0.0"
13
+ __author__ = "Ata Seren"
14
+ __email__ = "ata.seren@hotmail.com"
15
+ __license__ = "GPL-3.0"
16
+
17
+ from .main import main
18
+
19
+ __all__ = ["main"]
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Console script entry point for ReflectSonar
4
+ """
5
+ import sys
6
+ from reflectsonar.main import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """API module for ReflectSonar - handles SonarQube API interactions"""
@@ -0,0 +1,256 @@
1
+ """
2
+ This module is used to fetch data from SonarQube API
3
+ """
4
+ import traceback
5
+ from typing import Dict, List
6
+ import requests
7
+
8
+ from ..data.models import (SonarQubeProject, SonarQubeIssue, SonarQubeMeasure, # pylint: disable=import-error
9
+ SonarQubeHotspot, ReportData, SonarQubeRule) # pylint: disable=import-error
10
+
11
+ from ..report.utils import log
12
+
13
+ # Helpers
14
+ def get_json(url: str, token: str) -> Dict:
15
+ """Helper function to perform GET request and return JSON response"""
16
+ response = requests.get(url, auth=(token, ""), timeout=30)
17
+ response.raise_for_status()
18
+ return response.json()
19
+
20
+ def fetch(name: str, url: str, token: str, verbose: bool) -> Dict:
21
+ """Fetches data from SonarQube API by using get_json and utils.log"""
22
+ log(verbose, f"Fetching {name}...")
23
+ return get_json(url, token)
24
+
25
+ # Getters
26
+ def get_rules(base_url: str, token: str, rule_keys: List[str],
27
+ verbose: bool = False) -> Dict[str, 'SonarQubeRule']:
28
+ """Fetches rule descriptions from SonarQube API for given rule keys"""
29
+ if not rule_keys:
30
+ return {}
31
+
32
+ try:
33
+ # SonarQube API: /api/rules/show?key=rule_key
34
+ rules = {}
35
+
36
+ for rule_key in rule_keys:
37
+
38
+ log(verbose, f" 📋 Fetching rule description: {rule_key}")
39
+
40
+ rule_url = f"{base_url}/api/rules/show?key={rule_key}"
41
+ response = fetch("rule description...", rule_url, token, verbose)
42
+
43
+ if 'rule' in response:
44
+ rules[rule_key] = SonarQubeRule.from_dict(response['rule'])
45
+
46
+ log(verbose, f" ✅ Fetched {len(rules)} rule descriptions")
47
+ return rules
48
+
49
+ except requests.RequestException as e:
50
+ print(f"ERROR: Error fetching rules: {e}")
51
+ return {}
52
+
53
+ # Function to fetch code snippet for a specific issue or hotspot
54
+ def get_code_snippet(base_url: str, token: str, component: str, line: int) -> str:
55
+ """Fetches code snippet for a specific issue or hotspotfrom SonarQube API"""
56
+ try:
57
+ # Calculate line range (line ± context_lines)
58
+ from_line = max(1, line - 3)
59
+ to_line = line + 3
60
+
61
+ sources_url = f"{base_url}/api/sources/show?key={component}&from={from_line}&to={to_line}"
62
+
63
+ try:
64
+ response = fetch("code snippets...", sources_url, token, False)
65
+ except requests.RequestException as api_error:
66
+ print(f"ERROR: API call failed: {api_error}")
67
+ return ""
68
+
69
+ sources = response.get("sources", [])
70
+
71
+ if not sources:
72
+ print(f"ERROR: No sources found for {component}:{line}")
73
+ print(f"ERROR: Full API response: {response}")
74
+ return ""
75
+
76
+ # Format the code snippet with line numbers
77
+ snippet_lines = []
78
+ for source in sources:
79
+ line_num = source[0]
80
+ code = source[1]
81
+ # Mark the problematic line with >>>
82
+ marker = ">>> " if line_num == line else " "
83
+ snippet_lines.append(f"{marker}{line_num:3d}: {code}")
84
+
85
+ result = "\n".join(snippet_lines)
86
+ return result
87
+ except requests.RequestException as e:
88
+ print(f"ERROR: Error fetching code snippet for {component}:{line}: {e}") # Debug log
89
+ traceback.print_exc()
90
+ return ""
91
+
92
+ def fetch_all_hotspots(base_url: str, token: str, project_key: str) -> Dict:
93
+ """Fetches all security hotspots from a project"""
94
+ all_hotspots = []
95
+ page = 1
96
+ page_size = 500 # Maximum page size
97
+
98
+ while True:
99
+ hotspots_url = f"{base_url}/api/hotspots/search?projectKey={project_key}&ps={page_size}&p={page}" # pylint: disable=line-too-long
100
+
101
+ try:
102
+ page_data = fetch("hotspots...", hotspots_url, token, False)
103
+ page_hotspots = page_data.get("hotspots", [])
104
+
105
+ all_hotspots.extend(page_hotspots)
106
+
107
+ # Check if we have more pages
108
+ paging = page_data.get("paging", {})
109
+ total = paging.get("total", 0)
110
+ current_total = len(all_hotspots)
111
+
112
+ if current_total >= total:
113
+ break
114
+
115
+ page += 1
116
+
117
+ except requests.RequestException as e:
118
+ print(f"ERROR: Failed to fetch hotspots page {page}: {e}")
119
+ break
120
+
121
+ # Return in the same format as the original API response
122
+ return {
123
+ "hotspots": all_hotspots,
124
+ "paging": {
125
+ "pageIndex": 1,
126
+ "pageSize": len(all_hotspots),
127
+ "total": len(all_hotspots)
128
+ }
129
+ }
130
+
131
+ # Main function to get all report data
132
+ def get_report_data(base_url: str, token: str,
133
+ project_key: str, verbose: bool = False) -> ReportData:
134
+ """Main function that fetches all necessary data from SonarQube API"""
135
+ metric_keys = [
136
+ "software_quality_security_rating",
137
+ "software_quality_reliability_rating",
138
+ "software_quality_maintainability_rating",
139
+ "lines_to_cover",
140
+ "software_quality_maintainability_issues",
141
+ "software_quality_security_issues",
142
+ "software_quality_reliability_issues",
143
+ "accepted_issues",
144
+ "coverage",
145
+ "duplicated_lines_density",
146
+ "lines",
147
+ "security_hotspots",
148
+ ]
149
+ metrics_param = ",".join(metric_keys)
150
+
151
+ component_url = f"{base_url}/api/components/show?component={project_key}"
152
+ issues_url = f"{base_url}/api/issues/search?componentKeys={project_key}&ps=500"
153
+ measures_url = f"{base_url}/api/measures/component?component={project_key}&metricKeys={metrics_param}" # pylint: disable=line-too-long
154
+ settings_url = f"{base_url}/api/settings/values?keys=sonar.multi-quality-mode.enabled"
155
+
156
+ component_data = fetch("project component data...", component_url, token, verbose)
157
+ issues_data = fetch("issues data...", issues_url, token, verbose)
158
+ measures_data = fetch("measures data...", measures_url, token, verbose)
159
+ settings_data = fetch("SonarQube settings...", settings_url, token, verbose)
160
+ hotspots_data = fetch_all_hotspots(base_url, token, project_key)
161
+
162
+ project = SonarQubeProject.from_dict(component_data)
163
+
164
+ # Process issues and fetch code snippets
165
+ issues: List[SonarQubeIssue] = []
166
+ total_issues = len(issues_data.get("issues", []))
167
+
168
+ log(verbose, f"Processing {total_issues} issues and fetching code snippets...")
169
+
170
+ for i, issue_data in enumerate(issues_data.get("issues", [])):
171
+ issue = SonarQubeIssue.from_dict(issue_data)
172
+
173
+ if (i + 1) % 10 == 0: # Progress every 10 issues
174
+ log(verbose, f" Processed {i + 1}/{total_issues} issues...")
175
+
176
+ # Fetch code snippet if the issue has a line number
177
+ if issue.line:
178
+
179
+ code_snippet = get_code_snippet(base_url, token, issue.component, issue.line)
180
+
181
+ if not code_snippet.strip():
182
+ code_snippet = "No code snippet available for this issue."
183
+
184
+ issue.code_snippet = code_snippet
185
+ else:
186
+ print(f"ERROR: Issue {issue.key} has no line number, skipping code snippet")
187
+
188
+ issues.append(issue)
189
+
190
+ measures: Dict[str, SonarQubeMeasure] = {
191
+ m["metric"]: SonarQubeMeasure.from_dict(m)
192
+ for m in measures_data.get("component", {}).get("measures", [])
193
+ }
194
+
195
+ # Process hotspots and fetch code snippets
196
+ hotspots: List[SonarQubeHotspot] = []
197
+ total_hotspots = len(hotspots_data.get("hotspots", []))
198
+
199
+ log(verbose, f"Processing {total_hotspots} security hotspots and fetching code snippets...")
200
+
201
+ for i, hotspot_data in enumerate(hotspots_data.get("hotspots", [])):
202
+ hotspot = SonarQubeHotspot.from_dict(hotspot_data)
203
+
204
+ if (i + 1) % 10 == 0: # Progress every 10 hotspots
205
+ log(verbose, f" Processed {i + 1}/{total_hotspots} hotspots...")
206
+
207
+ # Fetch code snippet if the hotspot has a line number
208
+ if hotspot.line:
209
+ code_snippet = get_code_snippet(base_url, token, hotspot.component, hotspot.line)
210
+
211
+ if not code_snippet.strip():
212
+ code_snippet = "No code snippet available for this hotspot."
213
+
214
+ hotspot.code_snippet = code_snippet
215
+ else:
216
+ log(verbose, f"Hotspot {hotspot.key} has no line number, skipping code snippet")
217
+
218
+ hotspots.append(hotspot)
219
+
220
+ # Collect unique rule keys from issues and hotspots
221
+ rule_keys = set()
222
+ for issue in issues:
223
+ if issue.rule:
224
+ rule_keys.add(issue.rule)
225
+ for hotspot in hotspots:
226
+ if hotspot.rule_key:
227
+ rule_keys.add(hotspot.rule_key)
228
+
229
+ log(verbose, f"📋 Fetching descriptions for {len(rule_keys)} unique rules...")
230
+
231
+ # Fetch rule descriptions
232
+ rules = get_rules(base_url, token, list(rule_keys), verbose)
233
+
234
+ settings: bool = settings_data.get("sonar.multi-quality-mode.enabled",
235
+ {}).get("value", "true").lower() == "true"
236
+
237
+ log(verbose, "Data collection summary:")
238
+ log(verbose, f" • Project: {project.name}")
239
+ log(verbose, f" • Issues collected: {len(issues)}")
240
+ log(verbose, f" • Hotspots collected: {len(hotspots)}")
241
+ log(verbose, f" • Measures collected: {len(measures)}")
242
+ log(verbose, f" • MQR mode enabled: {settings}")
243
+ log(verbose, f" • Issues with code snippets: {sum(1 for i in issues if i.code_snippet and i.code_snippet.strip())}") # pylint: disable=line-too-long
244
+ log(verbose, f" • Hotspots with code snippets: {sum(1 for h in hotspots if h.code_snippet and h.code_snippet.strip())}") # pylint: disable=line-too-long
245
+ log(verbose, f" • Rules descriptions fetched: {len(rules)}")
246
+
247
+ return ReportData(
248
+ project=project,
249
+ issues=issues,
250
+ measures=measures,
251
+ hotspots=hotspots,
252
+ quality_gate={},
253
+ quality_profiles=[],
254
+ mode_setting=settings,
255
+ rules=rules
256
+ )
@@ -0,0 +1 @@
1
+ """Data models for ReflectSonar - SonarQube entity definitions"""
@@ -0,0 +1,154 @@
1
+ """
2
+ Defines data models for SonarQube entities such as issues, rules, hotspots, etc.
3
+ """
4
+ from dataclasses import dataclass, field
5
+ from typing import List, Dict, Any, Optional
6
+ from datetime import datetime
7
+
8
+ @dataclass
9
+ class SonarQubeIssue:
10
+ """Data model for a SonarQube issue"""
11
+ key: str
12
+ component: str
13
+ project: str
14
+ rule: str
15
+ severity: str
16
+ status: str
17
+ message: str
18
+ type: str
19
+ line: Optional[int] = None
20
+ effort: Optional[str] = None
21
+ author: Optional[str] = None
22
+ tags: List[str] = field(default_factory=list)
23
+ creation_date: Optional[datetime] = None
24
+ update_date: Optional[datetime] = None
25
+ impacts: List[Dict[str, Any]] = field(default_factory=list)
26
+ code_snippet: Optional[str] = None
27
+
28
+ @classmethod
29
+ def from_dict(cls, data: Dict[str, Any]) -> 'SonarQubeIssue':
30
+ """Creates a SonarQubeIssue instance from a dictionary"""
31
+ return cls(
32
+ key=data.get('key', ''),
33
+ component=data.get('component', ''),
34
+ project=data.get('project', ''),
35
+ rule=data.get('rule', ''),
36
+ severity=data.get('severity', ''),
37
+ status=data.get('status', ''),
38
+ message=data.get('message', ''),
39
+ type=data.get('type', ''),
40
+ line=data.get('line'),
41
+ effort=data.get('effort'),
42
+ author=data.get('author'),
43
+ tags=data.get('tags', []),
44
+ creation_date=data.get('creationDate')
45
+ if data.get('creationDate') else None,
46
+ update_date=data.get('updateDate')
47
+ if data.get('updateDate') else None,
48
+ impacts=data.get('impacts', [])
49
+ )
50
+
51
+ @dataclass
52
+ class SonarQubeRule:
53
+ """Data model for a SonarQube rule"""
54
+ key: str
55
+ name: str
56
+ description_sections: List[Dict[str, Any]] = field(default_factory=list)
57
+
58
+ @classmethod
59
+ def from_dict(cls, data: Dict[str, Any]) -> 'SonarQubeRule':
60
+ """Creates a SonarQubeRule instance from a dictionary"""
61
+ return cls(
62
+ key=data.get('key', ''),
63
+ name=data.get('name', ''),
64
+ description_sections=data.get('descriptionSections', [])
65
+ )
66
+
67
+ @dataclass
68
+ class SonarQubeMeasure:
69
+ """Data model for a SonarQube measure"""
70
+ metric: str
71
+ value: Any
72
+
73
+ @classmethod
74
+ def from_dict(cls, data: Dict[str, Any]) -> 'SonarQubeMeasure':
75
+ """Creates a SonarQubeMeasure instance from a dictionary"""
76
+ return cls(
77
+ metric=data.get('metric', ''),
78
+ value=data.get('value') or data.get('period', {}).get('value')
79
+ )
80
+
81
+ @dataclass
82
+ class SonarQubeProject:
83
+ """Data model for a SonarQube project"""
84
+ key: str
85
+ name: str
86
+ qualifier: str
87
+ visibility: str
88
+ last_analysis_date: Optional[datetime] = None
89
+ revision: Optional[str] = None
90
+
91
+ @classmethod
92
+ def from_dict(cls, data: Dict[str, Any]) -> 'SonarQubeProject':
93
+ """Creates a SonarQubeProject instance from a dictionary"""
94
+ component = data.get('component', {})
95
+ return cls(
96
+ key=component.get('key', ''),
97
+ name=component.get('name', ''),
98
+ qualifier=component.get('qualifier', ''),
99
+ visibility=component.get('visibility', ''),
100
+ last_analysis_date=component.get('analysisDate')
101
+ if component.get('analysisDate') else None,
102
+ revision=component.get('revision')
103
+ )
104
+
105
+ @dataclass
106
+ class SonarQubeHotspot:
107
+ """Data model for a SonarQube security hotspot"""
108
+ key: str
109
+ component: str
110
+ project: str
111
+ rule: str
112
+ status: str
113
+ message: str
114
+ line: Optional[int] = None
115
+ author: Optional[str] = None
116
+ creation_date: Optional[datetime] = None
117
+ update_date: Optional[datetime] = None
118
+ vulnerability_probability: str = "MEDIUM"
119
+ code_snippet: Optional[str] = None
120
+ security_category: Optional[str] = None
121
+ rule_key: Optional[str] = None
122
+
123
+ @classmethod
124
+ def from_dict(cls, data: Dict[str, Any]) -> 'SonarQubeHotspot':
125
+ """Creates a SonarQubeHotspot instance from a dictionary"""
126
+ return cls(
127
+ key=data.get('key', ''),
128
+ component=data.get('component', ''),
129
+ project=data.get('project', ''),
130
+ rule=data.get('rule', ''),
131
+ status=data.get('status', ''),
132
+ message=data.get('message', ''),
133
+ line=data.get('line'),
134
+ author=data.get('author'),
135
+ creation_date=data.get('creationDate')
136
+ if data.get('creationDate') else None,
137
+ update_date=data.get('updateDate')
138
+ if data.get('updateDate') else None,
139
+ vulnerability_probability=data.get('vulnerabilityProbability', 'MEDIUM'),
140
+ security_category=data.get('securityCategory'),
141
+ rule_key=data.get('ruleKey')
142
+ )
143
+
144
+ @dataclass
145
+ class ReportData:
146
+ """Data model for report data"""
147
+ project: SonarQubeProject
148
+ issues: List[SonarQubeIssue]
149
+ measures: Dict[str, SonarQubeMeasure]
150
+ hotspots: List[SonarQubeHotspot]
151
+ quality_gate: Dict[str, Any]
152
+ quality_profiles: List[Dict[str, Any]]
153
+ mode_setting: bool
154
+ rules: Dict[str, SonarQubeRule] = field(default_factory=dict)
reflectsonar/main.py ADDED
@@ -0,0 +1,101 @@
1
+ """
2
+ Main entry point for the ReflectSonar PDF report generator.
3
+ Handles command-line arguments, configuration loading,
4
+ and orchestrates the report generation process.
5
+ """
6
+ import argparse
7
+ import sys
8
+ import signal
9
+ import yaml
10
+
11
+ from .report.pdfgen import generate_pdf
12
+ from .report.utils import log, handle_exception
13
+ from .api.get_data import get_report_data
14
+
15
+ def load_config(config_path):
16
+ """Loads configuration from a YAML file"""
17
+ if not config_path:
18
+ return {}
19
+ try:
20
+ with open(config_path, 'r', encoding="utf-8") as f:
21
+ return yaml.safe_load(f) or {}
22
+ except Exception as e: # pylint: disable=broad-exception-caught
23
+ print(f"Warning: Could not load config file {config_path}: {e}")
24
+ return {}
25
+
26
+ def parse_arguments():
27
+ """Parses command-line arguments and loads configuration from a YAML file if provided"""
28
+ parser = argparse.ArgumentParser(description='Generate PDF reports from SonarQube data')
29
+ parser.add_argument('-c', '--config', default=None,
30
+ help='Path to YAML configuration file, overrides arguments if provided in the file')
31
+ parser.add_argument('-o', '--output', help='Output PDF file path')
32
+ parser.add_argument('-p', '--project', help='SonarQube project key')
33
+ parser.add_argument('-u', '--url', help='SonarQube server URL', default="http://localhost:9000")
34
+ parser.add_argument('-t', '--token', help='SonarQube authentication token')
35
+ parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose logging')
36
+
37
+ args = parser.parse_args()
38
+
39
+ # Load config file and override args if config values exist
40
+ if args.config:
41
+ config = load_config(args.config)
42
+ if 'project' in config:
43
+ args.project = config['project']
44
+ if 'token' in config:
45
+ args.token = config['token']
46
+ if 'url' in config:
47
+ args.url = config['url']
48
+ if 'output' in config:
49
+ args.output = config['output']
50
+ if 'verbose' in config:
51
+ args.verbose = config['verbose']
52
+
53
+ # Validate required fields
54
+ if not args.project:
55
+ parser.error('Project key is required (use -p or set in config file)')
56
+ if not args.token:
57
+ parser.error('SonarQube token is required (use -t or set in config file)')
58
+
59
+ return args
60
+
61
+ def handle_interrupt(signum, frame): # pylint: disable=unused-argument
62
+ """Handles keyboard interrupt (Ctrl+C)"""
63
+ print("\n")
64
+ print("🛑 Report generation interrupted by user")
65
+ print("✨ Thanks for using ReflectSonar!")
66
+ sys.exit(0)
67
+
68
+ # Main function to generate the PDF report
69
+ def main():
70
+ """Main function to generate the PDF report"""
71
+ signal.signal(signal.SIGINT, handle_interrupt)
72
+
73
+ try:
74
+ args = parse_arguments()
75
+
76
+ log(args.verbose, "🚀 Starting ReflectSonar PDF Report Generation")
77
+ log(args.verbose, f"📊 Project: {args.project}")
78
+ log(args.verbose, f"🌐 SonarQube URL: {args.url}")
79
+ log(args.verbose, f"📄 Output: {args.output or f'reflect_sonar_report_{args.project}_[timestamp].pdf'}") # pylint: disable=line-too-long
80
+
81
+ # Fetch data from SonarQube
82
+ log(args.verbose, "📡 Connecting to SonarQube and fetching data...")
83
+ print("📡 Fetching data from SonarQube... (Press Ctrl+C to cancel)")
84
+
85
+ report_data = get_report_data(args.url, args.token, args.project, verbose=args.verbose)
86
+
87
+ print("📄 Generating PDF report... (Press Ctrl+C to cancel)")
88
+
89
+ output_file = generate_pdf(report_data, args.output, args.project, verbose=args.verbose)
90
+
91
+ # Success message
92
+ print("✅ PDF report generated successfully!")
93
+ print(f"📁 Saved to: {output_file}")
94
+
95
+ return 0
96
+
97
+ except Exception as e: # pylint: disable=broad-exception-caught
98
+ return handle_exception(e, args.verbose)
99
+
100
+ if __name__ == "__main__":
101
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Report generation module for ReflectSonar - PDF creation and formatting"""