-
Notifications
You must be signed in to change notification settings - Fork 146
[issue-558] add visualization feature #562
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,6 +18,7 @@ | |
|
||
import click | ||
|
||
from spdx.graph_generation import export_graph_from_document | ||
from spdx.model.document import Document | ||
from spdx.parser.error import SPDXParsingError | ||
from spdx.parser.parse_anything import parse_file | ||
|
@@ -32,7 +33,8 @@ | |
@click.option( | ||
"--outfile", | ||
"-o", | ||
help="The file to write the converted document to (write a dash for output to stdout or omit for no conversion).", | ||
help="The file to write the converted document to (write a dash for output to stdout or omit for no conversion). " | ||
"If you add the option --graph to the command the generated graph will be written to this file.", | ||
) | ||
@click.option( | ||
"--version", | ||
|
@@ -41,7 +43,15 @@ | |
default=None, | ||
) | ||
@click.option("--novalidation", is_flag=True, help="Don't validate the provided document.") | ||
def main(infile: str, outfile: str, version: str, novalidation: bool): | ||
@click.option( | ||
"--graph", | ||
is_flag=True, | ||
default=False, | ||
help="Generate a relationship graph from the input file. " | ||
"The generated graph is saved to the file specified with --outfile. " | ||
"Note: You need to install the optional dependencies 'networkx' and 'pygraphviz' for this feature.", | ||
) | ||
def main(infile: str, outfile: str, version: str, novalidation: bool, graph: bool): | ||
""" | ||
CLI-tool for validating SPDX documents and converting between RDF, TAG-VALUE, JSON, YAML and XML formats. | ||
Formats are determined by the file endings. | ||
|
@@ -50,9 +60,6 @@ def main(infile: str, outfile: str, version: str, novalidation: bool): | |
try: | ||
document: Document = parse_file(infile) | ||
|
||
if outfile == "-": | ||
tagvalue_writer.write_document(document, sys.stdout) | ||
|
||
if not novalidation: | ||
if not version: | ||
version = document.creation_info.spdx_version | ||
|
@@ -72,7 +79,20 @@ def main(infile: str, outfile: str, version: str, novalidation: bool): | |
else: | ||
logging.info("The document is valid.") | ||
|
||
if outfile and outfile != "-": | ||
if outfile == "-": | ||
tagvalue_writer.write_document(document, sys.stdout) | ||
|
||
elif graph: | ||
try: | ||
export_graph_from_document(document, outfile) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe insert a catch that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sounds like a nice to have. Would merge it now |
||
except ImportError: | ||
logging.error( | ||
"To be able to draw a relationship graph of the parsed document " | ||
"you need to install 'networkx' and 'pygraphviz'. Run 'pip install \".[graph_generation]\"'." | ||
) | ||
sys.exit(1) | ||
|
||
elif outfile: | ||
write_file(document, outfile, validate=False) | ||
|
||
except NotImplementedError as err: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
# SPDX-FileCopyrightText: 2023 spdx contributors | ||
# | ||
# SPDX-License-Identifier: Apache-2.0 | ||
from typing import Dict, List, Union | ||
|
||
from spdx.model.file import File | ||
from spdx.model.package import Package | ||
from spdx.model.snippet import Snippet | ||
|
||
try: | ||
from networkx import DiGraph | ||
except ImportError: | ||
DiGraph = None | ||
from spdx.document_utils import get_contained_spdx_elements | ||
from spdx.model.document import Document | ||
from spdx.model.relationship import Relationship | ||
|
||
|
||
def export_graph_from_document(document: Document, file_name: str) -> None: | ||
from networkx.drawing import nx_agraph | ||
|
||
graph = generate_relationship_graph_from_spdx(document) | ||
_color_nodes(graph) | ||
attributes_graph = nx_agraph.to_agraph(graph) # convert to a pygraphviz graph | ||
attributes_graph.draw(file_name, prog="dot") | ||
|
||
|
||
def generate_relationship_graph_from_spdx(document: Document) -> DiGraph: | ||
from networkx import DiGraph | ||
|
||
graph = DiGraph() | ||
graph.add_node(document.creation_info.spdx_id, element=document.creation_info) | ||
|
||
contained_elements: Dict[str, Union[Package, File, Snippet]] = get_contained_spdx_elements(document) | ||
contained_element_nodes = [(spdx_id, {"element": element}) for spdx_id, element in contained_elements.items()] | ||
graph.add_nodes_from(contained_element_nodes) | ||
|
||
relationships_by_spdx_id: Dict[str, List[Relationship]] = dict() | ||
for relationship in document.relationships: | ||
relationships_by_spdx_id.setdefault(relationship.spdx_element_id, []).append(relationship) | ||
|
||
for spdx_id, relationships in relationships_by_spdx_id.items(): | ||
if spdx_id not in graph.nodes(): | ||
# this will add any external spdx_id to the graph where we have no further information about the element, | ||
# to indicate that this node represents an element we add the attribute "element" | ||
graph.add_node(spdx_id, element=None) | ||
for relationship in relationships: | ||
relationship_node_key = relationship.spdx_element_id + "_" + relationship.relationship_type.name | ||
graph.add_node(relationship_node_key, comment=relationship.comment) | ||
graph.add_edge(relationship.spdx_element_id, relationship_node_key) | ||
# if the related spdx element is SpdxNone or SpdxNoAssertion we need a type conversion | ||
related_spdx_element_id = str(relationship.related_spdx_element_id) | ||
|
||
if related_spdx_element_id not in graph.nodes(): | ||
Comment on lines
+52
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the intended behavior of NOASSERTION/NONE Nodes? The way I understand this, there will be only a single one in the whole graph that all relationships that have NOASSERTION/NONE in them will point to. Is that the way it should be? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your interpretation for the current implementation is correct. I don't have an opinion on how it should be. In the current use case we would transform the generated graph to a tree, so I didn't worry about it. But probably it would be better (clearer, in relation to a picture) to have individual NOASSERTION nodes for each relationship. |
||
# this will add any external spdx_id to the graph where we have no further information about | ||
# the element, to indicate that this node represents an element we add the attribute "element" | ||
graph.add_node( | ||
related_spdx_element_id, | ||
element=None, | ||
) | ||
graph.add_edge(relationship_node_key, related_spdx_element_id) | ||
|
||
return graph | ||
|
||
|
||
def _color_nodes(graph: DiGraph) -> None: | ||
for node in graph.nodes(): | ||
if "_" in node: | ||
# nodes representing a RelationshipType are concatenated with the spdx_element_id, | ||
# to only see the RelationshipType when rendering the graph to a picture we add | ||
armintaenzertng marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# a label to these nodes | ||
graph.add_node(node, color="lightgreen", label=node.split("_", 1)[-1]) | ||
elif node == "SPDXRef-DOCUMENT": | ||
graph.add_node(node, color="indianred2") | ||
else: | ||
graph.add_node(node, color="lightskyblue") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
# SPDX-FileCopyrightText: 2023 spdx contributors | ||
# | ||
# SPDX-License-Identifier: Apache-2.0 | ||
from pathlib import Path | ||
from typing import List | ||
from unittest import TestCase | ||
|
||
import pytest | ||
|
||
from spdx.graph_generation import generate_relationship_graph_from_spdx | ||
from spdx.model.document import Document | ||
from spdx.model.relationship import Relationship, RelationshipType | ||
from spdx.parser.parse_anything import parse_file | ||
from tests.spdx.fixtures import document_fixture, file_fixture, package_fixture | ||
|
||
try: | ||
import networkx # noqa: F401 | ||
import pygraphviz # noqa: F401 | ||
except ImportError: | ||
pytest.skip("Skip this module as the tests need optional dependencies to run.", allow_module_level=True) | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"file_name, nodes_count, edges_count, relationship_node_keys", | ||
[ | ||
( | ||
"SPDXJSONExample-v2.3.spdx.json", | ||
22, | ||
22, | ||
["SPDXRef-Package_DYNAMIC_LINK", "SPDXRef-JenaLib_CONTAINS"], | ||
), | ||
( | ||
"SPDXJSONExample-v2.2.spdx.json", | ||
20, | ||
19, | ||
["SPDXRef-Package_DYNAMIC_LINK", "SPDXRef-JenaLib_CONTAINS"], | ||
), | ||
( | ||
"SPDXRdfExample-v2.3.spdx.rdf.xml", | ||
22, | ||
22, | ||
["SPDXRef-Package_DYNAMIC_LINK", "SPDXRef-JenaLib_CONTAINS"], | ||
), | ||
( | ||
"SPDXRdfExample-v2.2.spdx.rdf.xml", | ||
20, | ||
17, | ||
["SPDXRef-Package_DYNAMIC_LINK", "SPDXRef-JenaLib_CONTAINS"], | ||
), | ||
( | ||
"SPDXTagExample-v2.3.spdx", | ||
22, | ||
22, | ||
["SPDXRef-Package_DYNAMIC_LINK", "SPDXRef-JenaLib_CONTAINS"], | ||
), | ||
], | ||
) | ||
def test_generate_graph_from_spdx( | ||
file_name: str, | ||
nodes_count: int, | ||
edges_count: int, | ||
relationship_node_keys: List[str], | ||
) -> None: | ||
document = parse_file(str(Path(__file__).resolve().parent.parent / "spdx" / "data" / "formats" / file_name)) | ||
graph = generate_relationship_graph_from_spdx(document) | ||
|
||
assert document.creation_info.spdx_id in graph.nodes() | ||
assert graph.number_of_nodes() == nodes_count | ||
assert graph.number_of_edges() == edges_count | ||
assert "SPDXRef-DOCUMENT_DESCRIBES" in graph.nodes() | ||
for relationship_node_key in relationship_node_keys: | ||
assert relationship_node_key in graph.nodes() | ||
|
||
|
||
def test_complete_connected_graph() -> None: | ||
document = _create_minimal_document() | ||
|
||
graph = generate_relationship_graph_from_spdx(document) | ||
|
||
TestCase().assertCountEqual( | ||
graph.nodes(), | ||
[ | ||
"SPDXRef-DOCUMENT", | ||
"SPDXRef-Package-A", | ||
"SPDXRef-Package-B", | ||
"SPDXRef-File", | ||
"SPDXRef-DOCUMENT_DESCRIBES", | ||
"SPDXRef-Package-A_CONTAINS", | ||
"SPDXRef-Package-B_CONTAINS", | ||
], | ||
) | ||
TestCase().assertCountEqual( | ||
graph.edges(), | ||
[ | ||
("SPDXRef-DOCUMENT", "SPDXRef-DOCUMENT_DESCRIBES"), | ||
("SPDXRef-DOCUMENT_DESCRIBES", "SPDXRef-Package-A"), | ||
("SPDXRef-DOCUMENT_DESCRIBES", "SPDXRef-Package-B"), | ||
("SPDXRef-Package-A", "SPDXRef-Package-A_CONTAINS"), | ||
("SPDXRef-Package-A_CONTAINS", "SPDXRef-File"), | ||
("SPDXRef-Package-B", "SPDXRef-Package-B_CONTAINS"), | ||
("SPDXRef-Package-B_CONTAINS", "SPDXRef-File"), | ||
], | ||
) | ||
|
||
|
||
def test_complete_unconnected_graph() -> None: | ||
document = _create_minimal_document() | ||
document.packages += [package_fixture(spdx_id="SPDXRef-Package-C", name="Package without connection to document")] | ||
|
||
graph = generate_relationship_graph_from_spdx(document) | ||
|
||
TestCase().assertCountEqual( | ||
graph.nodes(), | ||
[ | ||
"SPDXRef-DOCUMENT", | ||
"SPDXRef-Package-A", | ||
"SPDXRef-Package-B", | ||
"SPDXRef-File", | ||
"SPDXRef-DOCUMENT_DESCRIBES", | ||
"SPDXRef-Package-A_CONTAINS", | ||
"SPDXRef-Package-B_CONTAINS", | ||
"SPDXRef-Package-C", | ||
], | ||
) | ||
TestCase().assertCountEqual( | ||
graph.edges(), | ||
[ | ||
("SPDXRef-DOCUMENT", "SPDXRef-DOCUMENT_DESCRIBES"), | ||
("SPDXRef-DOCUMENT_DESCRIBES", "SPDXRef-Package-A"), | ||
("SPDXRef-DOCUMENT_DESCRIBES", "SPDXRef-Package-B"), | ||
("SPDXRef-Package-A", "SPDXRef-Package-A_CONTAINS"), | ||
("SPDXRef-Package-A_CONTAINS", "SPDXRef-File"), | ||
("SPDXRef-Package-B", "SPDXRef-Package-B_CONTAINS"), | ||
("SPDXRef-Package-B_CONTAINS", "SPDXRef-File"), | ||
], | ||
) | ||
|
||
|
||
def _create_minimal_document() -> Document: | ||
packages = [ | ||
package_fixture(spdx_id="SPDXRef-Package-A", name="Package-A"), | ||
package_fixture(spdx_id="SPDXRef-Package-B", name="Package-B"), | ||
] | ||
files = [ | ||
file_fixture(spdx_id="SPDXRef-File", name="File"), | ||
] | ||
relationships = [ | ||
Relationship("SPDXRef-DOCUMENT", RelationshipType.DESCRIBES, "SPDXRef-Package-A"), | ||
Relationship("SPDXRef-DOCUMENT", RelationshipType.DESCRIBES, "SPDXRef-Package-B"), | ||
Relationship("SPDXRef-Package-A", RelationshipType.CONTAINS, "SPDXRef-File"), | ||
Relationship("SPDXRef-Package-B", RelationshipType.CONTAINS, "SPDXRef-File"), | ||
] | ||
document = document_fixture(packages=packages, files=files, relationships=relationships, snippets=[]) | ||
|
||
return document |
Uh oh!
There was an error while loading. Please reload this page.