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 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