refscan 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- refscan/__init__.py +18 -0
- refscan/cli/cli.py +27 -0
- refscan/cli/graph.py +80 -0
- refscan/cli/scan.py +211 -0
- refscan/cli/version.py +14 -0
- refscan/grapher.py +173 -0
- refscan/lib/Finder.py +229 -0
- refscan/lib/Reference.py +37 -0
- refscan/lib/ReferenceList.py +206 -0
- refscan/lib/Violation.py +23 -0
- refscan/lib/ViolationList.py +28 -0
- refscan/lib/constants.py +8 -0
- refscan/lib/helpers.py +267 -0
- refscan/scanner.py +369 -0
- refscan/templates/graph.template.html +297 -0
- refscan-0.0.0.dist-info/LICENSE +42 -0
- refscan-0.0.0.dist-info/METADATA +382 -0
- refscan-0.0.0.dist-info/RECORD +20 -0
- refscan-0.0.0.dist-info/WHEEL +4 -0
- refscan-0.0.0.dist-info/entry_points.txt +3 -0
refscan/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from importlib.metadata import metadata, PackageNotFoundError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_package_metadata(key: str) -> str:
|
|
5
|
+
r"""
|
|
6
|
+
Returns metadata about the installed package.
|
|
7
|
+
|
|
8
|
+
References:
|
|
9
|
+
- https://docs.python.org/3/library/importlib.metadata.html#distribution-metadata
|
|
10
|
+
- https://github.com/python-poetry/poetry/issues/273#issuecomment-570999678
|
|
11
|
+
"""
|
|
12
|
+
metadata_value = ""
|
|
13
|
+
try:
|
|
14
|
+
package_metadata = metadata("refscan")
|
|
15
|
+
metadata_value = package_metadata.get(key, "")
|
|
16
|
+
except PackageNotFoundError:
|
|
17
|
+
pass
|
|
18
|
+
return metadata_value
|
refscan/cli/cli.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from refscan.cli.version import version
|
|
4
|
+
from refscan.cli.scan import scan
|
|
5
|
+
from refscan.cli.graph import graph
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
no_args_is_help=True, # treats the absence of args like the `--help` arg
|
|
9
|
+
add_completion=False, # hides the shell completion options from `--help` output
|
|
10
|
+
rich_markup_mode="markdown", # enables use of Markdown in docstrings and CLI help
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Add commands—implemented in other modules—to our Typer app.
|
|
14
|
+
# Reference: https://typer.tiangolo.com/tutorial/one-file-per-command/
|
|
15
|
+
#
|
|
16
|
+
# Note: Instead of annotating (with a decorator) a function defined in this module, we decorate a function
|
|
17
|
+
# that we import from a different module. While both approaches result in the "no-longer-bare" function
|
|
18
|
+
# being defined in this module, only the latter approach allows for the "bare" function to be defined
|
|
19
|
+
# in a different module (which I think will facilitate code maintenance).
|
|
20
|
+
# Reference: https://github.com/fastapi/typer/issues/178#issuecomment-738853400
|
|
21
|
+
#
|
|
22
|
+
app.command()(version)
|
|
23
|
+
app.command()(scan)
|
|
24
|
+
app.command()(graph)
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
app()
|
refscan/cli/graph.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from typing_extensions import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
import linkml_runtime
|
|
7
|
+
|
|
8
|
+
from refscan import grapher
|
|
9
|
+
from refscan.lib.constants import console
|
|
10
|
+
from refscan.lib.helpers import print_section_header
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def graph(
|
|
14
|
+
# Reference: https://typer.tiangolo.com/tutorial/parameter-types/path/
|
|
15
|
+
schema_file_path: Annotated[
|
|
16
|
+
Path,
|
|
17
|
+
typer.Option(
|
|
18
|
+
"--schema",
|
|
19
|
+
dir_okay=False,
|
|
20
|
+
writable=False,
|
|
21
|
+
readable=True,
|
|
22
|
+
resolve_path=True,
|
|
23
|
+
help="Filesystem path at which the YAML file representing the schema is located.",
|
|
24
|
+
),
|
|
25
|
+
],
|
|
26
|
+
graph_file_path: Annotated[
|
|
27
|
+
Optional[Path],
|
|
28
|
+
typer.Option(
|
|
29
|
+
"--graph",
|
|
30
|
+
dir_okay=False,
|
|
31
|
+
writable=True,
|
|
32
|
+
readable=False,
|
|
33
|
+
resolve_path=True,
|
|
34
|
+
help="Filesystem path at which you want **refscan** to generate the graph.",
|
|
35
|
+
),
|
|
36
|
+
] = "graph.html",
|
|
37
|
+
subject: Annotated[
|
|
38
|
+
grapher.Subject,
|
|
39
|
+
typer.Option(
|
|
40
|
+
"--subject",
|
|
41
|
+
case_sensitive=False,
|
|
42
|
+
help="Whether you want each node of the graph to represent a collection or a class.",
|
|
43
|
+
),
|
|
44
|
+
] = grapher.Subject.collection,
|
|
45
|
+
verbose: Annotated[
|
|
46
|
+
bool,
|
|
47
|
+
typer.Option(
|
|
48
|
+
"--verbose",
|
|
49
|
+
help="Show verbose output.",
|
|
50
|
+
),
|
|
51
|
+
] = False,
|
|
52
|
+
):
|
|
53
|
+
r"""
|
|
54
|
+
Generate an interactive graph of the references described by a schema.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
print_section_header(console, text="Reading schema")
|
|
58
|
+
|
|
59
|
+
# Instantiate a `linkml_runtime.SchemaView` bound to the specified schema.
|
|
60
|
+
if verbose:
|
|
61
|
+
console.print(f"Schema YAML file: {schema_file_path}")
|
|
62
|
+
schema_view = linkml_runtime.SchemaView(schema_file_path)
|
|
63
|
+
|
|
64
|
+
# Show high-level information about the schema.
|
|
65
|
+
console.print(f"Schema version: {schema_view.schema.version}")
|
|
66
|
+
|
|
67
|
+
html_result = grapher.graph(
|
|
68
|
+
schema_view=schema_view,
|
|
69
|
+
subject=subject,
|
|
70
|
+
verbose=verbose,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if verbose:
|
|
74
|
+
console.print(html_result)
|
|
75
|
+
|
|
76
|
+
with open(graph_file_path, "w") as f:
|
|
77
|
+
f.write(html_result)
|
|
78
|
+
|
|
79
|
+
console.print(f"Graph generated at: {graph_file_path}")
|
|
80
|
+
console.print()
|
refscan/cli/scan.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List, Optional, Dict
|
|
3
|
+
from typing_extensions import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
import linkml_runtime
|
|
7
|
+
|
|
8
|
+
from refscan.lib.constants import console
|
|
9
|
+
from refscan.lib.helpers import (
|
|
10
|
+
connect_to_database,
|
|
11
|
+
get_collection_name_to_class_names_map,
|
|
12
|
+
get_collection_names_from_schema,
|
|
13
|
+
get_lowercase_key,
|
|
14
|
+
print_section_header,
|
|
15
|
+
identify_references,
|
|
16
|
+
)
|
|
17
|
+
from refscan.lib.ViolationList import ViolationList
|
|
18
|
+
from refscan.scanner import scan as refscan_scan
|
|
19
|
+
from refscan import get_package_metadata
|
|
20
|
+
|
|
21
|
+
app_version = get_package_metadata("Version")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def scan(
|
|
25
|
+
# Reference: https://typer.tiangolo.com/tutorial/parameter-types/path/
|
|
26
|
+
schema_file_path: Annotated[
|
|
27
|
+
Path,
|
|
28
|
+
typer.Option(
|
|
29
|
+
"--schema",
|
|
30
|
+
dir_okay=False,
|
|
31
|
+
writable=False,
|
|
32
|
+
readable=True,
|
|
33
|
+
resolve_path=True,
|
|
34
|
+
help="Filesystem path at which the YAML file representing the schema is located.",
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
database_name: Annotated[
|
|
38
|
+
str,
|
|
39
|
+
typer.Option(
|
|
40
|
+
help="Name of the database.",
|
|
41
|
+
),
|
|
42
|
+
] = "nmdc",
|
|
43
|
+
mongo_uri: Annotated[
|
|
44
|
+
str,
|
|
45
|
+
typer.Option(
|
|
46
|
+
envvar="MONGO_URI",
|
|
47
|
+
help=(
|
|
48
|
+
"Connection string for accessing the MongoDB server. If you have Docker installed, "
|
|
49
|
+
"you can spin up a temporary MongoDB server at the default URI by running: "
|
|
50
|
+
"`$ docker run --rm --detach -p 27017:27017 mongo`"
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
] = "mongodb://localhost:27017",
|
|
54
|
+
verbose: Annotated[
|
|
55
|
+
bool,
|
|
56
|
+
typer.Option(
|
|
57
|
+
"--verbose",
|
|
58
|
+
help="Show verbose output.",
|
|
59
|
+
),
|
|
60
|
+
] = False,
|
|
61
|
+
# Reference: https://typer.tiangolo.com/tutorial/multiple-values/multiple-options/
|
|
62
|
+
skip_source_collection: Annotated[
|
|
63
|
+
Optional[List[str]],
|
|
64
|
+
typer.Option(
|
|
65
|
+
"--skip-source-collection",
|
|
66
|
+
"--skip",
|
|
67
|
+
help=(
|
|
68
|
+
"Name of collection you do not want to search for referring documents. "
|
|
69
|
+
"Option can be used multiple times."
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
] = None,
|
|
73
|
+
reference_report_file_path: Annotated[
|
|
74
|
+
Optional[Path],
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--reference-report",
|
|
77
|
+
dir_okay=False,
|
|
78
|
+
writable=True,
|
|
79
|
+
readable=False,
|
|
80
|
+
resolve_path=True,
|
|
81
|
+
help="Filesystem path at which you want the program to generate its reference report.",
|
|
82
|
+
),
|
|
83
|
+
] = "references.tsv",
|
|
84
|
+
violation_report_file_path: Annotated[
|
|
85
|
+
Optional[Path],
|
|
86
|
+
typer.Option(
|
|
87
|
+
"--violation-report",
|
|
88
|
+
dir_okay=False,
|
|
89
|
+
writable=True,
|
|
90
|
+
readable=False,
|
|
91
|
+
resolve_path=True,
|
|
92
|
+
help="Filesystem path at which you want the program to generate its violation report.",
|
|
93
|
+
),
|
|
94
|
+
] = "violations.tsv",
|
|
95
|
+
user_wants_to_skip_scan: Annotated[
|
|
96
|
+
bool,
|
|
97
|
+
typer.Option(
|
|
98
|
+
"--no-scan",
|
|
99
|
+
help="Generate a reference report, but do not scan the database for violations.",
|
|
100
|
+
),
|
|
101
|
+
] = False,
|
|
102
|
+
user_wants_to_locate_misplaced_documents: Annotated[
|
|
103
|
+
bool,
|
|
104
|
+
typer.Option(
|
|
105
|
+
"--locate-misplaced-documents",
|
|
106
|
+
help=(
|
|
107
|
+
"For each referenced document not found in any of the collections the schema _allows_, "
|
|
108
|
+
"also search for it in all _other_ collections."
|
|
109
|
+
),
|
|
110
|
+
),
|
|
111
|
+
] = False,
|
|
112
|
+
):
|
|
113
|
+
r"""
|
|
114
|
+
Scan the NMDC MongoDB database for referential integrity violations.
|
|
115
|
+
"""
|
|
116
|
+
# Instantiate a `linkml_runtime.SchemaView` bound to the specified schema.
|
|
117
|
+
if verbose:
|
|
118
|
+
console.print(f"Schema YAML file: {schema_file_path}")
|
|
119
|
+
schema_view = linkml_runtime.SchemaView(schema_file_path)
|
|
120
|
+
|
|
121
|
+
# Show high-level information about the application and schema.
|
|
122
|
+
console.print(f"refscan version: {app_version}")
|
|
123
|
+
console.print(f"Schema version: {schema_view.schema.version}")
|
|
124
|
+
|
|
125
|
+
# Show a header on the console, to tell the user which stage of execution we're entering.
|
|
126
|
+
print_section_header(console, text="Identifying references")
|
|
127
|
+
|
|
128
|
+
# Make a more self-documenting alias for the CLI option that can be specified multiple times.
|
|
129
|
+
names_of_source_collections_to_skip: list[str] = [] if skip_source_collection is None else skip_source_collection
|
|
130
|
+
|
|
131
|
+
# Get a list of collection names (technically, names of `Database` slots that satisfy some criteria)
|
|
132
|
+
# from the schema; e.g. ["study_set", "biosample_set", ...]
|
|
133
|
+
collection_names = get_collection_names_from_schema(schema_view)
|
|
134
|
+
console.print(f"Collections described by schema: {len(collection_names)}")
|
|
135
|
+
|
|
136
|
+
# For each collection, determine the names of the classes whose instances can be stored in that collection.
|
|
137
|
+
collection_name_to_class_names = get_collection_name_to_class_names_map(schema_view=schema_view)
|
|
138
|
+
|
|
139
|
+
# Identify the inter-document references that the schema allows to exist.
|
|
140
|
+
references = identify_references(
|
|
141
|
+
schema_view=schema_view, collection_name_to_class_names=collection_name_to_class_names
|
|
142
|
+
)
|
|
143
|
+
console.print(f"References described by schema: {len(references)}")
|
|
144
|
+
|
|
145
|
+
num_collections_having_references = references.count_source_collections()
|
|
146
|
+
console.print(f"Collections containing references: {num_collections_having_references}")
|
|
147
|
+
console.print() # newline
|
|
148
|
+
|
|
149
|
+
# Create a reference report in TSV format.
|
|
150
|
+
console.print(f"Writing reference report: {reference_report_file_path}")
|
|
151
|
+
references.dump_to_tsv_file(file_path=reference_report_file_path)
|
|
152
|
+
|
|
153
|
+
# Display a table of references.
|
|
154
|
+
if verbose:
|
|
155
|
+
console.print(references.as_table())
|
|
156
|
+
|
|
157
|
+
# If the user opted to skip the scanning step, exit the script.
|
|
158
|
+
if user_wants_to_skip_scan:
|
|
159
|
+
console.print()
|
|
160
|
+
console.print("Skipping scan and exiting.")
|
|
161
|
+
console.print()
|
|
162
|
+
raise typer.Exit(code=0)
|
|
163
|
+
|
|
164
|
+
print_section_header(console, text="Scanning for violations")
|
|
165
|
+
|
|
166
|
+
# Connect to the MongoDB server and verify the database is accessible.
|
|
167
|
+
mongo_client = connect_to_database(mongo_uri, database_name)
|
|
168
|
+
db = mongo_client.get_database(database_name)
|
|
169
|
+
|
|
170
|
+
# Perform the scan.
|
|
171
|
+
source_collections_and_their_violations: Dict[str, ViolationList] = refscan_scan(
|
|
172
|
+
db=db,
|
|
173
|
+
schema_view=schema_view,
|
|
174
|
+
references=references,
|
|
175
|
+
names_of_source_collections_to_skip=names_of_source_collections_to_skip,
|
|
176
|
+
user_wants_to_locate_misplaced_documents=user_wants_to_locate_misplaced_documents,
|
|
177
|
+
verbose=verbose,
|
|
178
|
+
console=console,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Close the connection to the MongoDB server.
|
|
182
|
+
mongo_client.close()
|
|
183
|
+
|
|
184
|
+
print_section_header(console, text="Summarizing results")
|
|
185
|
+
|
|
186
|
+
# Make a list of all violations among all collections.
|
|
187
|
+
all_violations = ViolationList()
|
|
188
|
+
for collection_name, violations in sorted(source_collections_and_their_violations.items(), key=get_lowercase_key):
|
|
189
|
+
all_violations.extend(violations)
|
|
190
|
+
|
|
191
|
+
# Print a message indicating the number of violations in this collection.
|
|
192
|
+
num_violations = len(violations)
|
|
193
|
+
color_name = "white" if num_violations == 0 else "red"
|
|
194
|
+
console.print(
|
|
195
|
+
f"Number of violations in {collection_name}: "
|
|
196
|
+
f"[{color_name} not bold]{num_violations}[/{color_name} not bold]"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if verbose:
|
|
200
|
+
console.print(violations)
|
|
201
|
+
|
|
202
|
+
num_all_violations = len(all_violations)
|
|
203
|
+
color_name = "white" if num_all_violations == 0 else "red"
|
|
204
|
+
console.print() # newline
|
|
205
|
+
console.print(f"Total violations: " f"[{color_name}]{num_all_violations}[/{color_name}]")
|
|
206
|
+
console.print() # newline
|
|
207
|
+
|
|
208
|
+
# Create a TSV-formatted violation report that lists all violations among all collections.
|
|
209
|
+
console.print(f"Writing violation report: {violation_report_file_path}")
|
|
210
|
+
all_violations.dump_to_tsv_file(file_path=violation_report_file_path)
|
|
211
|
+
console.print() # newline
|
refscan/cli/version.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from refscan.lib.constants import console
|
|
4
|
+
from refscan import get_package_metadata
|
|
5
|
+
|
|
6
|
+
app_version = get_package_metadata("Version")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def version() -> None:
|
|
10
|
+
r"""
|
|
11
|
+
Show version number and exit.
|
|
12
|
+
"""
|
|
13
|
+
console.print(f"[white]{app_version}[/white]")
|
|
14
|
+
raise typer.Exit()
|
refscan/grapher.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Union, List, Dict, Any
|
|
3
|
+
import json
|
|
4
|
+
import base64
|
|
5
|
+
from importlib import resources
|
|
6
|
+
|
|
7
|
+
import linkml_runtime
|
|
8
|
+
|
|
9
|
+
from refscan import get_package_metadata
|
|
10
|
+
from refscan.lib.constants import console
|
|
11
|
+
from refscan.lib.helpers import (
|
|
12
|
+
get_collection_name_to_class_names_map,
|
|
13
|
+
print_section_header,
|
|
14
|
+
get_collection_names_from_schema,
|
|
15
|
+
identify_references,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Define some type aliases that we can reference later, to simplify the latter type hints.
|
|
19
|
+
# TODO: Update these type hints to not use `Any`.
|
|
20
|
+
NodeType = Dict[str, Dict[str, Any]]
|
|
21
|
+
EdgeType = Dict[str, Dict[str, Any]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Subject(str, Enum):
|
|
25
|
+
r"""The subject (i.e. focal point) of the graph."""
|
|
26
|
+
|
|
27
|
+
collection = "collection"
|
|
28
|
+
class_ = "class"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_template(resource_path: str) -> str:
|
|
32
|
+
r"""
|
|
33
|
+
Returns the contents of a template file as a string.
|
|
34
|
+
|
|
35
|
+
Note: We do this via `importlib.resources` instead of a regular `open()` so
|
|
36
|
+
that the path is accurate both when this script is run in a development
|
|
37
|
+
environment and when this script is run when installed from PyPI,
|
|
38
|
+
instead of it only being accurate in the former case.
|
|
39
|
+
Reference: https://docs.python.org/3.9/library/importlib.html#importlib.resources.files
|
|
40
|
+
"""
|
|
41
|
+
package_name = "refscan"
|
|
42
|
+
return resources.files(package_name).joinpath(resource_path).read_text(encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def encode_json_value_as_base64_str(json_value: Union[dict, list]) -> str:
|
|
46
|
+
r"""Helper function that encodes the specified JSON value as a base64 string."""
|
|
47
|
+
|
|
48
|
+
value_as_string = json.dumps(json_value)
|
|
49
|
+
string_as_bytes = value_as_string.encode("utf-8")
|
|
50
|
+
encoded_bytes = base64.b64encode(string_as_bytes)
|
|
51
|
+
encoded_bytes_as_string = encoded_bytes.decode("utf-8")
|
|
52
|
+
return encoded_bytes_as_string
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_class_abstract(class_name: str, schema_view: linkml_runtime.SchemaView) -> bool:
|
|
56
|
+
r"""
|
|
57
|
+
Returns whether the specified schema class is abstract.
|
|
58
|
+
|
|
59
|
+
Note: The `abstract` property can contain `True`, `False`, or `None`.
|
|
60
|
+
|
|
61
|
+
Reference: https://linkml.io/linkml/schemas/inheritance.html#abstract-classes-and-slots
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
return schema_view.get_class(class_name).abstract is True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def graph(
|
|
68
|
+
schema_view: linkml_runtime.SchemaView,
|
|
69
|
+
subject: Subject,
|
|
70
|
+
verbose: bool = False,
|
|
71
|
+
) -> str:
|
|
72
|
+
r"""
|
|
73
|
+
Generates an interactive graph (network diagram) of the references described by a schema.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
# Show a header on the console, to tell the user which stage of execution we're entering.
|
|
77
|
+
print_section_header(console, text="Identifying references")
|
|
78
|
+
|
|
79
|
+
# Get a list of collection names (technically, `Database` slot names) from the schema.
|
|
80
|
+
# e.g. ["study_set", ...]
|
|
81
|
+
collection_names = get_collection_names_from_schema(schema_view)
|
|
82
|
+
console.print(f"Collections described by schema: {len(collection_names)}")
|
|
83
|
+
|
|
84
|
+
# For each collection, determine the names of the classes whose instances can be stored in that collection.
|
|
85
|
+
collection_name_to_class_names = get_collection_name_to_class_names_map(schema_view=schema_view)
|
|
86
|
+
|
|
87
|
+
# Identify the inter-document references that the schema allows to exist.
|
|
88
|
+
references = identify_references(
|
|
89
|
+
schema_view=schema_view, collection_name_to_class_names=collection_name_to_class_names
|
|
90
|
+
)
|
|
91
|
+
console.print(f"References described by schema: {len(references)}")
|
|
92
|
+
|
|
93
|
+
console.print(f"References: {len(references)}")
|
|
94
|
+
if verbose:
|
|
95
|
+
console.print(references)
|
|
96
|
+
|
|
97
|
+
print_section_header(console, text="Generating graph")
|
|
98
|
+
|
|
99
|
+
# Generate a list of elements (i.e. nodes and edges) for use by the `cytoscape` JavaScript library.
|
|
100
|
+
#
|
|
101
|
+
# Note: Nodes are represented like this (here are two examples):
|
|
102
|
+
# ```
|
|
103
|
+
# { data: { id: "data_generation_set" } }
|
|
104
|
+
# { data: { id: "study_set" } }
|
|
105
|
+
# ```
|
|
106
|
+
# Edges are represented like this (here is one example):
|
|
107
|
+
# ```
|
|
108
|
+
# { data: { id: "data_generation_set__to__study_set", source: "data_generation_set", target: "study_set" } }
|
|
109
|
+
# ```
|
|
110
|
+
#
|
|
111
|
+
# Reference: https://js.cytoscape.org/#notation/elements-json
|
|
112
|
+
#
|
|
113
|
+
nodes: List[NodeType] = []
|
|
114
|
+
edges: List[EdgeType] = []
|
|
115
|
+
for r in references:
|
|
116
|
+
# Get the source and target names depending upon the subject of the graph.
|
|
117
|
+
source_name = r.source_collection_name if subject == Subject.collection else r.source_class_name
|
|
118
|
+
target_name = r.target_collection_name if subject == Subject.collection else r.target_class_name
|
|
119
|
+
|
|
120
|
+
# If the subject of the graph is "class", determine whether the source class is abstract. Same for the target class.
|
|
121
|
+
# Note: This will allow us to display abstract classes differently from concrete classes in the diagram.
|
|
122
|
+
is_source_abstract = is_class_abstract(source_name, schema_view) if subject == Subject.class_ else None
|
|
123
|
+
is_target_abstract = is_class_abstract(target_name, schema_view) if subject == Subject.class_ else None
|
|
124
|
+
|
|
125
|
+
# Append a node for the source subject if we haven't already done so. Same for the target subject.
|
|
126
|
+
node_ids = [n["data"]["id"] for n in nodes]
|
|
127
|
+
if source_name not in node_ids:
|
|
128
|
+
nodes.append(dict(data=dict(id=source_name, is_abstract=is_source_abstract)))
|
|
129
|
+
if target_name not in node_ids:
|
|
130
|
+
nodes.append(dict(data=dict(id=target_name, is_abstract=is_target_abstract)))
|
|
131
|
+
|
|
132
|
+
# Append an edge for this source subject-to-target subject relationship if we haven't already done so.
|
|
133
|
+
edge_id = f"{source_name}__to__{target_name}"
|
|
134
|
+
edge_ids = [e["data"]["id"] for e in edges]
|
|
135
|
+
if edge_id not in edge_ids:
|
|
136
|
+
edge = dict(data=dict(id=edge_id, source=source_name, target=target_name, source_fields=[]))
|
|
137
|
+
edges.append(edge)
|
|
138
|
+
|
|
139
|
+
# Ensure this edge's `data.source_fields` list accounts for this reference's source field name.
|
|
140
|
+
edge = next(e for e in edges if e["data"]["id"] == edge_id) # gets the matching edge
|
|
141
|
+
if r.source_field_name not in edge["data"]["source_fields"]: # avoids repeating the field name
|
|
142
|
+
edge["data"]["source_fields"].append(r.source_field_name)
|
|
143
|
+
|
|
144
|
+
# Replace each edge's `source_fields` property with a `label` property containing a comma-delimited string.
|
|
145
|
+
# Example: if `source_fields` contains ["a", "b", "c"] -> `label` will contain "a, b, c"
|
|
146
|
+
# Reference: https://docs.python.org/3/library/stdtypes.html#dict.pop
|
|
147
|
+
for edge in edges:
|
|
148
|
+
source_fields: list = edge["data"].pop("source_fields", []) # removes the `source_fields` property
|
|
149
|
+
edge["data"]["label"] = ", ".join(source_fields)
|
|
150
|
+
|
|
151
|
+
console.print(f"Nodes: {len(nodes)}")
|
|
152
|
+
console.print(f"Edges: {len(edges)}")
|
|
153
|
+
console.print()
|
|
154
|
+
|
|
155
|
+
elements = nodes + edges # join the lists
|
|
156
|
+
|
|
157
|
+
# Load the HTML template file.
|
|
158
|
+
html_template = load_template(r"templates/graph.template.html")
|
|
159
|
+
|
|
160
|
+
# Generate an HTML file (based upon the template) that contains those elements.
|
|
161
|
+
graph_data_json_base64_str = encode_json_value_as_base64_str(elements)
|
|
162
|
+
graph_metadata_json_base64_str = encode_json_value_as_base64_str(
|
|
163
|
+
dict(
|
|
164
|
+
app_version=get_package_metadata("Version"),
|
|
165
|
+
schema_version=schema_view.schema.version,
|
|
166
|
+
subject_singular="class" if subject == Subject.class_ else "collection",
|
|
167
|
+
subject_plural="classes" if subject == Subject.class_ else "collections",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
html_result = html_template.replace("{{ graph_data_json_base64 }}", graph_data_json_base64_str)
|
|
171
|
+
html_result = html_result.replace("{{ graph_metadata_json_base64 }}", graph_metadata_json_base64_str)
|
|
172
|
+
|
|
173
|
+
return html_result
|