Skip to content

Commit 42b5c6f

Browse files
SCANPY-139 Parse well-known project properties form pyproject.toml (#160)
1 parent f36016f commit 42b5c6f

File tree

6 files changed

+176
-53
lines changed

6 files changed

+176
-53
lines changed

src/pysonar_scanner/configuration/cli.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,25 @@ def __parse_cli_args(cls) -> argparse.Namespace:
213213
help="Path to the pyproject.toml file. If not provided, it will look in the SONAR_PROJECT_BASE_DIR",
214214
)
215215

216+
parser.add_argument(
217+
"--sonar-project-version",
218+
"-Dsonar.projectVersion",
219+
type=str,
220+
help="Version of the project",
221+
)
222+
223+
parser.add_argument(
224+
"--sonar-project-description",
225+
"-Dsonar.projectDescription",
226+
type=str,
227+
help="Description of the project",
228+
)
229+
230+
parser.add_argument(
231+
"--sonar-python-version",
232+
"-Dsonar.python.version",
233+
type=str,
234+
help="Python version used for the project",
235+
)
236+
216237
return parser.parse_args()

src/pysonar_scanner/configuration/configuration_loader.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
from pathlib import Path
2121

2222
from pysonar_scanner.configuration.cli import CliConfigurationLoader
23+
from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader
2324
from pysonar_scanner.configuration.properties import SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key
2425
from pysonar_scanner.configuration.properties import PROPERTIES
25-
from pysonar_scanner.configuration import sonar_project_properties, pyproject_toml
26+
from pysonar_scanner.configuration import sonar_project_properties
2627

2728
from pysonar_scanner.exceptions import MissingKeyException
2829

@@ -37,17 +38,19 @@ def load() -> dict[Key, any]:
3738
# each property loader is required to return NO default values.
3839
# E.g. if no property has been set, an empty dict must be returned.
3940
# Default values should be set through the get_static_default_properties() method
40-
resolved_properties = get_static_default_properties()
4141
cli_properties = CliConfigurationLoader.load()
4242
# CLI properties have a higher priority than properties file,
4343
# but we need to resolve them first to load the properties file
4444
base_dir = Path(cli_properties.get(SONAR_PROJECT_BASE_DIR, "."))
45-
resolved_properties.update(sonar_project_properties.load(base_dir))
4645

4746
toml_path_property = cli_properties.get("toml-path", ".")
4847
toml_dir = Path(toml_path_property) if "toml-path" in cli_properties else base_dir
49-
resolved_properties.update(pyproject_toml.load(toml_dir))
48+
toml_properties = TomlConfigurationLoader.load(toml_dir)
5049

50+
resolved_properties = get_static_default_properties()
51+
resolved_properties.update(toml_properties.project_properties)
52+
resolved_properties.update(sonar_project_properties.load(base_dir))
53+
resolved_properties.update(toml_properties.sonar_properties)
5154
resolved_properties.update(cli_properties)
5255
return resolved_properties
5356

src/pysonar_scanner/configuration/properties.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@
6060
SONAR_SOURCES: Key = "sonar.sources"
6161
SONAR_EXCLUSIONS: Key = "sonar.exclusions"
6262
SONAR_TESTS: Key = "sonar.tests"
63+
SONAR_PROJECT_VERSION: Key = "sonar.projectVersion"
64+
SONAR_PROJECT_DESCRIPTION: Key = "sonar.projectDescription"
65+
SONAR_PYTHON_VERSION: Key = "sonar.python.version"
6366

6467
# pysonar scanner specific properties
6568
TOML_PATH: Key = "toml-path"
@@ -258,5 +261,20 @@ def python_name(self) -> str:
258261
default_value=None,
259262
cli_getter=lambda args: args.toml_path
260263
),
264+
Property(
265+
name=SONAR_PROJECT_VERSION,
266+
default_value=None,
267+
cli_getter=lambda args: args.sonar_project_version
268+
),
269+
Property(
270+
name=SONAR_PROJECT_DESCRIPTION,
271+
default_value=None,
272+
cli_getter=lambda args: args.sonar_project_description
273+
),
274+
Property(
275+
name=SONAR_PYTHON_VERSION,
276+
default_value=None,
277+
cli_getter=lambda args: args.sonar_python_version
278+
),
261279
]
262280
# fmt: on

src/pysonar_scanner/configuration/pyproject_toml.py

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,35 +25,79 @@
2525
from pysonar_scanner.configuration import properties
2626

2727

28-
def flatten_config_dict(config: dict[str, any], prefix: str) -> dict[str, any]:
29-
"""Flatten nested dictionaries into dot notation keys"""
30-
result = {}
31-
for key, value in config.items():
32-
if isinstance(value, dict):
33-
result.update(flatten_config_dict(value, f"{prefix}{key}."))
34-
else:
35-
property_name = f"{prefix}{key}"
36-
result[property_name] = value
37-
return result
28+
class TomlProperties:
29+
sonar_properties: Dict[str, str]
30+
project_properties: Dict[str, str]
3831

32+
def __init__(self, sonar_properties: Dict[str, str], project_properties: Dict[str, str]):
33+
self.sonar_properties = sonar_properties
34+
self.project_properties = project_properties
3935

40-
def load(base_dir: Path) -> Dict[str, str]:
41-
filepath = base_dir / "pyproject.toml"
42-
if not os.path.isfile(filepath):
43-
return {}
4436

45-
try:
46-
with open(filepath, "rb") as f:
47-
toml_dict = tomli.load(f)
37+
class TomlConfigurationLoader:
38+
@staticmethod
39+
def load(base_dir: Path) -> TomlProperties:
40+
filepath = base_dir / "pyproject.toml"
41+
if not os.path.isfile(filepath):
42+
return TomlProperties({}, {})
43+
44+
try:
45+
with open(filepath, "rb") as f:
46+
toml_dict = tomli.load(f)
47+
# Look for configuration in the tool.sonar section
48+
sonar_properties = TomlConfigurationLoader.__read_sonar_properties(toml_dict)
49+
# Look for general project configuration
50+
project_properties = TomlConfigurationLoader.__read_project_properties(toml_dict)
51+
return TomlProperties(sonar_properties, project_properties)
52+
except Exception:
53+
# If there's any error parsing the TOML file, return empty TomlProperties
54+
# SCANPY-135: We should log the pyproject.toml parsing error
55+
return TomlProperties({}, {})
4856

49-
# Look for configuration in the tool.sonar section
57+
@staticmethod
58+
def __read_sonar_properties(toml_dict) -> Dict[str, str]:
5059
if "tool" in toml_dict and "sonar" in toml_dict["tool"]:
5160
sonar_config = toml_dict["tool"]["sonar"]
5261
python_to_java_names = {prop.python_name(): prop.name for prop in properties.PROPERTIES}
53-
flattened_sonar_config = flatten_config_dict(sonar_config, prefix="sonar.")
62+
flattened_sonar_config = TomlConfigurationLoader.__flatten_config_dict(sonar_config, prefix="sonar.")
5463
return {python_to_java_names.get(key, key): value for key, value in flattened_sonar_config.items()}
5564
return {}
56-
except Exception:
57-
# If there's any error parsing the TOML file, return empty dict
58-
# SCANPY-135: We should log the pyproject.toml parsing error
59-
return {}
65+
66+
@staticmethod
67+
def __read_project_properties(toml_dict) -> Dict[str, str]:
68+
# Extract project metadata
69+
project_properties = {}
70+
if "project" in toml_dict:
71+
project_data = toml_dict["project"]
72+
# Known pyproject.toml project keys and associated Sonar property names
73+
property_mapping = {
74+
"name": properties.SONAR_PROJECT_NAME,
75+
"description": properties.SONAR_PROJECT_DESCRIPTION,
76+
"version": properties.SONAR_PROJECT_VERSION,
77+
"requires-python": properties.SONAR_PYTHON_VERSION,
78+
}
79+
80+
for toml_key, sonar_property in property_mapping.items():
81+
if toml_key in project_data:
82+
string_property = TomlConfigurationLoader.__convert_arrays_to_string(project_data[toml_key])
83+
project_properties[sonar_property] = string_property
84+
return project_properties
85+
86+
@staticmethod
87+
def __flatten_config_dict(config: dict[str, any], prefix: str) -> dict[str, any]:
88+
"""Flatten nested dictionaries into dot notation keys"""
89+
result = {}
90+
for key, value in config.items():
91+
if isinstance(value, dict):
92+
result.update(TomlConfigurationLoader.__flatten_config_dict(value, f"{prefix}{key}."))
93+
else:
94+
property_name = f"{prefix}{key}"
95+
result[property_name] = value
96+
return result
97+
98+
@staticmethod
99+
def __convert_arrays_to_string(property) -> str:
100+
if isinstance(property, list):
101+
return ",".join(str(item) for item in property)
102+
else:
103+
return property

tests/test_configuration_loader.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
SONAR_USER_HOME,
4343
SONAR_VERBOSE,
4444
TOML_PATH,
45+
SONAR_PROJECT_DESCRIPTION,
46+
SONAR_PYTHON_VERSION,
4547
)
4648
from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader, SONAR_PROJECT_BASE_DIR
4749
from pysonar_scanner.exceptions import MissingKeyException
@@ -292,6 +294,10 @@ def test_properties_and_toml_priority(self):
292294
"pyproject.toml",
293295
contents=(
294296
"""
297+
[project]
298+
name = "My Overridden Project Name"
299+
description = "My Project Description"
300+
requires-python = ["3.6", "3.7", "3.8"]
295301
[tool.sonar]
296302
projectKey = "toml-project-key"
297303
project-name = "TOML Project"
@@ -303,11 +309,17 @@ def test_properties_and_toml_priority(self):
303309

304310
configuration = ConfigurationLoader.load()
305311

306-
# TOML values have priority over sonar-project.properties
312+
# Generic pyproject.toml properties are retrieved when no other source is available
313+
self.assertEqual(configuration[SONAR_PROJECT_DESCRIPTION], "My Project Description")
314+
self.assertEqual(configuration[SONAR_PYTHON_VERSION], "3.6,3.7,3.8")
315+
316+
# sonar-project.properties values have priority over generic properties from pyproject.toml
307317
self.assertEqual(configuration[SONAR_PROJECT_NAME], "TOML Project")
318+
319+
# Sonar pyproject.toml values have priority over sonar-project.properties
308320
self.assertEqual(configuration[SONAR_SOURCES], "src/toml")
309-
self.assertEqual(configuration[SONAR_TESTS], "test/properties")
310321
self.assertEqual(configuration[SONAR_EXCLUSIONS], "toml-exclusions/**/*")
311322

312323
# CLI args still have highest priority
313324
self.assertEqual(configuration[SONAR_PROJECT_KEY], "ProjectKeyFromCLI")
325+
self.assertEqual(configuration[SONAR_TESTS], "test/properties")

tests/test_pyproject_toml.py

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from pathlib import Path
2121

2222
from pyfakefs.fake_filesystem_unittest import TestCase
23-
from pysonar_scanner.configuration import pyproject_toml
23+
from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader
2424

2525

2626
class TestTomlFile(TestCase):
@@ -38,12 +38,14 @@ def test_load_toml_file_with_sonarqube_config(self):
3838
exclusions = "**/generated/**/*,**/deprecated/**/*,**/testdata/**/*"
3939
""",
4040
)
41-
properties = pyproject_toml.load(Path("."))
41+
properties = TomlConfigurationLoader.load(Path("."))
4242

43-
self.assertEqual(properties.get("sonar.projectKey"), "my-project")
44-
self.assertEqual(properties.get("sonar.projectName"), "My Project")
45-
self.assertEqual(properties.get("sonar.sources"), "src")
46-
self.assertEqual(properties.get("sonar.exclusions"), "**/generated/**/*,**/deprecated/**/*,**/testdata/**/*")
43+
self.assertEqual(properties.sonar_properties.get("sonar.projectKey"), "my-project")
44+
self.assertEqual(properties.sonar_properties.get("sonar.projectName"), "My Project")
45+
self.assertEqual(properties.sonar_properties.get("sonar.sources"), "src")
46+
self.assertEqual(
47+
properties.sonar_properties.get("sonar.exclusions"), "**/generated/**/*,**/deprecated/**/*,**/testdata/**/*"
48+
)
4749

4850
def test_load_toml_file_kebab_case(self):
4951
self.fs.create_file(
@@ -54,10 +56,10 @@ def test_load_toml_file_kebab_case(self):
5456
project-name = "My Project"
5557
""",
5658
)
57-
properties = pyproject_toml.load(Path("."))
59+
properties = TomlConfigurationLoader.load(Path("."))
5860

59-
self.assertEqual(properties.get("sonar.projectKey"), "my-project")
60-
self.assertEqual(properties.get("sonar.projectName"), "My Project")
61+
self.assertEqual(properties.sonar_properties.get("sonar.projectKey"), "my-project")
62+
self.assertEqual(properties.sonar_properties.get("sonar.projectName"), "My Project")
6163

6264
def test_load_toml_file_without_sonar_section(self):
6365
self.fs.create_file(
@@ -71,19 +73,19 @@ def test_load_toml_file_without_sonar_section(self):
7173
profile = "black"
7274
""",
7375
)
74-
properties = pyproject_toml.load(Path("."))
76+
properties = TomlConfigurationLoader.load(Path("."))
7577

76-
self.assertEqual(len(properties), 0)
78+
self.assertEqual(len(properties.sonar_properties), 0)
7779

7880
def test_load_missing_file(self):
79-
properties = pyproject_toml.load(Path("."))
80-
self.assertEqual(len(properties), 0)
81+
properties = TomlConfigurationLoader.load(Path("."))
82+
self.assertEqual(len(properties.sonar_properties), 0)
8183

8284
def test_load_empty_file(self):
8385
self.fs.create_file("pyproject.toml", contents="")
84-
properties = pyproject_toml.load(Path("."))
86+
properties = TomlConfigurationLoader.load(Path("."))
8587

86-
self.assertEqual(len(properties), 0)
88+
self.assertEqual(len(properties.sonar_properties), 0)
8789

8890
def test_load_malformed_toml_file(self):
8991
self.fs.create_file(
@@ -93,9 +95,9 @@ def test_load_malformed_toml_file(self):
9395
sonar.projectKey = "my-project"
9496
""",
9597
)
96-
properties = pyproject_toml.load(Path("."))
98+
properties = TomlConfigurationLoader.load(Path("."))
9799

98-
self.assertEqual(len(properties), 0)
100+
self.assertEqual(len(properties.sonar_properties), 0)
99101

100102
def test_load_toml_with_nested_values(self):
101103
self.fs.create_file(
@@ -109,11 +111,11 @@ def test_load_toml_with_nested_values(self):
109111
coverage.reportPaths = "coverage.xml"
110112
""",
111113
)
112-
properties = pyproject_toml.load(Path("."))
114+
properties = TomlConfigurationLoader.load(Path("."))
113115

114-
self.assertEqual(properties.get("sonar.projectKey"), "my-project")
115-
self.assertEqual(properties.get("sonar.python.version"), "3.9,3.10,3.11,3.12,3.13")
116-
self.assertEqual(properties.get("sonar.python.coverage.reportPaths"), "coverage.xml")
116+
self.assertEqual(properties.sonar_properties.get("sonar.projectKey"), "my-project")
117+
self.assertEqual(properties.sonar_properties.get("sonar.python.version"), "3.9,3.10,3.11,3.12,3.13")
118+
self.assertEqual(properties.sonar_properties.get("sonar.python.coverage.reportPaths"), "coverage.xml")
117119

118120
def test_load_toml_file_from_custom_dir(self):
119121
self.fs.create_dir("custom/path")
@@ -125,7 +127,30 @@ def test_load_toml_file_from_custom_dir(self):
125127
projectName = "Custom Path Project"
126128
""",
127129
)
128-
properties = pyproject_toml.load(Path("custom/path"))
130+
properties = TomlConfigurationLoader.load(Path("custom/path"))
131+
132+
self.assertEqual(properties.sonar_properties.get("sonar.projectKey"), "custom-path-project")
133+
self.assertEqual(properties.sonar_properties.get("sonar.projectName"), "Custom Path Project")
134+
135+
def test_load_toml_file_project_content(self):
136+
self.fs.create_file(
137+
"pyproject.toml",
138+
contents=(
139+
"""
140+
[project]
141+
name = "My Overridden Project Name"
142+
description = "My Project Description"
143+
requires-python = ["3.6", "3.7", "3.8"]
144+
[tool.sonar]
145+
project-key = "my-project"
146+
project-name = "My Project"
147+
"""
148+
),
149+
)
150+
properties = TomlConfigurationLoader.load(Path("."))
129151

130-
self.assertEqual(properties.get("sonar.projectKey"), "custom-path-project")
131-
self.assertEqual(properties.get("sonar.projectName"), "Custom Path Project")
152+
self.assertEqual(properties.sonar_properties.get("sonar.projectKey"), "my-project")
153+
self.assertEqual(properties.sonar_properties.get("sonar.projectName"), "My Project")
154+
self.assertEqual(properties.project_properties.get("sonar.projectName"), "My Overridden Project Name")
155+
self.assertEqual(properties.project_properties.get("sonar.projectDescription"), "My Project Description")
156+
self.assertEqual(properties.project_properties.get("sonar.python.version"), "3.6,3.7,3.8")

0 commit comments

Comments
 (0)