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/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
@@ -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
@@ -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
@@ -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"