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/lib/Finder.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from typing import List, Optional, Dict, Tuple
|
|
2
|
+
|
|
3
|
+
from pymongo.database import Database
|
|
4
|
+
from pymongo.client_session import ClientSession
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Finder:
|
|
8
|
+
r"""
|
|
9
|
+
A class that can be used to find a document in a MongoDB database, in a way that uses past searches to try to
|
|
10
|
+
speed up future searches.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, database: Database):
|
|
14
|
+
r"""
|
|
15
|
+
Initializes an instance of the `Finder` class. The instance is bound to the specified MongoDB database.
|
|
16
|
+
"""
|
|
17
|
+
self.db = database
|
|
18
|
+
|
|
19
|
+
# Initialize our cache of names of collections we most recently found referenced document `id`s in.
|
|
20
|
+
#
|
|
21
|
+
# Note: This is a queue we will use to keep track of which collections we most recently found referenced
|
|
22
|
+
# document `id`s in. This will enable us to _begin_ searching in those same collections for subsequent
|
|
23
|
+
# searches. The idea that this will speed things up is based on the assumption that references emanating
|
|
24
|
+
# from source documents that we process _temporally close_ to one another, likely point to target
|
|
25
|
+
# documents residing _in the same collections_ as one another.
|
|
26
|
+
#
|
|
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
|
+
|
|
30
|
+
# Initialize our cache of document `id` presences/absences by collection.
|
|
31
|
+
#
|
|
32
|
+
# Note: This is a dictionary we will use to keep track of the referenced `id`s we find in each collection,
|
|
33
|
+
# and the referenced `id`s we fail to find (i.e. confirm do _not_ exist) in each collection. This will
|
|
34
|
+
# enable us to avoid querying the database for the same information that we have previously obtained
|
|
35
|
+
# by querying the database.
|
|
36
|
+
#
|
|
37
|
+
# Note: The top-level items in the dictionary have keys that are collection names and values that are
|
|
38
|
+
# dictionaries. Each of those dictionaries has keys that are document `id`s and values that are boolean
|
|
39
|
+
# flags, which indicate whether we know that a document having that `id` exists in that collection
|
|
40
|
+
# (`True`) or we know that a document having that `id` does _not_ exist in that collection (`False`).
|
|
41
|
+
# Here's an example value of this attribute:
|
|
42
|
+
# ```
|
|
43
|
+
# {
|
|
44
|
+
# "biosample_set": {"nmdc:bsm-00-000001": True, "nmdc:bsm-00-000002": False},
|
|
45
|
+
# "study_set": {"nmdc:sty-00-000001": True},
|
|
46
|
+
# }
|
|
47
|
+
# ```
|
|
48
|
+
#
|
|
49
|
+
# Note: If this program ever leads to memory consumption (RAM usage) issues in practice, consider
|
|
50
|
+
# redesigning this cache and/or allowing the user to disable its use.
|
|
51
|
+
#
|
|
52
|
+
self.cached_id_presence_by_collection: Dict[str, Dict[str, bool]] = {}
|
|
53
|
+
|
|
54
|
+
def _set_name_of_collection_most_recently_found_in(self, collection_name: str):
|
|
55
|
+
r"""
|
|
56
|
+
Helper function that updates our cache of collection names in which target documents were most recently found,
|
|
57
|
+
so that the specified collection is at the front/start of the list. That will make it so it gets searched first
|
|
58
|
+
next time.
|
|
59
|
+
"""
|
|
60
|
+
# Make a concise alias for the instance attribute (both will refer to the same data structure).
|
|
61
|
+
queue = self.cached_collection_names_where_recently_found
|
|
62
|
+
|
|
63
|
+
# Check whether either (a) the queue is empty or (b) this collection name is not at the front of the queue.
|
|
64
|
+
# Example: [] is empty, or "b_set" is not the first item in ["a_set", "b_set", "c_set"]
|
|
65
|
+
if len(queue) == 0 or queue[0] != collection_name:
|
|
66
|
+
|
|
67
|
+
# If this collection name is already (elsewhere) in the queue, remove it from that position, automatically
|
|
68
|
+
# updating the list indexes of subsequent list items (so there is no "gap" in the list).
|
|
69
|
+
# Example: Removing "b_set" from ["a_set", "b_set", "c_set"] → ["a_set", "c_set"]
|
|
70
|
+
if collection_name in queue:
|
|
71
|
+
queue.remove(collection_name)
|
|
72
|
+
|
|
73
|
+
# Prepend this collection name to the beginning of the queue, dropping any excess collection names.
|
|
74
|
+
# Example: Prepending ["b_set"] to ["a_set", "c_set"] → ["b_set", "a_set", "c_set"]
|
|
75
|
+
queue = [collection_name] + queue[0 : self.cache_size - 1]
|
|
76
|
+
|
|
77
|
+
# Note: This unnecessary assignment is here only to tell my IDE that `queue` isn't an unused variable.
|
|
78
|
+
self.cached_collection_names_where_recently_found = queue
|
|
79
|
+
|
|
80
|
+
def _optimize_collection_search_order(self, names_of_eligible_collections: List[str]) -> List[str]:
|
|
81
|
+
r"""
|
|
82
|
+
Helper function that returns an optimized order in which the specified collections could be searched, in order
|
|
83
|
+
to take advantage of things the instance "remembers" from recent searches.
|
|
84
|
+
"""
|
|
85
|
+
ordered_collection_names = names_of_eligible_collections.copy()
|
|
86
|
+
|
|
87
|
+
# Iterate over the collection names in the queue, from back (oldest) to front (newest).
|
|
88
|
+
for cached_collection_name in reversed(self.cached_collection_names_where_recently_found):
|
|
89
|
+
|
|
90
|
+
# If this cached collection name is among the eligible ones, move it to the front of the list.
|
|
91
|
+
# Example: Since "b_set" is in ["a_set", "b_set", "c_set"] → ["b_set, "a_set", "c_set"]
|
|
92
|
+
if cached_collection_name in names_of_eligible_collections:
|
|
93
|
+
ordered_collection_names.remove(cached_collection_name)
|
|
94
|
+
ordered_collection_names.insert(0, cached_collection_name)
|
|
95
|
+
|
|
96
|
+
return ordered_collection_names
|
|
97
|
+
|
|
98
|
+
def _set_cached_id_presence_in_collection(self, collection_name: str, document_id: str, is_present: bool) -> None:
|
|
99
|
+
r"""
|
|
100
|
+
Helper function that updates our cache of document `id` presences/absences, setting the presence flag for the
|
|
101
|
+
specified document `id` in the specified collection.
|
|
102
|
+
"""
|
|
103
|
+
if collection_name not in self.cached_id_presence_by_collection:
|
|
104
|
+
self.cached_id_presence_by_collection[collection_name] = {} # creates key if it does not exist
|
|
105
|
+
self.cached_id_presence_by_collection[collection_name][document_id] = is_present
|
|
106
|
+
|
|
107
|
+
def _get_cached_id_presence_in_collection(self, collection_name: str, document_id: str) -> Optional[bool]:
|
|
108
|
+
r"""
|
|
109
|
+
Helper function that checks our cache of document `id` presences/absences, returning the presence/absence flag,
|
|
110
|
+
if any, for the specified document `id` in the specified collection.
|
|
111
|
+
"""
|
|
112
|
+
if (
|
|
113
|
+
collection_name not in self.cached_id_presence_by_collection
|
|
114
|
+
or document_id not in self.cached_id_presence_by_collection[collection_name]
|
|
115
|
+
):
|
|
116
|
+
return None
|
|
117
|
+
else:
|
|
118
|
+
return self.cached_id_presence_by_collection[collection_name][document_id]
|
|
119
|
+
|
|
120
|
+
def check_whether_document_having_id_exists_among_collections(
|
|
121
|
+
self, document_id: str, collection_names: List[str], client_session: Optional[ClientSession] = None
|
|
122
|
+
) -> Optional[str]:
|
|
123
|
+
r"""
|
|
124
|
+
Checks whether any document in any of the specified collections has the specified value in its `id` field.
|
|
125
|
+
Returns the name of the first collection, if any, containing such a document. If none of the collections
|
|
126
|
+
contain such a document, the function returns `None`.
|
|
127
|
+
|
|
128
|
+
If a `pymongo.client_session.ClientSession` is specified, this function will access the database within
|
|
129
|
+
the context of that session. If a transaction is pending on that session, this feature can be used
|
|
130
|
+
to check whether the specified document _would_ exist if that transaction were to be committed.
|
|
131
|
+
|
|
132
|
+
References:
|
|
133
|
+
- https://pymongo.readthedocs.io/en/stable/api/pymongo/collection.html#pymongo.collection.Collection.find_one
|
|
134
|
+
- https://pymongo.readthedocs.io/en/stable/api/pymongo/client_session.html#transactions
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
# Initialize the name of the containing collection to `None`.
|
|
138
|
+
name_of_collection_containing_target_document = None
|
|
139
|
+
|
|
140
|
+
# Optimize the search order of the collections, based upon the outcomes of recent searches.
|
|
141
|
+
ordered_collection_names = self._optimize_collection_search_order(collection_names)
|
|
142
|
+
|
|
143
|
+
# Search the collections.
|
|
144
|
+
query_filter = dict(id=document_id)
|
|
145
|
+
for collection_name in ordered_collection_names:
|
|
146
|
+
|
|
147
|
+
# Before querying the database, check our cache of document `id` presences/absences per collection.
|
|
148
|
+
is_id_present: Optional[bool] = self._get_cached_id_presence_in_collection(
|
|
149
|
+
collection_name=collection_name, document_id=document_id
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# If we already know it's _not_ in this collection, stop evaluating this collection and, instead,
|
|
153
|
+
# proceed to evaluate the next collection.
|
|
154
|
+
if is_id_present is False:
|
|
155
|
+
continue # stop evaluating this collection
|
|
156
|
+
|
|
157
|
+
# If we already know it _is_ in this collection, record the collection name, update the cache of names
|
|
158
|
+
# of collections where a referenced document was recently found, and skip searching additional collections.
|
|
159
|
+
if is_id_present is True:
|
|
160
|
+
name_of_collection_containing_target_document = collection_name
|
|
161
|
+
self._set_name_of_collection_most_recently_found_in(collection_name=collection_name)
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
# If we find it in the collection in the database, record the collection name, update both (a) the cache
|
|
165
|
+
# of which documents we have found in which collections, and (b) the cache of names of collections where
|
|
166
|
+
# a referenced document was recently found, and skip searching additional collections.
|
|
167
|
+
collection = self.db.get_collection(collection_name)
|
|
168
|
+
if collection.find_one(query_filter, projection=["_id"], session=client_session) is not None:
|
|
169
|
+
name_of_collection_containing_target_document = collection_name
|
|
170
|
+
|
|
171
|
+
# Record that a document having this `id` is _present_ in this collection.
|
|
172
|
+
self._set_cached_id_presence_in_collection(
|
|
173
|
+
collection_name=collection_name, document_id=document_id, is_present=True
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
self._set_name_of_collection_most_recently_found_in(collection_name=collection_name)
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
# If we made it this far, record that a document having this `id` does _not_ exist in this collection.
|
|
180
|
+
self._set_cached_id_presence_in_collection(
|
|
181
|
+
collection_name=collection_name, document_id=document_id, is_present=False
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return name_of_collection_containing_target_document
|
|
185
|
+
|
|
186
|
+
def find_documents_having_type_and_value_in_field(
|
|
187
|
+
self,
|
|
188
|
+
collection_name: str,
|
|
189
|
+
type_and_field_name_tuples: List[Tuple[str, str]],
|
|
190
|
+
value: str,
|
|
191
|
+
client_session: Optional[ClientSession] = None,
|
|
192
|
+
) -> List[dict]:
|
|
193
|
+
r"""
|
|
194
|
+
Finds all documents residing in the specified collection that—for any of the specified
|
|
195
|
+
`type`-and-`field_name` combinations—have both (a) that `type` value and (b) the specified
|
|
196
|
+
`value` value in their `field_name` field. Returns a list of descriptors of those
|
|
197
|
+
documents (each descriptor consists of the document's `_id`, `id`, and `type`).
|
|
198
|
+
|
|
199
|
+
For example, if the `type_and_field_name_tuples` parameter contains the tuple
|
|
200
|
+
`("nmdc:Study", "part_of")`, and the `value` parameter is `"nmdc:sty-00-000001"`,
|
|
201
|
+
then this function will return all documents in the specified collection where both
|
|
202
|
+
(a) the document's `type` field contains the value `"nmdc:Study"` and
|
|
203
|
+
(b) the document's `part_of` field contains the value `"nmdc:sty-00-000001"`.
|
|
204
|
+
|
|
205
|
+
Note: `type_and_field_name_tuples` is a list of tuples, where the first item in each tuple is a
|
|
206
|
+
`type` value (e.g. "nmdc:Study") and the second item is a field name (e.g. "part_of").
|
|
207
|
+
|
|
208
|
+
If a `pymongo.client_session.ClientSession` is specified, this function will access the database within
|
|
209
|
+
the context of that session.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
# Build the query filter terms based on the `type`-and-`field_name` tuples.
|
|
213
|
+
query_filter_terms = []
|
|
214
|
+
for type_and_field_name_tuple in type_and_field_name_tuples:
|
|
215
|
+
type_value, field_name = type_and_field_name_tuple
|
|
216
|
+
query_filter_term = {"type": type_value, field_name: value}
|
|
217
|
+
query_filter_terms.append(query_filter_term)
|
|
218
|
+
|
|
219
|
+
# Define the overall query filter as a logical "OR" of the individual terms.
|
|
220
|
+
query_filter = {"$or": query_filter_terms}
|
|
221
|
+
|
|
222
|
+
# Only request essential data from the database (as a performance optimization).
|
|
223
|
+
projection = ["_id", "id", "type"]
|
|
224
|
+
|
|
225
|
+
documents = []
|
|
226
|
+
collection = self.db.get_collection(collection_name)
|
|
227
|
+
for document in collection.find(query_filter, projection=projection, session=client_session):
|
|
228
|
+
documents.append(document)
|
|
229
|
+
return documents
|
refscan/lib/Reference.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True, order=True)
|
|
5
|
+
class Reference:
|
|
6
|
+
"""
|
|
7
|
+
A generic reference to a document in a collection.
|
|
8
|
+
|
|
9
|
+
Note: `frozen` means the instances are immutable.
|
|
10
|
+
Note: `order` means the instances have methods that help with sorting. For example, an `__eq__` method that
|
|
11
|
+
can be used to compare instances of the class as though they were tuples of those instances' fields.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
source_collection_name: str = field() # e.g. "study_set"
|
|
15
|
+
source_class_name: str = field() # e.g. "Study"
|
|
16
|
+
source_field_name: str = field() # e.g. "part_of"
|
|
17
|
+
target_collection_name: str = field() # e.g. "study_set" (reminder: a study can be part of another study)
|
|
18
|
+
target_class_name: str = field() # e.g. "Study"
|
|
19
|
+
|
|
20
|
+
def __eq__(self, other):
|
|
21
|
+
r"""
|
|
22
|
+
Determines whether an instance of this class is equal to the specified "other" value.
|
|
23
|
+
|
|
24
|
+
Note: This method dictates what will happen under the hood when the `==` operator is used.
|
|
25
|
+
Reference: https://docs.python.org/3/reference/datamodel.html#object.__eq__
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
if not isinstance(other, Reference):
|
|
29
|
+
return False
|
|
30
|
+
else:
|
|
31
|
+
return (
|
|
32
|
+
self.source_collection_name == other.source_collection_name
|
|
33
|
+
and self.source_class_name == other.source_class_name
|
|
34
|
+
and self.source_field_name == other.source_field_name
|
|
35
|
+
and self.target_collection_name == other.target_collection_name
|
|
36
|
+
and self.target_class_name == other.target_class_name
|
|
37
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from typing import List, Dict, Union, Optional
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from dataclasses import fields, astuple
|
|
4
|
+
from collections import UserList
|
|
5
|
+
from itertools import groupby
|
|
6
|
+
import csv
|
|
7
|
+
|
|
8
|
+
from rich.table import Table, Column
|
|
9
|
+
|
|
10
|
+
from refscan.lib.Reference import Reference
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReferenceList(UserList):
|
|
14
|
+
"""
|
|
15
|
+
A list of references.
|
|
16
|
+
|
|
17
|
+
Note: `UserList` is a base class that facilitates the implementation of custom list classes.
|
|
18
|
+
One thing it does is enable sorting via `sorted(the_list)`.
|
|
19
|
+
"""
|
|
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
|
+
|
|
32
|
+
def get_source_collection_names(self) -> list[str]:
|
|
33
|
+
"""
|
|
34
|
+
Returns the distinct `source_collection_names` values among all references in the list.
|
|
35
|
+
"""
|
|
36
|
+
distinct_source_collection_names = []
|
|
37
|
+
for reference in self.data:
|
|
38
|
+
if reference.source_collection_name not in distinct_source_collection_names:
|
|
39
|
+
distinct_source_collection_names.append(reference.source_collection_name)
|
|
40
|
+
return distinct_source_collection_names
|
|
41
|
+
|
|
42
|
+
def count_source_collections(self) -> int:
|
|
43
|
+
r"""
|
|
44
|
+
Returns the number of distinct source collection names among all references in the list.
|
|
45
|
+
"""
|
|
46
|
+
return len(self.get_source_collection_names())
|
|
47
|
+
|
|
48
|
+
def get_source_field_names_of_source_collection(self, collection_name: str) -> list[str]:
|
|
49
|
+
"""
|
|
50
|
+
Returns the distinct source field names of the specified source collection.
|
|
51
|
+
"""
|
|
52
|
+
distinct_source_field_names = []
|
|
53
|
+
for reference in self.data:
|
|
54
|
+
if reference.source_collection_name == collection_name:
|
|
55
|
+
if reference.source_field_name not in distinct_source_field_names:
|
|
56
|
+
distinct_source_field_names.append(reference.source_field_name)
|
|
57
|
+
return distinct_source_field_names
|
|
58
|
+
|
|
59
|
+
def get_target_collection_names(
|
|
60
|
+
self,
|
|
61
|
+
source_class_name: str,
|
|
62
|
+
source_field_name: str,
|
|
63
|
+
) -> list[str]:
|
|
64
|
+
"""
|
|
65
|
+
Returns a list of the names of the collections in which a [target] document referenced by the specified field
|
|
66
|
+
of a [source] document representing an instance of the specified schema class, might exist.
|
|
67
|
+
|
|
68
|
+
TODO: Execution spends a lot of time in this function. Consider replacing it with something faster (e.g. a LUT).
|
|
69
|
+
"""
|
|
70
|
+
target_collection_names = []
|
|
71
|
+
for reference in self.data: # note: in a `UserList`, `self.data` refers to the underlying list data structure
|
|
72
|
+
|
|
73
|
+
# If this reference's source describes the specified source, record the reference's target collection name.
|
|
74
|
+
if reference.source_class_name == source_class_name and reference.source_field_name == source_field_name:
|
|
75
|
+
target_collection_names.append(reference.target_collection_name)
|
|
76
|
+
|
|
77
|
+
distinct_target_collection_names = list(set(target_collection_names))
|
|
78
|
+
return distinct_target_collection_names
|
|
79
|
+
|
|
80
|
+
def get_groups(self, field_names: list[str]):
|
|
81
|
+
r"""
|
|
82
|
+
Returns an iterable of groups, where each group has a distinct combination of values in the specified fields.
|
|
83
|
+
|
|
84
|
+
TODO: Add a type hint that indicates the return type. It's an instance of a class named `groupby` (lowercase).
|
|
85
|
+
|
|
86
|
+
Note: This method can be used to "consolidate" references that have the same source collection name,
|
|
87
|
+
source field name, and target collection name (i.e. ones that only differ by target class name).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def make_group_key(reference: Reference) -> tuple:
|
|
91
|
+
"""Helper function that returns a key that can be used to group references."""
|
|
92
|
+
values = []
|
|
93
|
+
for field_name in field_names:
|
|
94
|
+
if not hasattr(reference, field_name):
|
|
95
|
+
raise ValueError(f"No such field: {field_name}")
|
|
96
|
+
values.append(getattr(reference, field_name))
|
|
97
|
+
return tuple(values)
|
|
98
|
+
|
|
99
|
+
groups = groupby(sorted(self.data), key=make_group_key)
|
|
100
|
+
return groups
|
|
101
|
+
|
|
102
|
+
def dump_to_tsv_file(self, file_path: Union[str, Path]) -> None:
|
|
103
|
+
r"""
|
|
104
|
+
Helper function that dumps the references to a TSV file at the specified path.
|
|
105
|
+
"""
|
|
106
|
+
column_names = [field_.name for field_ in fields(Reference)]
|
|
107
|
+
with open(file_path, "w", newline="") as tsv_file:
|
|
108
|
+
writer = csv.writer(tsv_file, delimiter="\t")
|
|
109
|
+
writer.writerow(column_names) # header row
|
|
110
|
+
for reference in self.data:
|
|
111
|
+
writer.writerow(astuple(reference)) # data row
|
|
112
|
+
|
|
113
|
+
def get_reference_field_names_by_source_class_name(self) -> Dict[str, List[str]]:
|
|
114
|
+
r"""
|
|
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.
|
|
117
|
+
|
|
118
|
+
Example: {"Study": ["part_of"]}
|
|
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
|
+
|
|
125
|
+
reference_field_names_by_source_class_name: Dict[str, List[str]] = {}
|
|
126
|
+
for reference in self.data:
|
|
127
|
+
source_class_name = reference.source_class_name
|
|
128
|
+
source_field_name = reference.source_field_name
|
|
129
|
+
|
|
130
|
+
# Initialize a dictionary item for this source class, if one doesn't already exist.
|
|
131
|
+
if source_class_name not in reference_field_names_by_source_class_name.keys():
|
|
132
|
+
reference_field_names_by_source_class_name[source_class_name] = []
|
|
133
|
+
|
|
134
|
+
# If the source field name isn't already in this class's list, append it.
|
|
135
|
+
if source_field_name not in reference_field_names_by_source_class_name[source_class_name]:
|
|
136
|
+
reference_field_names_by_source_class_name[source_class_name].append(source_field_name)
|
|
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
|
+
|
|
141
|
+
return reference_field_names_by_source_class_name
|
|
142
|
+
|
|
143
|
+
def get_by_target_class_name(self, target_class_name: str) -> "ReferenceList":
|
|
144
|
+
r"""
|
|
145
|
+
Returns a new `ReferenceList` that contains only the references whose target class
|
|
146
|
+
has the specified name.
|
|
147
|
+
|
|
148
|
+
Maintainer note: The return type is written as a string to avoid the error:
|
|
149
|
+
> NameError: name 'ReferenceList' is not defined
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
filtered_references = ReferenceList()
|
|
153
|
+
for reference in self.data:
|
|
154
|
+
if reference.target_class_name == target_class_name:
|
|
155
|
+
filtered_references.append(reference)
|
|
156
|
+
return filtered_references
|
|
157
|
+
|
|
158
|
+
def group_by_source_collection_name(self) -> Dict[str, "ReferenceList"]:
|
|
159
|
+
r"""
|
|
160
|
+
Returns a dictionary that maps source collection names to `ReferenceList` instances
|
|
161
|
+
containing the references that have that source collection name.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
grouped_references: Dict[str, ReferenceList] = {}
|
|
165
|
+
for reference in self.data:
|
|
166
|
+
if reference.source_collection_name not in grouped_references:
|
|
167
|
+
grouped_references[reference.source_collection_name] = ReferenceList()
|
|
168
|
+
grouped_references[reference.source_collection_name].append(reference)
|
|
169
|
+
|
|
170
|
+
return grouped_references
|
|
171
|
+
|
|
172
|
+
def as_table(self) -> Table:
|
|
173
|
+
r"""
|
|
174
|
+
Returns the references as a `rich.Table` instance.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
# Make the data rows for the table. Data rows that would have had the same
|
|
178
|
+
# (combination of) the following fields, get consolidated into a single row;
|
|
179
|
+
# and all the target class names are displayed as a list on that row.
|
|
180
|
+
fields_to_group_rows_by = [
|
|
181
|
+
"source_collection_name",
|
|
182
|
+
"source_class_name",
|
|
183
|
+
"source_field_name",
|
|
184
|
+
"target_collection_name",
|
|
185
|
+
]
|
|
186
|
+
groups = self.get_groups(fields_to_group_rows_by)
|
|
187
|
+
data_rows: list[tuple[str, str, str, str, str]] = []
|
|
188
|
+
for key, group in groups:
|
|
189
|
+
target_class_names = list(set([ref.target_class_name for ref in group])) # omit duplicate class names
|
|
190
|
+
row = (key[0], key[1], key[2], key[3], ", ".join(target_class_names))
|
|
191
|
+
data_rows.append(row)
|
|
192
|
+
|
|
193
|
+
# Initialize the table, then add the data rows to it.
|
|
194
|
+
table = Table(
|
|
195
|
+
Column(header="Source collection", footer=f"{len(data_rows)} rows"),
|
|
196
|
+
Column(header="Source class"),
|
|
197
|
+
Column(header="Source field"),
|
|
198
|
+
Column(header="Target collection"),
|
|
199
|
+
Column(header="Target class(es)"),
|
|
200
|
+
title="References",
|
|
201
|
+
show_footer=True,
|
|
202
|
+
)
|
|
203
|
+
for row in data_rows:
|
|
204
|
+
table.add_row(*row)
|
|
205
|
+
|
|
206
|
+
return table
|
refscan/lib/Violation.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True, order=True)
|
|
6
|
+
class Violation:
|
|
7
|
+
"""
|
|
8
|
+
A specific reference that lacks integrity.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
source_collection_name: str = field()
|
|
12
|
+
source_class_name: str = field()
|
|
13
|
+
source_field_name: str = field()
|
|
14
|
+
source_document_object_id: str = field()
|
|
15
|
+
source_document_id: str = field()
|
|
16
|
+
target_id: str = field()
|
|
17
|
+
|
|
18
|
+
# Note: This field contains the name of the collection, if any, in which the targeted document was found.
|
|
19
|
+
# Its name was designed to emphasize that it is _not_ a collection that the schema allows the document
|
|
20
|
+
# to exist in, but is instead the collection the document _actually_ exists in. The fact that the latter
|
|
21
|
+
# is not among the former is why there is a `Violation` instance describing this reference.
|
|
22
|
+
#
|
|
23
|
+
name_of_collection_containing_target: Optional[str] = field()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from dataclasses import fields, astuple
|
|
4
|
+
from collections import UserList
|
|
5
|
+
import csv
|
|
6
|
+
|
|
7
|
+
from refscan.lib.Violation import Violation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ViolationList(UserList):
|
|
11
|
+
"""
|
|
12
|
+
A list of violations.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def dump_to_tsv_file(
|
|
16
|
+
self, file_path: Union[str, Path], include_name_of_collection_containing_target: bool = True
|
|
17
|
+
) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Helper function that dumps the violations to a TSV file at the specified path.
|
|
20
|
+
"""
|
|
21
|
+
column_names = [field_.name for field_ in fields(Violation)]
|
|
22
|
+
if not include_name_of_collection_containing_target:
|
|
23
|
+
column_names = [cn for cn in column_names if cn != "name_of_collection_containing_target"]
|
|
24
|
+
with open(file_path, "w", newline="") as tsv_file:
|
|
25
|
+
writer = csv.writer(tsv_file, delimiter="\t")
|
|
26
|
+
writer.writerow(column_names) # header row
|
|
27
|
+
for violation in self.data:
|
|
28
|
+
writer.writerow(astuple(violation)) # data row
|
refscan/lib/constants.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
|
|
3
|
+
# Instantiate a Rich console for fancy console output.
|
|
4
|
+
# Reference: https://rich.readthedocs.io/en/stable/console.html
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
# Note: This is the only schema class name hard-coded into this script.
|
|
8
|
+
DATABASE_CLASS_NAME = "Database"
|