Skip to content

Commit 9809d30

Browse files
Cleanup requirement string to avoid packvers parsing issues
Extract clean name and version from requirements instead of using dumps() output that includes hash options and other pip-specific syntax that `packvers.requirements.Requirement` cannot parse. Resolves: #243. Signed-off-by: Marcel Bochtler <[email protected]>
1 parent ff07d0e commit 9809d30

File tree

6 files changed

+644
-2
lines changed

6 files changed

+644
-2
lines changed

src/_packagedcode/pypi.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ def parse(cls, location):
534534
for metapath in path.iterdir():
535535
if not metapath.name.endswith('METADATA'):
536536
continue
537-
537+
538538
yield parse_metadata(
539539
location=metapath,
540540
datasource_id=cls.datasource_id,
@@ -907,6 +907,37 @@ def parse(cls, location):
907907
# TODO: enable nested load
908908

909909

910+
def build_pep508_requirement_string(req):
911+
"""
912+
Build a PEP 508 compliant requirement string based on https://peps.python.org/pep-0508.
913+
The format follows the PEP 508 grammar: name [extras] version_spec ; marker
914+
This excludes pip-specific options that are not part of PEP 508 like --hash or --editable.
915+
But preserves standard PEP 508 requirement components:
916+
- Package name (required)
917+
- Version specifiers (>=, ==, etc.)
918+
- Extras [extra1,extra2]
919+
- Environment markers ; python_version >= "3.8"
920+
"""
921+
if not req.name:
922+
return ""
923+
924+
parts = [req.name]
925+
926+
# Add extras: package[extra1,extra2]
927+
if req.extras:
928+
parts.append(f"[{','.join(sorted(req.extras))}]")
929+
930+
# Add version specifiers: >=1.0,<2.0
931+
if req.specifier:
932+
parts.append(req.dumps_specifier())
933+
934+
# Add environment markers: ; python_version >= "3.8"
935+
if req.marker:
936+
parts.append(f"; {req.marker}")
937+
938+
return "".join(parts)
939+
940+
910941
def get_requirements_txt_dependencies(location, include_nested=False):
911942
"""
912943
Return a two-tuple of (list of deps, mapping of extra data) list of
@@ -945,7 +976,7 @@ def get_requirements_txt_dependencies(location, include_nested=False):
945976

946977
purl = purl and purl.to_string() or None
947978

948-
requirement = req.dumps()
979+
requirement = build_pep508_requirement_string(req)
949980

950981
if location.endswith(
951982
(
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
addict==2.4.0 \
2+
--hash=sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc \
3+
--hash=sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494
4+
5+
# Package with environment marker and no hashes
6+
license-expression ; platform_system == "Windows"
7+
8+
# Package with both hash and environment marker
9+
requests==2.25.1 ; python_version >= "3.6" \
10+
--hash=sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e \
11+
--hash=sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804
12+
13+
# Package with complex specifiers and extras
14+
click[unicode]>=6.0,<7.0 ; python_version < "3.8"
15+
16+
# Package with multiple extras and environment marker
17+
flask[async,dotenv]>=1.0 ; python_version >= "3.7" and platform_machine == "x86_64"

tests/data/hash-requirements.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
addict==2.4.0 \
2+
--hash=sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc \
3+
--hash=sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494
4+
5+
requests==2.25.1 \
6+
--hash=sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e \
7+
--hash=sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804

tests/data/hash-requirements.txt-expected.json

Lines changed: 541 additions & 0 deletions
Large diffs are not rendered by default.

tests/test_cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,17 @@ def test_cli_with_pinned_requirements_file():
414414
)
415415

416416

417+
@pytest.mark.online
418+
def test_cli_with_hash_requirements():
419+
requirements_file = test_env.get_test_loc("hash-requirements.txt")
420+
expected_file = test_env.get_test_loc("hash-requirements.txt-expected.json", must_exist=False)
421+
check_requirements_resolution(
422+
requirements_file=requirements_file,
423+
expected_file=expected_file,
424+
regen=REGEN_TEST_FIXTURES,
425+
)
426+
427+
417428
@pytest.mark.online
418429
def test_cli_with_setup_py_failure():
419430
setup_py_file = setup_test_env.get_test_loc("simple-setup.py")

tests/test_pypi.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
from _packagedcode import models
1313
from _packagedcode.pypi import can_process_dependent_package
14+
from _packagedcode.pypi import build_pep508_requirement_string
15+
from pip_requirements_parser import InstallRequirement, RequirementLine
16+
from packaging.requirements import Requirement
1417

1518

1619
def test_can_process_dependent_package():
@@ -99,3 +102,35 @@ def test_can_not_process_dependent_package_with_any_flags_set():
99102
)
100103

101104
assert not can_process_dependent_package(dependency)
105+
106+
107+
def create_requirement(requirement_string):
108+
req_line = RequirementLine(requirement_string, 1, "test.txt")
109+
requirement = Requirement(requirement_string)
110+
return InstallRequirement(requirement, req_line)
111+
112+
113+
def test_build_pep508_requirement_string():
114+
req = create_requirement("django")
115+
result = build_pep508_requirement_string(req)
116+
assert result == "django"
117+
118+
req = create_requirement("requests>=2.25.0")
119+
result = build_pep508_requirement_string(req)
120+
assert result == "requests>=2.25.0"
121+
122+
req = create_requirement("pytest[coverage,xdist]")
123+
result = build_pep508_requirement_string(req)
124+
assert (result == "pytest[coverage,xdist]")
125+
126+
req = create_requirement('pywin32; sys_platform == "win32"')
127+
result = build_pep508_requirement_string(req)
128+
assert result == 'pywin32; sys_platform == "win32"'
129+
130+
req = create_requirement('django[admin,auth]>=3.2,<4.0; python_version >= "3.8"')
131+
result = build_pep508_requirement_string(req)
132+
assert result == 'django[admin,auth]>=3.2,<4.0; python_version >= "3.8"'
133+
134+
req = create_requirement("mypackage")
135+
result = build_pep508_requirement_string(req)
136+
assert result == "mypackage"

0 commit comments

Comments
 (0)