Skip to content

Commit

Permalink
fix: improved handling for requirements.txt content without pinned …
Browse files Browse the repository at this point in the history
…or declared versions

Signed-off-by: Paul Horton <phorton@sonatype.com>
  • Loading branch information
madpah committed Sep 27, 2021
1 parent bc54bed commit 7f318cb
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 6 deletions.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ from cyclonedx.parser.environment import EnvironmentParser
parser = EnvironmentParser()
```

#### Notes on Requirements parsing

CycloneDX software bill-of-materials require pinned versions of requirements. If your `requirements.txt` does not have
pinned versions, warnings will be recorded and the dependencies without pinned versions will be excluded from the
generated CycloneDX. CycloneDX schemas (from version 1.0+) require a component to have a version when included in a
CycloneDX bill of materials (according to schema).

If you need to use a `requirements.txt` in your project that does not have pinned versions an acceptable workaround
might be to:

```
pip install -r requirements.txt
pip freeze > requirements-frozen.txt
```

You can then feed in the frozen requirements from `requirements-frozen.txt` _or_ use the `Environment` parser one you
have `pip install`ed your dependencies.

### Modelling

You can create a BOM Model from either a Parser instance or manually using the methods avaialbel directly on the `Bom` class.
Expand Down
26 changes: 26 additions & 0 deletions cyclonedx/parser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,40 @@
from ..model.component import Component


class ParserWarning:
_item: str
_warning: str

def __init__(self, item: str, warning: str):
self._item = item
self._warning = warning

def get_item(self) -> str:
return self._item

def get_warning_message(self) -> str:
return self._warning

def __repr__(self):
return '<ParserWarning item=\'{}\'>'.format(self._item)


class BaseParser(ABC):
_components: List[Component] = []
_warnings: List[ParserWarning] = []

def __init__(self):
self._components.clear()
self._warnings.clear()

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

def get_components(self) -> List[Component]:
return self._components

def get_warnings(self) -> List[ParserWarning]:
return self._warnings

def has_warnings(self) -> bool:
return len(self._warnings) > 0
21 changes: 15 additions & 6 deletions cyclonedx/parser/requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import pkg_resources

from . import BaseParser
from . import BaseParser, ParserWarning
from ..model.component import Component


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


class RequirementsFileParser(RequirementsParser):
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/requirements-without-pinned-versions.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
certifi==2021.5.30 # via requests
chardet>=4.0.0 # via requests
idna
requests
urllib3
15 changes: 15 additions & 0 deletions tests/test_parser_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def test_simple(self):
)
r.close()
self.assertTrue(1, parser.component_count())
self.assertFalse(parser.has_warnings())

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

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

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

@unittest.skip('Not yet supported')
def test_example_with_hashes(self):
Expand All @@ -66,3 +70,14 @@ def test_example_with_hashes(self):
)
r.close()
self.assertTrue(5, parser.component_count())
self.assertFalse(parser.has_warnings())

def test_example_without_pinned_versions(self):
with open(os.path.join(os.path.dirname(__file__), 'fixtures/requirements-without-pinned-versions.txt')) as r:
parser = RequirementsParser(
requirements_content=r.read()
)
r.close()
self.assertTrue(2, parser.component_count())
self.assertTrue(parser.has_warnings())
self.assertEqual(3, len(parser.get_warnings()))

0 comments on commit 7f318cb

Please sign in to comment.