refscan 0.2.5__tar.gz → 0.3.0__tar.gz
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-0.2.5 → refscan-0.3.0}/PKG-INFO +11 -1
- {refscan-0.2.5 → refscan-0.3.0}/README.md +10 -0
- {refscan-0.2.5 → refscan-0.3.0}/pyproject.toml +24 -1
- {refscan-0.2.5 → refscan-0.3.0}/refscan/__init__.py +1 -1
- {refscan-0.2.5 → refscan-0.3.0}/refscan/cli/scan.py +0 -1
- {refscan-0.2.5 → refscan-0.3.0}/refscan/grapher.py +8 -3
- {refscan-0.2.5 → refscan-0.3.0}/refscan/lib/Finder.py +2 -2
- {refscan-0.2.5 → refscan-0.3.0}/refscan/lib/ReferenceList.py +22 -3
- {refscan-0.2.5 → refscan-0.3.0}/refscan/lib/helpers.py +2 -0
- {refscan-0.2.5 → refscan-0.3.0}/refscan/scanner.py +17 -13
- {refscan-0.2.5 → refscan-0.3.0}/LICENSE +0 -0
- {refscan-0.2.5 → refscan-0.3.0}/refscan/cli/cli.py +0 -0
- {refscan-0.2.5 → refscan-0.3.0}/refscan/cli/graph.py +0 -0
- {refscan-0.2.5 → refscan-0.3.0}/refscan/cli/version.py +0 -0
- {refscan-0.2.5 → refscan-0.3.0}/refscan/lib/Reference.py +0 -0
- {refscan-0.2.5 → refscan-0.3.0}/refscan/lib/Violation.py +0 -0
- {refscan-0.2.5 → refscan-0.3.0}/refscan/lib/ViolationList.py +0 -0
- {refscan-0.2.5 → refscan-0.3.0}/refscan/lib/constants.py +0 -0
- {refscan-0.2.5 → refscan-0.3.0}/refscan/templates/graph.template.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: refscan
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Command-line program that scans the NMDC MongoDB database for referential integrity violations
|
|
5
5
|
Keywords: mongodb,mongo,relationships,reference,database,data,referential integrity,scan
|
|
6
6
|
Requires-Python: >=3.9,<4.0
|
|
@@ -313,6 +313,16 @@ While editing the tool's source code, you can run the tool as you normally would
|
|
|
313
313
|
poetry run refscan --help
|
|
314
314
|
```
|
|
315
315
|
|
|
316
|
+
### Check types
|
|
317
|
+
|
|
318
|
+
We use [mypy](https://mypy.readthedocs.io/en/stable/) as the static type checker for `refscan`.
|
|
319
|
+
|
|
320
|
+
You can perform static type checking by running the following command from the root directory of the repository:
|
|
321
|
+
|
|
322
|
+
```shell
|
|
323
|
+
poetry run mypy
|
|
324
|
+
```
|
|
325
|
+
|
|
316
326
|
### Run tests
|
|
317
327
|
|
|
318
328
|
We use [pytest](https://docs.pytest.org/en/8.2.x/) as the testing framework for `refscan`.
|
|
@@ -292,6 +292,16 @@ While editing the tool's source code, you can run the tool as you normally would
|
|
|
292
292
|
poetry run refscan --help
|
|
293
293
|
```
|
|
294
294
|
|
|
295
|
+
### Check types
|
|
296
|
+
|
|
297
|
+
We use [mypy](https://mypy.readthedocs.io/en/stable/) as the static type checker for `refscan`.
|
|
298
|
+
|
|
299
|
+
You can perform static type checking by running the following command from the root directory of the repository:
|
|
300
|
+
|
|
301
|
+
```shell
|
|
302
|
+
poetry run mypy
|
|
303
|
+
```
|
|
304
|
+
|
|
295
305
|
### Run tests
|
|
296
306
|
|
|
297
307
|
We use [pytest](https://docs.pytest.org/en/8.2.x/) as the testing framework for `refscan`.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[tool.poetry]
|
|
4
4
|
name = "refscan"
|
|
5
|
-
version = "v0.
|
|
5
|
+
version = "v0.3.0"
|
|
6
6
|
description = "Command-line program that scans the NMDC MongoDB database for referential integrity violations"
|
|
7
7
|
authors = []
|
|
8
8
|
readme = "README.md"
|
|
@@ -43,6 +43,10 @@ pytest-cov = "^5.0.0"
|
|
|
43
43
|
# We use `mongomock` to test MongoDB interaction without involving a real MongoDB instance.
|
|
44
44
|
# Docs: https://github.com/mongomock/mongomock
|
|
45
45
|
mongomock = "^4.3.0"
|
|
46
|
+
# We use `mypy` to perform static type checking.
|
|
47
|
+
# Usage: $ poetry run mypy
|
|
48
|
+
# Docs: https://mypy.readthedocs.io/en/stable/
|
|
49
|
+
mypy = "^1.16.0"
|
|
46
50
|
|
|
47
51
|
[tool.poetry.scripts]
|
|
48
52
|
# Reference: https://python-poetry.org/docs/pyproject#scripts
|
|
@@ -61,6 +65,25 @@ addopts = "--doctest-modules --cov=refscan"
|
|
|
61
65
|
# Reference: https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#configuration-format
|
|
62
66
|
line-length = 120
|
|
63
67
|
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
# Make it so, when someone runs `$ poetry run mypy`, these default CLI options are used.
|
|
70
|
+
# Reference: https://mypy.readthedocs.io/en/stable/config_file.html
|
|
71
|
+
files = "."
|
|
72
|
+
|
|
73
|
+
[[tool.mypy.overrides]]
|
|
74
|
+
# Silence the following error:
|
|
75
|
+
# > Skipping analyzing "linkml_runtime": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
|
76
|
+
#
|
|
77
|
+
# Note: We first tried using the following, but it seemed to have no effect on the behavior of mypy when run via `$ poetry run mypy`.
|
|
78
|
+
# ```
|
|
79
|
+
# [mypy-linkml_runtime]
|
|
80
|
+
# ignore_missing_imports = true
|
|
81
|
+
# ```
|
|
82
|
+
# So, we—instead—used the syntax shown on: https://stackoverflow.com/a/74198016
|
|
83
|
+
#
|
|
84
|
+
module = "linkml_runtime"
|
|
85
|
+
ignore_missing_imports = true
|
|
86
|
+
|
|
64
87
|
[build-system]
|
|
65
88
|
requires = ["poetry-core"]
|
|
66
89
|
build-backend = "poetry.core.masonry.api"
|
|
@@ -12,7 +12,7 @@ def get_package_metadata(key: str) -> str:
|
|
|
12
12
|
metadata_value = ""
|
|
13
13
|
try:
|
|
14
14
|
package_metadata = metadata("refscan")
|
|
15
|
-
metadata_value = package_metadata.get(key)
|
|
15
|
+
metadata_value = package_metadata.get(key, "")
|
|
16
16
|
except PackageNotFoundError:
|
|
17
17
|
pass
|
|
18
18
|
return metadata_value
|
|
@@ -172,7 +172,6 @@ def scan(
|
|
|
172
172
|
db=db,
|
|
173
173
|
schema_view=schema_view,
|
|
174
174
|
references=references,
|
|
175
|
-
collection_names=collection_names,
|
|
176
175
|
names_of_source_collections_to_skip=names_of_source_collections_to_skip,
|
|
177
176
|
user_wants_to_locate_misplaced_documents=user_wants_to_locate_misplaced_documents,
|
|
178
177
|
verbose=verbose,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
|
-
from typing import Union
|
|
2
|
+
from typing import Union, List, Dict, Any
|
|
3
3
|
import json
|
|
4
4
|
import base64
|
|
5
5
|
from importlib import resources
|
|
@@ -15,6 +15,11 @@ from refscan.lib.helpers import (
|
|
|
15
15
|
identify_references,
|
|
16
16
|
)
|
|
17
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
|
+
|
|
18
23
|
|
|
19
24
|
class Subject(str, Enum):
|
|
20
25
|
r"""The subject (i.e. focal point) of the graph."""
|
|
@@ -105,8 +110,8 @@ def graph(
|
|
|
105
110
|
#
|
|
106
111
|
# Reference: https://js.cytoscape.org/#notation/elements-json
|
|
107
112
|
#
|
|
108
|
-
nodes = []
|
|
109
|
-
edges = []
|
|
113
|
+
nodes: List[NodeType] = []
|
|
114
|
+
edges: List[EdgeType] = []
|
|
110
115
|
for r in references:
|
|
111
116
|
# Get the source and target names depending upon the subject of the graph.
|
|
112
117
|
source_name = r.source_collection_name if subject == Subject.collection else r.source_class_name
|
|
@@ -24,8 +24,8 @@ class Finder:
|
|
|
24
24
|
# from source documents that we process _temporally close_ to one another, likely point to target
|
|
25
25
|
# documents residing _in the same collections_ as one another.
|
|
26
26
|
#
|
|
27
|
-
self.cached_collection_names_where_recently_found = []
|
|
28
|
-
self.cache_size = 2 # we'll cache up to 2 collection names
|
|
27
|
+
self.cached_collection_names_where_recently_found: List[str] = []
|
|
28
|
+
self.cache_size: int = 2 # we'll cache up to 2 collection names
|
|
29
29
|
|
|
30
30
|
# Initialize our cache of document `id` presences/absences by collection.
|
|
31
31
|
#
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import List, Dict, Union
|
|
1
|
+
from typing import List, Dict, Union, Optional
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
from dataclasses import fields, astuple
|
|
4
4
|
from collections import UserList
|
|
@@ -18,6 +18,17 @@ class ReferenceList(UserList):
|
|
|
18
18
|
One thing it does is enable sorting via `sorted(the_list)`.
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
|
+
# A cached dictionary that maps source class names to a list of the names of
|
|
22
|
+
# the fields of that class that contain references. Initialized to `None` to
|
|
23
|
+
# indicate that it hasn't been computed/cached yet.
|
|
24
|
+
#
|
|
25
|
+
# Example: {"Study": ["part_of"]}
|
|
26
|
+
#
|
|
27
|
+
# TODO: Consider implementing caching via functools's `@cached_property` decorator instead.
|
|
28
|
+
# Reference: https://docs.python.org/3/library/functools.html#functools.cached_property
|
|
29
|
+
#
|
|
30
|
+
_reference_field_names_by_source_class_name: Optional[Dict[str, List[str]]] = None
|
|
31
|
+
|
|
21
32
|
def get_source_collection_names(self) -> list[str]:
|
|
22
33
|
"""
|
|
23
34
|
Returns the distinct `source_collection_names` values among all references in the list.
|
|
@@ -101,11 +112,16 @@ class ReferenceList(UserList):
|
|
|
101
112
|
|
|
102
113
|
def get_reference_field_names_by_source_class_name(self) -> Dict[str, List[str]]:
|
|
103
114
|
r"""
|
|
104
|
-
Returns a dictionary that maps source class names to a list of the
|
|
105
|
-
|
|
115
|
+
Returns a dictionary that maps source class names to a list of the names of
|
|
116
|
+
the fields of that class that contain references.
|
|
106
117
|
|
|
107
118
|
Example: {"Study": ["part_of"]}
|
|
108
119
|
"""
|
|
120
|
+
|
|
121
|
+
# If this dictionary has already been computed by this class, return that cached dictionary.
|
|
122
|
+
if isinstance(self._reference_field_names_by_source_class_name, dict):
|
|
123
|
+
return self._reference_field_names_by_source_class_name
|
|
124
|
+
|
|
109
125
|
reference_field_names_by_source_class_name: Dict[str, List[str]] = {}
|
|
110
126
|
for reference in self.data:
|
|
111
127
|
source_class_name = reference.source_class_name
|
|
@@ -119,6 +135,9 @@ class ReferenceList(UserList):
|
|
|
119
135
|
if source_field_name not in reference_field_names_by_source_class_name[source_class_name]:
|
|
120
136
|
reference_field_names_by_source_class_name[source_class_name].append(source_field_name)
|
|
121
137
|
|
|
138
|
+
# Cache the dictionary for subsequent invocations of this method.
|
|
139
|
+
self._reference_field_names_by_source_class_name = reference_field_names_by_source_class_name
|
|
140
|
+
|
|
122
141
|
return reference_field_names_by_source_class_name
|
|
123
142
|
|
|
124
143
|
def get_by_target_class_name(self, target_class_name: str) -> "ReferenceList":
|
|
@@ -18,6 +18,8 @@ def connect_to_database(mongo_uri: str, database_name: str, verbose: bool = True
|
|
|
18
18
|
mongo_client: MongoClient = MongoClient(host=mongo_uri, directConnection=True)
|
|
19
19
|
|
|
20
20
|
with timeout(5): # if any message exchange takes > 5 seconds, this will raise an exception
|
|
21
|
+
if mongo_client.address is None:
|
|
22
|
+
raise ValueError("Failed to determine MongoDB server hostname or port number.")
|
|
21
23
|
(host, port_number) = mongo_client.address
|
|
22
24
|
|
|
23
25
|
if verbose:
|
|
@@ -8,6 +8,7 @@ from linkml_runtime import SchemaView
|
|
|
8
8
|
from refscan.lib.Finder import Finder
|
|
9
9
|
from refscan.lib.helpers import (
|
|
10
10
|
derive_schema_class_name_from_document,
|
|
11
|
+
get_collection_names_from_schema,
|
|
11
12
|
translate_class_uri_into_schema_class_name,
|
|
12
13
|
translate_schema_class_name_into_class_uri,
|
|
13
14
|
init_progress_bar,
|
|
@@ -110,11 +111,8 @@ def identify_referring_documents(
|
|
|
110
111
|
def scan_outgoing_references(
|
|
111
112
|
document: dict,
|
|
112
113
|
schema_view: SchemaView,
|
|
113
|
-
# TODO: Cache this dictionary, since it is derived from the schema alone.
|
|
114
|
-
reference_field_names_by_source_class_name: Dict[str, List[str]],
|
|
115
114
|
references: ReferenceList,
|
|
116
115
|
finder: Finder,
|
|
117
|
-
collection_names: List[str],
|
|
118
116
|
source_collection_name: str,
|
|
119
117
|
client_session: Optional[ClientSession] = None,
|
|
120
118
|
user_wants_to_locate_misplaced_documents: bool = False,
|
|
@@ -125,10 +123,8 @@ def scan_outgoing_references(
|
|
|
125
123
|
|
|
126
124
|
:param document: The source document from which the references emanate
|
|
127
125
|
:param schema_view: A SchemaView bound to the schema
|
|
128
|
-
:param reference_field_names_by_source_class_name: Dictionary mapping class names to lists of reference field names
|
|
129
126
|
:param references: A `ReferenceList` derived from the schema
|
|
130
127
|
:param finder: A `Finder` bound to the database being scanned
|
|
131
|
-
:param collection_names: List of collection names that are described by the schema
|
|
132
128
|
:param source_collection_name: Name of collection in which source document resides (this is included in the
|
|
133
129
|
violation report in an attempt to facilitate investigation of violations)
|
|
134
130
|
:param client_session: A `pymongo.client_session.ClientSession` instance that, if specified, will be used when
|
|
@@ -145,14 +141,20 @@ def scan_outgoing_references(
|
|
|
145
141
|
|
|
146
142
|
# Get the document's `id` so that we can include it in this script's output.
|
|
147
143
|
source_document_object_id = document["_id"]
|
|
148
|
-
source_document_id = document["id"] if "id" in document else None
|
|
144
|
+
source_document_id: Optional[str] = document["id"] if "id" in document else None
|
|
149
145
|
|
|
150
146
|
# Get the document's schema class name so that we can interpret its fields accordingly.
|
|
151
147
|
source_class_name = derive_schema_class_name_from_document(schema_view, document)
|
|
148
|
+
if source_class_name is None:
|
|
149
|
+
raise ValueError(f"Failed to derive schema class name from document having `id`: {source_document_id}")
|
|
152
150
|
|
|
153
151
|
# Get the names of that class's fields that can contain references.
|
|
152
|
+
reference_field_names_by_source_class_name = references.get_reference_field_names_by_source_class_name()
|
|
154
153
|
names_of_reference_fields = reference_field_names_by_source_class_name.get(source_class_name, [])
|
|
155
154
|
|
|
155
|
+
# Get the names of collections that are described by the schema.
|
|
156
|
+
collection_names = get_collection_names_from_schema(schema_view=schema_view)
|
|
157
|
+
|
|
156
158
|
# Check each field that both (a) exists in the document and (b) can contain a reference.
|
|
157
159
|
for field_name in names_of_reference_fields:
|
|
158
160
|
if field_name in document:
|
|
@@ -191,6 +193,15 @@ def scan_outgoing_references(
|
|
|
191
193
|
)
|
|
192
194
|
)
|
|
193
195
|
|
|
196
|
+
# Handle the case where the source document does not have an `id`.
|
|
197
|
+
#
|
|
198
|
+
# Note: As a reminder, according to the NMDC Schema (as of version v11.7.0), documents in the
|
|
199
|
+
# `functional_annotation_agg` collection do not have an `id` field.
|
|
200
|
+
# Reference: https://microbiomedata.github.io/nmdc-schema/FunctionalAnnotationAggMember/
|
|
201
|
+
#
|
|
202
|
+
if not isinstance(source_document_id, str):
|
|
203
|
+
source_document_id = ""
|
|
204
|
+
|
|
194
205
|
# Instantiate a `Violation` containing information about this referential integrity violation.
|
|
195
206
|
violation = Violation(
|
|
196
207
|
source_collection_name=source_collection_name,
|
|
@@ -212,7 +223,6 @@ def scan(
|
|
|
212
223
|
db: Database,
|
|
213
224
|
schema_view: SchemaView,
|
|
214
225
|
references: ReferenceList,
|
|
215
|
-
collection_names: List[str],
|
|
216
226
|
names_of_source_collections_to_skip: List[str],
|
|
217
227
|
user_wants_to_locate_misplaced_documents: bool = False,
|
|
218
228
|
console: Console = default_console,
|
|
@@ -226,7 +236,6 @@ def scan(
|
|
|
226
236
|
:param schema_view: A SchemaView bound to the schema with which that database complies
|
|
227
237
|
(except that it may not be compliant in terms of referential integrity)
|
|
228
238
|
:param references: A `ReferenceList` derived from the schema
|
|
229
|
-
:param collection_names: List of collection names that are described by the schema
|
|
230
239
|
:param names_of_source_collections_to_skip: List of source collections to skip
|
|
231
240
|
:param user_wants_to_locate_misplaced_documents: Whether the user wants the function to proceed to search illegal
|
|
232
241
|
collections after failing to find the referenced document among
|
|
@@ -235,9 +244,6 @@ def scan(
|
|
|
235
244
|
:param verbose: Whether you want the function to print a higher-than-normal amount of information to the console
|
|
236
245
|
"""
|
|
237
246
|
|
|
238
|
-
# Get a dictionary that maps source class names to the names of their fields that can contain references.
|
|
239
|
-
reference_field_names_by_source_class_name = references.get_reference_field_names_by_source_class_name()
|
|
240
|
-
|
|
241
247
|
# Initialize a progress bar.
|
|
242
248
|
custom_progress = init_progress_bar()
|
|
243
249
|
|
|
@@ -317,10 +323,8 @@ def scan(
|
|
|
317
323
|
violations = scan_outgoing_references(
|
|
318
324
|
document=document,
|
|
319
325
|
schema_view=schema_view,
|
|
320
|
-
reference_field_names_by_source_class_name=reference_field_names_by_source_class_name,
|
|
321
326
|
references=references,
|
|
322
327
|
finder=finder,
|
|
323
|
-
collection_names=collection_names,
|
|
324
328
|
source_collection_name=source_collection_name,
|
|
325
329
|
user_wants_to_locate_misplaced_documents=user_wants_to_locate_misplaced_documents,
|
|
326
330
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|