Skip to content

Commit f248015

Browse files
authored
Merge pull request #15 from CycloneDX/fix/issue-14-requirements-unpinned-versions
fix: improved handling for `requirements.txt` content without pinned …
2 parents bc54bed + 7f318cb commit f248015

File tree

5 files changed

+79
-6
lines changed

5 files changed

+79
-6
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ from cyclonedx.parser.environment import EnvironmentParser
6363
parser = EnvironmentParser()
6464
```
6565

66+
#### Notes on Requirements parsing
67+
68+
CycloneDX software bill-of-materials require pinned versions of requirements. If your `requirements.txt` does not have
69+
pinned versions, warnings will be recorded and the dependencies without pinned versions will be excluded from the
70+
generated CycloneDX. CycloneDX schemas (from version 1.0+) require a component to have a version when included in a
71+
CycloneDX bill of materials (according to schema).
72+
73+
If you need to use a `requirements.txt` in your project that does not have pinned versions an acceptable workaround
74+
might be to:
75+
76+
```
77+
pip install -r requirements.txt
78+
pip freeze > requirements-frozen.txt
79+
```
80+
81+
You can then feed in the frozen requirements from `requirements-frozen.txt` _or_ use the `Environment` parser one you
82+
have `pip install`ed your dependencies.
83+
6684
### Modelling
6785

6886
You can create a BOM Model from either a Parser instance or manually using the methods avaialbel directly on the `Bom` class.

cyclonedx/parser/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,40 @@
2020
from ..model.component import Component
2121

2222

23+
class ParserWarning:
24+
_item: str
25+
_warning: str
26+
27+
def __init__(self, item: str, warning: str):
28+
self._item = item
29+
self._warning = warning
30+
31+
def get_item(self) -> str:
32+
return self._item
33+
34+
def get_warning_message(self) -> str:
35+
return self._warning
36+
37+
def __repr__(self):
38+
return '<ParserWarning item=\'{}\'>'.format(self._item)
39+
40+
2341
class BaseParser(ABC):
2442
_components: List[Component] = []
43+
_warnings: List[ParserWarning] = []
2544

2645
def __init__(self):
2746
self._components.clear()
47+
self._warnings.clear()
2848

2949
def component_count(self) -> int:
3050
return len(self._components)
3151

3252
def get_components(self) -> List[Component]:
3353
return self._components
54+
55+
def get_warnings(self) -> List[ParserWarning]:
56+
return self._warnings
57+
58+
def has_warnings(self) -> bool:
59+
return len(self._warnings) > 0

cyclonedx/parser/requirements.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import pkg_resources
2121

22-
from . import BaseParser
22+
from . import BaseParser, ParserWarning
2323
from ..model.component import Component
2424

2525

@@ -35,13 +35,22 @@ def __init__(self, requirements_content: str):
3535
Note that the below line will get the first (lowest) version specified in the Requirement and
3636
ignore the operator (it might not be ==). This is passed to the Component.
3737
38-
For example if a requirement was listed as: "PickyThing>1.6,<=1.9,!=1.8.6", we'll be interpretting this
38+
For example if a requirement was listed as: "PickyThing>1.6,<=1.9,!=1.8.6", we'll be interpreting this
3939
as if it were written "PickyThing==1.6"
4040
"""
41-
(op, version) = requirement.specs[0]
42-
self._components.append(Component(
43-
name=requirement.project_name, version=version
44-
))
41+
try:
42+
(op, version) = requirement.specs[0]
43+
self._components.append(Component(
44+
name=requirement.project_name, version=version
45+
))
46+
except IndexError:
47+
self._warnings.append(
48+
ParserWarning(
49+
item=requirement.project_name,
50+
warning='Requirement \'{}\' does not have a pinned version and cannot be included in your '
51+
'CycloneDX SBOM.'.format(requirement.project_name)
52+
)
53+
)
4554

4655

4756
class RequirementsFileParser(RequirementsParser):
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
certifi==2021.5.30 # via requests
2+
chardet>=4.0.0 # via requests
3+
idna
4+
requests
5+
urllib3

tests/test_parser_requirements.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def test_simple(self):
3333
)
3434
r.close()
3535
self.assertTrue(1, parser.component_count())
36+
self.assertFalse(parser.has_warnings())
3637

3738
def test_example_1(self):
3839
with open(os.path.join(os.path.dirname(__file__), 'fixtures/requirements-example-1.txt')) as r:
@@ -41,6 +42,7 @@ def test_example_1(self):
4142
)
4243
r.close()
4344
self.assertTrue(3, parser.component_count())
45+
self.assertFalse(parser.has_warnings())
4446

4547
def test_example_with_comments(self):
4648
with open(os.path.join(os.path.dirname(__file__), 'fixtures/requirements-with-comments.txt')) as r:
@@ -49,6 +51,7 @@ def test_example_with_comments(self):
4951
)
5052
r.close()
5153
self.assertTrue(5, parser.component_count())
54+
self.assertFalse(parser.has_warnings())
5255

5356
def test_example_multiline_with_comments(self):
5457
with open(os.path.join(os.path.dirname(__file__), 'fixtures/requirements-multilines-with-comments.txt')) as r:
@@ -57,6 +60,7 @@ def test_example_multiline_with_comments(self):
5760
)
5861
r.close()
5962
self.assertTrue(5, parser.component_count())
63+
self.assertFalse(parser.has_warnings())
6064

6165
@unittest.skip('Not yet supported')
6266
def test_example_with_hashes(self):
@@ -66,3 +70,14 @@ def test_example_with_hashes(self):
6670
)
6771
r.close()
6872
self.assertTrue(5, parser.component_count())
73+
self.assertFalse(parser.has_warnings())
74+
75+
def test_example_without_pinned_versions(self):
76+
with open(os.path.join(os.path.dirname(__file__), 'fixtures/requirements-without-pinned-versions.txt')) as r:
77+
parser = RequirementsParser(
78+
requirements_content=r.read()
79+
)
80+
r.close()
81+
self.assertTrue(2, parser.component_count())
82+
self.assertTrue(parser.has_warnings())
83+
self.assertEqual(3, len(parser.get_warnings()))

0 commit comments

Comments
 (0)