diff --git a/src/fosslight_source/run_manifest_extractor.py b/src/fosslight_source/run_manifest_extractor.py index 27d70f9..962d095 100644 --- a/src/fosslight_source/run_manifest_extractor.py +++ b/src/fosslight_source/run_manifest_extractor.py @@ -178,16 +178,28 @@ def get_licenses_from_pyproject_toml(file_path: str) -> list[str]: data = None if isinstance(data, dict): - project_tbl = data.get('project') or {} - license_value = project_tbl.get('license') - if isinstance(license_value, str) and license_value.strip(): - return [license_value.strip()] - if isinstance(license_value, dict): - text_value = license_value.get('text') - if isinstance(text_value, str) and text_value.strip(): - return [text_value.strip()] - if license_value.get('file'): - return [] + project_tbl = data.get('project') + if isinstance(project_tbl, dict) and 'license' in project_tbl: + license_value = project_tbl.get('license') + if isinstance(license_value, str) and license_value.strip(): + return [license_value.strip()] + if isinstance(license_value, dict): + text_value = license_value.get('text') + if isinstance(text_value, str) and text_value.strip(): + return [text_value.strip()] + if license_value.get('file'): + return [] + return [] + + tool_tbl = data.get('tool') + if isinstance(tool_tbl, dict): + poetry_tbl = tool_tbl.get('poetry') + if isinstance(poetry_tbl, dict): + poetry_license = poetry_tbl.get('license') + if isinstance(poetry_license, str) and poetry_license.strip(): + return [poetry_license.strip()] + # Structured parse succeeded; do not re-read/regex the same file. + return [] except Exception as ex: logger.info(f"Failed to parse pyproject.toml via toml parser for {file_path}: {ex}") @@ -195,24 +207,40 @@ def get_licenses_from_pyproject_toml(file_path: str) -> list[str]: with open(file_path, 'r', encoding='utf-8') as f: content = f.read() project_match = re.search(r'^\s*\[project\]\s*(.*?)(?=^\s*\[|\Z)', content, flags=re.MULTILINE | re.DOTALL) - if not project_match: - return [] - block = project_match.group(1) - m = re.search(r'^\s*license\s*=\s*(?P"""|\'\'\'|"|\')(?P.*?)(?P=q)', block, - flags=re.MULTILINE | re.DOTALL) - if m: - val = m.group('val').strip() - if val: - return [val] - m2 = re.search(r'^\s*license\s*=\s*\{[^}]*?\btext\s*=\s*(?P"""|\'\'\'|"|\')(?P.*?)(?P=q)', - block, flags=re.MULTILINE | re.DOTALL) - if m2: - val = m2.group('val').strip() - if val: - return [val] - m3 = re.search(r'^\s*license\s*=\s*\{[^}]*?\bfile\s*=', block, flags=re.MULTILINE | re.DOTALL) - if m3: - return [] + if project_match: + block = project_match.group(1) + project_license_declared = re.search(r'^\s*license\s*=', block, flags=re.MULTILINE) + if project_license_declared: + m = re.search(r'^\s*license\s*=\s*(?P"""|\'\'\'|"|\')(?P.*?)(?P=q)', block, + flags=re.MULTILINE | re.DOTALL) + if m: + val = m.group('val').strip() + if val: + return [val] + m2 = re.search(r'^\s*license\s*=\s*\{[^}]*?\btext\s*=\s*(?P"""|\'\'\'|"|\')(?P.*?)(?P=q)', + block, flags=re.MULTILINE | re.DOTALL) + if m2: + val = m2.group('val').strip() + if val: + return [val] + return [] + + poetry_match = re.search( + r'^\s*\[tool\.poetry\]\s*(.*?)(?=^\s*\[|\Z)', + content, + flags=re.MULTILINE | re.DOTALL, + ) + if poetry_match: + poetry_block = poetry_match.group(1) + poetry_license = re.search( + r'^\s*license\s*=\s*(?P"""|\'\'\'|"|\')(?P.*?)(?P=q)', + poetry_block, + flags=re.MULTILINE | re.DOTALL, + ) + if poetry_license: + val = poetry_license.group('val').strip() + if val: + return [val] except Exception as ex: logger.info(f"Failed to parse pyproject.toml {file_path}: {ex}") return [] diff --git a/tests/test_manifest_pyproject.py b/tests/test_manifest_pyproject.py new file mode 100644 index 0000000..b756734 --- /dev/null +++ b/tests/test_manifest_pyproject.py @@ -0,0 +1,51 @@ +# Copyright (c) 2026 LG Electronics Inc. +# SPDX-License-Identifier: Apache-2.0 +"""Tests for pyproject.toml manifest license extraction.""" + +from fosslight_source.run_manifest_extractor import get_licenses_from_pyproject_toml + + +def test_pep621_license_string(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text( + '[project]\nname = "demo"\nlicense = "MIT"\n', + encoding="utf-8", + ) + assert get_licenses_from_pyproject_toml(str(path)) == ["MIT"] + + +def test_pep621_license_text_table(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text( + '[project]\nname = "demo"\nlicense = {text = "MIT"}\n', + encoding="utf-8", + ) + assert get_licenses_from_pyproject_toml(str(path)) == ["MIT"] + + +def test_pep621_license_file_returns_empty(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text( + '[project]\nname = "demo"\nlicense = {file = "LICENSE"}\n', + encoding="utf-8", + ) + assert get_licenses_from_pyproject_toml(str(path)) == [] + + +def test_legacy_poetry_license(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text( + '[tool.poetry]\nname = "demo"\nlicense = "Apache-2.0"\n', + encoding="utf-8", + ) + assert get_licenses_from_pyproject_toml(str(path)) == ["Apache-2.0"] + + +def test_project_license_file_does_not_fall_back_to_poetry(tmp_path): + path = tmp_path / "pyproject.toml" + path.write_text( + '[project]\nname = "demo"\nlicense = {file = "LICENSE"}\n\n' + '[tool.poetry]\nname = "demo"\nlicense = "MIT"\n', + encoding="utf-8", + ) + assert get_licenses_from_pyproject_toml(str(path)) == []