sapiopycommons 2025.7.9a582__py3-none-any.whl → 2025.7.10a595__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.
Potentially problematic release.
This version of sapiopycommons might be problematic. Click here for more details.
- sapiopycommons/callbacks/callback_util.py +665 -332
- sapiopycommons/callbacks/field_builder.py +2 -0
- sapiopycommons/chem/IndigoMolecules.py +31 -1
- sapiopycommons/chem/Molecules.py +3 -3
- sapiopycommons/chem/ps_commons.py +523 -0
- sapiopycommons/customreport/auto_pagers.py +26 -1
- sapiopycommons/customreport/term_builder.py +1 -1
- sapiopycommons/datatype/pseudo_data_types.py +349 -326
- sapiopycommons/eln/experiment_cache.py +188 -0
- sapiopycommons/eln/experiment_handler.py +408 -767
- sapiopycommons/eln/experiment_report_util.py +11 -6
- sapiopycommons/eln/experiment_step_factory.py +476 -0
- sapiopycommons/eln/plate_designer.py +7 -2
- sapiopycommons/eln/step_creation.py +236 -0
- sapiopycommons/files/file_util.py +7 -5
- sapiopycommons/general/accession_service.py +2 -2
- sapiopycommons/general/aliases.py +3 -1
- sapiopycommons/general/audit_log.py +7 -0
- sapiopycommons/general/custom_report_util.py +12 -0
- sapiopycommons/general/data_structure_util.py +115 -0
- sapiopycommons/processtracking/custom_workflow_handler.py +11 -1
- sapiopycommons/processtracking/endpoints.py +27 -0
- sapiopycommons/recordmodel/record_handler.py +657 -317
- sapiopycommons/rules/eln_rule_handler.py +8 -1
- sapiopycommons/rules/on_save_rule_handler.py +8 -1
- sapiopycommons/webhook/webhook_handlers.py +3 -0
- sapiopycommons/webhook/webservice_handlers.py +2 -2
- {sapiopycommons-2025.7.9a582.dist-info → sapiopycommons-2025.7.10a595.dist-info}/METADATA +2 -2
- sapiopycommons-2025.7.10a595.dist-info/RECORD +69 -0
- sapiopycommons/ai/__init__.py +0 -0
- sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.py +0 -43
- sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.pyi +0 -31
- sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2_grpc.py +0 -24
- sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.py +0 -123
- sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.pyi +0 -598
- sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2_grpc.py +0 -24
- sapiopycommons/ai/api/plan/proto/step_output_pb2.py +0 -45
- sapiopycommons/ai/api/plan/proto/step_output_pb2.pyi +0 -42
- sapiopycommons/ai/api/plan/proto/step_output_pb2_grpc.py +0 -24
- sapiopycommons/ai/api/plan/proto/step_pb2.py +0 -43
- sapiopycommons/ai/api/plan/proto/step_pb2.pyi +0 -43
- sapiopycommons/ai/api/plan/proto/step_pb2_grpc.py +0 -24
- sapiopycommons/ai/api/plan/script/proto/script_pb2.py +0 -55
- sapiopycommons/ai/api/plan/script/proto/script_pb2.pyi +0 -115
- sapiopycommons/ai/api/plan/script/proto/script_pb2_grpc.py +0 -153
- sapiopycommons/ai/api/plan/tool/proto/entry_pb2.py +0 -57
- sapiopycommons/ai/api/plan/tool/proto/entry_pb2.pyi +0 -96
- sapiopycommons/ai/api/plan/tool/proto/entry_pb2_grpc.py +0 -24
- sapiopycommons/ai/api/plan/tool/proto/tool_pb2.py +0 -67
- sapiopycommons/ai/api/plan/tool/proto/tool_pb2.pyi +0 -220
- sapiopycommons/ai/api/plan/tool/proto/tool_pb2_grpc.py +0 -154
- sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.py +0 -39
- sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.pyi +0 -32
- sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2_grpc.py +0 -24
- sapiopycommons/ai/protobuf_utils.py +0 -508
- sapiopycommons/ai/test_client.py +0 -251
- sapiopycommons/ai/tool_service_base.py +0 -798
- sapiopycommons-2025.7.9a582.dist-info/RECORD +0 -92
- {sapiopycommons-2025.7.9a582.dist-info → sapiopycommons-2025.7.10a595.dist-info}/WHEEL +0 -0
- {sapiopycommons-2025.7.9a582.dist-info → sapiopycommons-2025.7.10a595.dist-info}/licenses/LICENSE +0 -0
|
@@ -443,6 +443,8 @@ class FieldBuilder:
|
|
|
443
443
|
raise SapioException("Unable to set multiple list modes at once for a selection list.")
|
|
444
444
|
# Static values don't have a list mode. Evaluate this last so that the multiple list modes check doesn't
|
|
445
445
|
# need to be more complex.
|
|
446
|
+
# PR-47531: Even though static values don't use an existing list mode, a list mode must still be set.
|
|
447
|
+
list_mode = ListMode.USER
|
|
446
448
|
|
|
447
449
|
if not list_mode and static_values is None:
|
|
448
450
|
raise SapioException("A list mode must be chosen for selection list fields.")
|
|
@@ -6,13 +6,42 @@ indigo = Indigo()
|
|
|
6
6
|
renderer = IndigoRenderer(indigo)
|
|
7
7
|
indigo.setOption("render-output-format", "svg")
|
|
8
8
|
indigo.setOption("ignore-stereochemistry-errors", True)
|
|
9
|
+
# Ignore only if loading as non-query object. That is the meaning of this flag. Does nothing if it's query molecule.
|
|
10
|
+
indigo.setOption("ignore-noncritical-query-features", True)
|
|
9
11
|
indigo.setOption("render-stereo-style", "ext")
|
|
10
12
|
indigo.setOption("aromaticity-model", "generic")
|
|
11
13
|
indigo.setOption("render-coloring", True)
|
|
12
14
|
indigo.setOption("molfile-saving-mode", "3000")
|
|
15
|
+
indigo.setOption("dearomatize-verification", False)
|
|
13
16
|
indigo_inchi = IndigoInchi(indigo)
|
|
14
17
|
|
|
15
18
|
|
|
19
|
+
def get_aromatic_dearomatic_forms(m: IndigoObject):
|
|
20
|
+
"""
|
|
21
|
+
Get the aromatic and dearomatic forms of the molecule. Retain the original form if it's not inversible.
|
|
22
|
+
Inversible: after aromatic-dearomatic-aromatic transformation, the molecule is the same as the first aromatic transformation.
|
|
23
|
+
:param m: molecule from indigo.
|
|
24
|
+
:return: pair of indigo objects, first is aromatic, second is dearomatic.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
aromatic_reaction = m.clone()
|
|
28
|
+
aromatic_reaction.aromatize()
|
|
29
|
+
dearomatic_reaction = aromatic_reaction.clone()
|
|
30
|
+
dearomatic_reaction.dearomatize()
|
|
31
|
+
second_aromatic_reaction = dearomatic_reaction.clone()
|
|
32
|
+
second_aromatic_reaction.aromatize()
|
|
33
|
+
match = indigo.exactMatch(aromatic_reaction, second_aromatic_reaction)
|
|
34
|
+
if match:
|
|
35
|
+
return aromatic_reaction, dearomatic_reaction
|
|
36
|
+
else:
|
|
37
|
+
return m, dearomatic_reaction
|
|
38
|
+
except (Exception):
|
|
39
|
+
# If aromatization then following deromatization fails, we just skip it.
|
|
40
|
+
dearomatic_reaction = m.clone()
|
|
41
|
+
dearomatic_reaction.dearomatize()
|
|
42
|
+
return m, dearomatic_reaction
|
|
43
|
+
|
|
44
|
+
|
|
16
45
|
# Function to process dative bonds in a molecule
|
|
17
46
|
# Returns True if at least one dative bond (_BOND_COORDINATION) was removed
|
|
18
47
|
def remove_dative_bonds_in_mol(molecule: IndigoObject) -> bool:
|
|
@@ -51,7 +80,8 @@ def remove_dative_in_reaction(reaction: IndigoObject) -> bool:
|
|
|
51
80
|
:param reaction: The reaction to remove dative bonds.
|
|
52
81
|
:return: Whether there are any dative bonds in the reaction that were removed.
|
|
53
82
|
"""
|
|
54
|
-
reactant_dative_removed: bool = any(
|
|
83
|
+
reactant_dative_removed: bool = any(
|
|
84
|
+
remove_dative_bonds_in_mol(reactant) for reactant in reaction.iterateReactants())
|
|
55
85
|
product_dative_removed: bool = any(remove_dative_bonds_in_mol(product) for product in reaction.iterateProducts())
|
|
56
86
|
return reactant_dative_removed or product_dative_removed
|
|
57
87
|
|
sapiopycommons/chem/Molecules.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Author Yechen Qiao
|
|
2
2
|
# Common Molecule Utilities for Molecule Transfers with Sapio
|
|
3
|
-
|
|
3
|
+
from indigo import IndigoObject
|
|
4
4
|
from rdkit import Chem
|
|
5
5
|
from rdkit.Chem import Crippen, MolToInchi
|
|
6
6
|
from rdkit.Chem import Descriptors
|
|
@@ -10,7 +10,7 @@ from rdkit.Chem.MolStandardize import rdMolStandardize
|
|
|
10
10
|
from rdkit.Chem.SaltRemover import SaltRemover
|
|
11
11
|
from rdkit.Chem.rdchem import Mol, RWMol, Bond
|
|
12
12
|
|
|
13
|
-
from sapiopycommons.chem.IndigoMolecules import indigo, renderer, indigo_inchi
|
|
13
|
+
from sapiopycommons.chem.IndigoMolecules import indigo, renderer, indigo_inchi, get_aromatic_dearomatic_forms
|
|
14
14
|
|
|
15
15
|
metal_disconnector = rdMolStandardize.MetalDisconnector()
|
|
16
16
|
tautomer_params = Chem.MolStandardize.rdMolStandardize.CleanupParameters()
|
|
@@ -247,7 +247,7 @@ def mol_to_sapio_substance(mol: Mol, include_stereoisomers=False,
|
|
|
247
247
|
molecule["image"] = None
|
|
248
248
|
# We need to test the INCHI can be loaded back to indigo.
|
|
249
249
|
indigo_mol = indigo.loadMolecule(molBlock)
|
|
250
|
-
indigo_mol
|
|
250
|
+
indigo_mol = get_aromatic_dearomatic_forms(indigo_mol)[0] # Get the aromatic form of the molecule.
|
|
251
251
|
if enhanced_stereo:
|
|
252
252
|
# Remove enhanced stereo layer when generating InChI as the stereo hash is generated separately for reg.
|
|
253
253
|
Chem.CanonicalizeEnhancedStereo(inchi_mol)
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parallel Synthesis Commons
|
|
3
|
+
Author: Yechen Qiao
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from indigo import IndigoObject
|
|
10
|
+
from sapiopycommons.chem.IndigoMolecules import indigo, get_aromatic_dearomatic_forms, renderer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SerializableQueryMolecule:
|
|
14
|
+
mol_block: str
|
|
15
|
+
smarts: str
|
|
16
|
+
render_svg: str
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def create(query_molecule: IndigoObject):
|
|
20
|
+
aromatic, dearomatic = get_aromatic_dearomatic_forms(query_molecule)
|
|
21
|
+
ret: SerializableQueryMolecule = SerializableQueryMolecule()
|
|
22
|
+
ret.mol_block = aromatic.molfile()
|
|
23
|
+
ret.smarts = aromatic.smarts()
|
|
24
|
+
ret.render_svg = renderer.renderToString(dearomatic)
|
|
25
|
+
return ret
|
|
26
|
+
|
|
27
|
+
def to_json(self) -> dict[str, Any]:
|
|
28
|
+
"""
|
|
29
|
+
Save the SerializableQueryMolecule to a JSON string.
|
|
30
|
+
:return: A JSON string representation of the query molecule.
|
|
31
|
+
"""
|
|
32
|
+
return {
|
|
33
|
+
"mol_block": self.mol_block,
|
|
34
|
+
"smarts": self.smarts,
|
|
35
|
+
"render_svg": self.render_svg
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SerializableMoleculeMatch:
|
|
40
|
+
"""
|
|
41
|
+
A serializable match that stores and loads a match that can be serialized to JSON.
|
|
42
|
+
"""
|
|
43
|
+
_query_atom_to_atom: dict[int, int]
|
|
44
|
+
_query_bond_to_bond: dict[int, int]
|
|
45
|
+
_query_molecule_file: str
|
|
46
|
+
_matching_molecule_file: str
|
|
47
|
+
_query_molecule: IndigoObject
|
|
48
|
+
_matching_molecule: IndigoObject
|
|
49
|
+
_record_id: int # Only when received from Sapio.
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def record_id(self) -> int:
|
|
53
|
+
"""
|
|
54
|
+
Get the record ID of the match.
|
|
55
|
+
:return: The record ID.
|
|
56
|
+
"""
|
|
57
|
+
return self._record_id
|
|
58
|
+
|
|
59
|
+
def __str__(self):
|
|
60
|
+
return json.dumps(self.to_json())
|
|
61
|
+
|
|
62
|
+
def __hash__(self):
|
|
63
|
+
return hash(self._query_molecule.smarts())
|
|
64
|
+
|
|
65
|
+
def __eq__(self, other):
|
|
66
|
+
if not isinstance(other, SerializableMoleculeMatch):
|
|
67
|
+
return False
|
|
68
|
+
if self._query_atom_to_atom == other._query_atom_to_atom and \
|
|
69
|
+
self._query_bond_to_bond == other._query_bond_to_bond and \
|
|
70
|
+
self._query_molecule_file == other._query_molecule_file and \
|
|
71
|
+
self._matching_molecule_file == other._matching_molecule_file and \
|
|
72
|
+
self._record_id == other._record_id:
|
|
73
|
+
return True
|
|
74
|
+
if self._query_molecule.smarts() != other._query_molecule.smarts():
|
|
75
|
+
return False
|
|
76
|
+
return are_symmetrical_subs(self, other)
|
|
77
|
+
|
|
78
|
+
def mapAtom(self, atom: IndigoObject) -> IndigoObject | None:
|
|
79
|
+
if not self._query_atom_to_atom or atom.index() not in self._query_atom_to_atom:
|
|
80
|
+
return None
|
|
81
|
+
index = self._query_atom_to_atom[atom.index()]
|
|
82
|
+
return self._matching_molecule.getAtom(index)
|
|
83
|
+
|
|
84
|
+
def mapBond(self, bond: IndigoObject) -> IndigoObject | None:
|
|
85
|
+
if not self._query_bond_to_bond or bond.index() not in self._query_bond_to_bond:
|
|
86
|
+
return None
|
|
87
|
+
index = self._query_bond_to_bond[bond.index()]
|
|
88
|
+
return self._matching_molecule.getBond(index)
|
|
89
|
+
|
|
90
|
+
def to_json(self) -> dict[str, Any]:
|
|
91
|
+
"""
|
|
92
|
+
Save the SerializableMoleculeMatch to a JSON string.
|
|
93
|
+
:return: A JSON string representation of the match.
|
|
94
|
+
"""
|
|
95
|
+
return {
|
|
96
|
+
"query_molecule_file": self._query_molecule_file,
|
|
97
|
+
"matching_molecule_file": self._matching_molecule_file,
|
|
98
|
+
"query_atom_to_atom": self._query_atom_to_atom,
|
|
99
|
+
"query_bond_to_bond": self._query_bond_to_bond,
|
|
100
|
+
"record_id": self._record_id
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def from_json(json_dct: dict[str, Any]) -> 'SerializableMoleculeMatch':
|
|
105
|
+
"""
|
|
106
|
+
Load a SerializableMoleculeMatch from a JSON string.
|
|
107
|
+
:param json_dct: A JSON string representation of the match.
|
|
108
|
+
:return: A new SerializableMoleculeMatch instance.
|
|
109
|
+
"""
|
|
110
|
+
smm = SerializableMoleculeMatch()
|
|
111
|
+
smm._query_atom_to_atom = {}
|
|
112
|
+
for key, value in json_dct.get("query_atom_to_atom", {}).items():
|
|
113
|
+
smm._query_atom_to_atom[int(key)] = int(value)
|
|
114
|
+
smm._query_bond_to_bond = {}
|
|
115
|
+
for key, value in json_dct.get("query_bond_to_bond", {}).items():
|
|
116
|
+
smm._query_bond_to_bond[int(key)] = int(value)
|
|
117
|
+
smm._query_molecule_file = json_dct.get("query_molecule_file")
|
|
118
|
+
smm._matching_molecule_file = json_dct.get("matching_molecule_file")
|
|
119
|
+
smm._query_molecule = indigo.loadQueryMolecule(smm._query_molecule_file)
|
|
120
|
+
smm._matching_molecule = indigo.loadMolecule(smm._matching_molecule_file)
|
|
121
|
+
smm._record_id = json_dct.get("record_id", 0) # Default to 0 if not present
|
|
122
|
+
return smm
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def create(query_molecule: IndigoObject, matching_molecule: IndigoObject,
|
|
126
|
+
match: IndigoObject) -> 'SerializableMoleculeMatch':
|
|
127
|
+
"""
|
|
128
|
+
Create a SerializableMoleculeMatch from a query molecule, matching molecule, and match.
|
|
129
|
+
:param query_molecule: The query molecule.
|
|
130
|
+
:param matching_molecule: The matching molecule.
|
|
131
|
+
:param match: The match object containing atom mappings.
|
|
132
|
+
:return: A new SerializableMoleculeMatch instance.
|
|
133
|
+
"""
|
|
134
|
+
smm = SerializableMoleculeMatch()
|
|
135
|
+
smm._query_atom_to_atom = {}
|
|
136
|
+
smm._query_bond_to_bond = {}
|
|
137
|
+
smm._query_molecule = query_molecule.clone()
|
|
138
|
+
smm._matching_molecule = matching_molecule.clone()
|
|
139
|
+
smm._query_molecule_file = query_molecule.molfile()
|
|
140
|
+
smm._matching_molecule_file = matching_molecule.molfile()
|
|
141
|
+
smm._record_id = 0
|
|
142
|
+
|
|
143
|
+
for qatom in query_molecule.iterateAtoms():
|
|
144
|
+
concrete_atom = match.mapAtom(qatom)
|
|
145
|
+
if concrete_atom is None:
|
|
146
|
+
continue
|
|
147
|
+
smm._query_atom_to_atom[qatom.index()] = concrete_atom.index()
|
|
148
|
+
|
|
149
|
+
for qbond in query_molecule.iterateBonds():
|
|
150
|
+
concrete_bond = match.mapBond(qbond)
|
|
151
|
+
if concrete_bond is None:
|
|
152
|
+
continue
|
|
153
|
+
smm._query_bond_to_bond[qbond.index()] = concrete_bond.index()
|
|
154
|
+
return smm
|
|
155
|
+
|
|
156
|
+
def get_matched_molecule_copy(self):
|
|
157
|
+
return self._matching_molecule.clone()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class ReplacementReaction:
|
|
162
|
+
"""
|
|
163
|
+
A replacement reaction stores reactio template with 1 reactant replaced by specific user match.
|
|
164
|
+
"""
|
|
165
|
+
reaction: IndigoObject
|
|
166
|
+
reaction_reactant: IndigoObject
|
|
167
|
+
replacement_reactant: IndigoObject
|
|
168
|
+
replacement_query_reaction_match: SerializableMoleculeMatch
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# noinspection PyProtectedMember
|
|
172
|
+
def highlight_mol_substructure_serial_match(molecule: IndigoObject, serializable_match: SerializableMoleculeMatch):
|
|
173
|
+
"""
|
|
174
|
+
Highlight the substructure in the molecule based on the SerializableMoleculeMatch.
|
|
175
|
+
:param molecule: The molecule to highlight.
|
|
176
|
+
:param serializable_match: The SerializableMoleculeMatch containing atom mappings.
|
|
177
|
+
"""
|
|
178
|
+
for qatom in serializable_match._query_molecule.iterateAtoms():
|
|
179
|
+
atom = serializable_match.mapAtom(qatom)
|
|
180
|
+
if atom is None:
|
|
181
|
+
continue
|
|
182
|
+
atom.highlight()
|
|
183
|
+
|
|
184
|
+
for nei in atom.iterateNeighbors():
|
|
185
|
+
if not nei.isPseudoatom() and not nei.isRSite() and nei.atomicNumber() == 1:
|
|
186
|
+
nei.highlight()
|
|
187
|
+
nei.bond().highlight()
|
|
188
|
+
|
|
189
|
+
for bond in serializable_match._query_molecule.iterateBonds():
|
|
190
|
+
bond = serializable_match.mapBond(bond)
|
|
191
|
+
if bond is None:
|
|
192
|
+
continue
|
|
193
|
+
bond.highlight()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def clear_highlights(molecule: IndigoObject):
|
|
197
|
+
"""
|
|
198
|
+
Clear all highlights in the molecule.
|
|
199
|
+
:param molecule: The molecule to clear highlights from.
|
|
200
|
+
"""
|
|
201
|
+
for atom in molecule.iterateAtoms():
|
|
202
|
+
atom.unhighlight()
|
|
203
|
+
for bond in molecule.iterateBonds():
|
|
204
|
+
bond.unhighlight()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def clear_reaction_highlights(reaction: IndigoObject):
|
|
208
|
+
"""
|
|
209
|
+
Clear all highlights in the reaction.
|
|
210
|
+
:param reaction: The reaction to clear highlights from.
|
|
211
|
+
"""
|
|
212
|
+
for reactant in reaction.iterateReactants():
|
|
213
|
+
clear_highlights(reactant)
|
|
214
|
+
for product in reaction.iterateProducts():
|
|
215
|
+
clear_highlights(product)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def reserve_atom_mapping_number_of_search_result(q_reaction: IndigoObject, q_reactant: IndigoObject,
|
|
219
|
+
new_reaction_reactant: IndigoObject, new_reaction: IndigoObject,
|
|
220
|
+
sub_match: SerializableMoleculeMatch) -> None:
|
|
221
|
+
"""
|
|
222
|
+
Set the atom mapping number on the query molecule based on the atom mapping number of the sub_match molecule, if it exists.
|
|
223
|
+
:param new_reaction: The new reaction where the new reaction's reactant is found. This will be the target reaciton to write AAM to.
|
|
224
|
+
:param new_reaction_reactant: The new reaction's reactant where the AAM will be written to.
|
|
225
|
+
:param q_reactant: The query reactant from the query reaction that is being matched.
|
|
226
|
+
:param q_reaction: The query reaction that contains the query reactant for the sub_match.
|
|
227
|
+
:param sub_match: The substructure search match obtained from indigo.substructureMatcher(mol).match(query).
|
|
228
|
+
"""
|
|
229
|
+
for query_atom in q_reactant.iterateAtoms():
|
|
230
|
+
concrete_atom = sub_match.mapAtom(query_atom)
|
|
231
|
+
if concrete_atom is None:
|
|
232
|
+
continue
|
|
233
|
+
reaction_atom = q_reactant.getAtom(query_atom.index())
|
|
234
|
+
map_num = q_reaction.atomMappingNumber(reaction_atom)
|
|
235
|
+
if map_num:
|
|
236
|
+
concrete_atom = new_reaction_reactant.getAtom(concrete_atom.index())
|
|
237
|
+
new_reaction.setAtomMappingNumber(concrete_atom, map_num)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def clean_product_aam(reaction: IndigoObject):
|
|
241
|
+
"""
|
|
242
|
+
Remove atom mappings from product that are not present in the reactants.
|
|
243
|
+
"""
|
|
244
|
+
existing_mapping_numbers = set()
|
|
245
|
+
for reactant in reaction.iterateReactants():
|
|
246
|
+
for atom in reactant.iterateAtoms():
|
|
247
|
+
map_num = reaction.atomMappingNumber(atom)
|
|
248
|
+
if map_num:
|
|
249
|
+
existing_mapping_numbers.add(map_num)
|
|
250
|
+
|
|
251
|
+
for product in reaction.iterateProducts():
|
|
252
|
+
for atom in product.iterateAtoms():
|
|
253
|
+
map_num = reaction.atomMappingNumber(atom)
|
|
254
|
+
if map_num and map_num not in existing_mapping_numbers:
|
|
255
|
+
reaction.setAtomMappingNumber(atom, 0) # YQ: atom number 0 means no mapping number in Indigo
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def make_concrete_reaction(reactants: list[IndigoObject], products: list[IndigoObject], replacement: IndigoObject,
|
|
259
|
+
replacement_index: int) -> tuple[IndigoObject, IndigoObject]:
|
|
260
|
+
"""
|
|
261
|
+
Create a concrete reaction from the given reactants and products, replacing the specified reactant with the replacement molecule.
|
|
262
|
+
:param reactants: List of reactant molecules.
|
|
263
|
+
:param products: List of product molecules.
|
|
264
|
+
:param replacement: The molecule to replace in the reactants.
|
|
265
|
+
:param replacement_index: The index of the reactant to replace.
|
|
266
|
+
:return: A new IndigoObject representing the concrete reaction.
|
|
267
|
+
"""
|
|
268
|
+
concrete_reaction = indigo.createQueryReaction()
|
|
269
|
+
for i, reactant in enumerate(reactants):
|
|
270
|
+
if i == replacement_index:
|
|
271
|
+
concrete_reaction.addReactant(indigo.loadQueryMolecule(replacement.molfile()))
|
|
272
|
+
else:
|
|
273
|
+
concrete_reaction.addReactant(reactant.clone())
|
|
274
|
+
for product in products:
|
|
275
|
+
concrete_reaction.addProduct(product.clone())
|
|
276
|
+
return concrete_reaction, concrete_reaction.getMolecule(replacement_index)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def is_ambiguous_atom(atom: IndigoObject) -> bool:
|
|
280
|
+
"""
|
|
281
|
+
Test whether the symbol is an adjacent matching wildcard.
|
|
282
|
+
"""
|
|
283
|
+
if atom.isPseudoatom() or atom.isRSite():
|
|
284
|
+
return True
|
|
285
|
+
symbol = atom.symbol()
|
|
286
|
+
if symbol in {'A', 'Q', 'X', 'M', 'AH', 'QH', 'XH', 'MH', 'NOT', 'R', '*'}:
|
|
287
|
+
return True
|
|
288
|
+
return "[" in symbol and "]" in symbol
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def get_react_site_highlights(product, ignored_atom_indexes):
|
|
292
|
+
"""
|
|
293
|
+
Get the highlights for the reaction site in the product, ignoring the atoms that are not part of the reaction site.
|
|
294
|
+
:param product: The product molecule.
|
|
295
|
+
:param ignored_atom_indexes: A set of atom indexes to ignore.
|
|
296
|
+
:return: An IndigoObject with highlighted atoms and bonds that are part of the reaction site.
|
|
297
|
+
"""
|
|
298
|
+
highlight = product.clone()
|
|
299
|
+
for atom in highlight.iterateAtoms():
|
|
300
|
+
if atom.index() not in ignored_atom_indexes:
|
|
301
|
+
atom.highlight()
|
|
302
|
+
for nei in atom.iterateNeighbors():
|
|
303
|
+
if nei.index() not in ignored_atom_indexes:
|
|
304
|
+
nei.highlight()
|
|
305
|
+
nei.bond().highlight()
|
|
306
|
+
return highlight
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def inherit_auto_map_by_match(target_reaction: IndigoObject, source_reaction: IndigoObject,
|
|
310
|
+
reaction_match: IndigoObject):
|
|
311
|
+
"""
|
|
312
|
+
Inherit the auto-mapping from the source reaction to the target reaction based on the reaction match.
|
|
313
|
+
:param target_reaction: The target reaction to inherit auto-mapping to.
|
|
314
|
+
:param source_reaction: The source reaction to inherit auto-mapping from.
|
|
315
|
+
:param reaction_match: The match object that maps atoms and bonds between the source and target reactions.
|
|
316
|
+
"""
|
|
317
|
+
source_molecules = []
|
|
318
|
+
for q_reactant in source_reaction.iterateReactants():
|
|
319
|
+
source_molecules.append(q_reactant)
|
|
320
|
+
for q_product in source_reaction.iterateProducts():
|
|
321
|
+
source_molecules.append(q_product)
|
|
322
|
+
for source_molecule in source_molecules:
|
|
323
|
+
for source_atom in source_molecule.iterateAtoms():
|
|
324
|
+
source_atom_map_number = source_reaction.atomMappingNumber(source_atom)
|
|
325
|
+
if source_atom_map_number == 0:
|
|
326
|
+
continue
|
|
327
|
+
target_atom = reaction_match.mapAtom(source_atom)
|
|
328
|
+
if target_atom:
|
|
329
|
+
target_reaction.setAtomMappingNumber(target_atom, source_atom_map_number)
|
|
330
|
+
target_reaction.automap("keep")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def get_used_reactants_for_match(
|
|
334
|
+
reaction: IndigoObject, q_reaction: IndigoObject, reaction_match: IndigoObject,
|
|
335
|
+
kept_replacement_reaction_list_list: list[list[ReplacementReaction]]) -> list[ReplacementReaction]:
|
|
336
|
+
"""
|
|
337
|
+
Find the replacement reactions that correspond to the reactants in reaction that also matches the query reaction.
|
|
338
|
+
Return None if any of the reactants do not have a corresponding replacement reaction, even though reaction may have matches directly to the query reaction.
|
|
339
|
+
Otherwise, return a list of ReplacementReaction objects that correspond to the reactants in the reaction ordered by the reactants in the query reaction.
|
|
340
|
+
"""
|
|
341
|
+
q_reactants = []
|
|
342
|
+
for q_reactant in q_reaction.iterateReactants():
|
|
343
|
+
q_reactants.append(q_reactant)
|
|
344
|
+
q_products = []
|
|
345
|
+
for rr_product in q_reaction.iterateProducts():
|
|
346
|
+
q_products.append(rr_product)
|
|
347
|
+
reactants = []
|
|
348
|
+
for enum_r in reaction.iterateReactants():
|
|
349
|
+
reactants.append(enum_r)
|
|
350
|
+
products = []
|
|
351
|
+
for enum_p in reaction.iterateProducts():
|
|
352
|
+
products.append(enum_p)
|
|
353
|
+
q_reactant: IndigoObject
|
|
354
|
+
ret: list[ReplacementReaction] = []
|
|
355
|
+
for reactant_index, q_reactant in enumerate(q_reactants):
|
|
356
|
+
replacement_list = kept_replacement_reaction_list_list[reactant_index]
|
|
357
|
+
enum_r = reactants[reactant_index]
|
|
358
|
+
useful_enumr_atom_indexes = set()
|
|
359
|
+
for q_atom in q_reactant.iterateAtoms():
|
|
360
|
+
enum_atom = reaction_match.mapAtom(q_atom)
|
|
361
|
+
if enum_atom:
|
|
362
|
+
useful_enumr_atom_indexes.add(enum_atom.index())
|
|
363
|
+
found: ReplacementReaction | None = None
|
|
364
|
+
for rr_index, rr in enumerate(replacement_list):
|
|
365
|
+
exact_match = indigo.exactMatch(rr.replacement_reactant, enum_r)
|
|
366
|
+
if not exact_match:
|
|
367
|
+
# YQ Skip if this enumeration is not meant to be the same reactant as replacement we are iterating.
|
|
368
|
+
continue
|
|
369
|
+
query_reactant_atom_by_index: dict[int, IndigoObject] = {}
|
|
370
|
+
rr_reactant_atom_by_index: dict[int, IndigoObject] = {}
|
|
371
|
+
query_reactant_index_to_rr_reactant_index: dict[int, int] = {}
|
|
372
|
+
rr_reactant_index_to_query_reactant_index: dict[int, int] = {}
|
|
373
|
+
enum_r_atom_mapping_number_to_rr_atom: dict[int, IndigoObject] = {}
|
|
374
|
+
q_reaction_atom_mapping_number_to_rr_atom: dict[int, IndigoObject] = {}
|
|
375
|
+
q_r_site_to_rr_atom: dict[str, IndigoObject] = {}
|
|
376
|
+
for q_atom in q_reactant.iterateAtoms():
|
|
377
|
+
query_reactant_atom_by_index[q_atom.index()] = q_atom
|
|
378
|
+
rr_atom = rr.replacement_query_reaction_match.mapAtom(q_atom)
|
|
379
|
+
if rr_atom:
|
|
380
|
+
query_reactant_index_to_rr_reactant_index[q_atom.index()] = rr_atom.index()
|
|
381
|
+
rr_reactant_index_to_query_reactant_index[rr_atom.index()] = q_atom.index()
|
|
382
|
+
q_reaction_atom_mapping_number = q_reaction.atomMappingNumber(q_atom)
|
|
383
|
+
if q_reaction_atom_mapping_number > 0:
|
|
384
|
+
q_reaction_atom_mapping_number_to_rr_atom[q_reaction_atom_mapping_number] = rr_atom
|
|
385
|
+
if q_atom.isRSite():
|
|
386
|
+
r_site = q_atom.symbol()
|
|
387
|
+
q_r_site_to_rr_atom[r_site] = rr_atom
|
|
388
|
+
for rr_atom in rr.replacement_reactant.iterateAtoms():
|
|
389
|
+
rr_reactant_atom_by_index[rr_atom.index()] = rr_atom
|
|
390
|
+
enum_r_atom = exact_match.mapAtom(rr_atom)
|
|
391
|
+
if enum_r_atom:
|
|
392
|
+
enum_r_atom_mapping_number = reaction.atomMappingNumber(enum_r_atom)
|
|
393
|
+
if enum_r_atom_mapping_number > 0:
|
|
394
|
+
enum_r_atom_mapping_number_to_rr_atom[enum_r_atom_mapping_number] = rr_atom
|
|
395
|
+
|
|
396
|
+
rr_products = []
|
|
397
|
+
for rr_product in rr.reaction.iterateProducts():
|
|
398
|
+
rr_products.append(rr_product)
|
|
399
|
+
still_valid_rr = True
|
|
400
|
+
for product_index, enum_product in enumerate(products):
|
|
401
|
+
if not still_valid_rr:
|
|
402
|
+
break
|
|
403
|
+
query_product = q_products[product_index]
|
|
404
|
+
enum_r_atom_mapping_number_to_q_product_atom = {}
|
|
405
|
+
for q_atom in query_product.iterateAtoms():
|
|
406
|
+
enum_atom = reaction_match.mapAtom(q_atom)
|
|
407
|
+
if enum_atom:
|
|
408
|
+
enum_mapping_number = reaction.atomMappingNumber(enum_atom)
|
|
409
|
+
if enum_mapping_number > 0:
|
|
410
|
+
enum_r_atom_mapping_number_to_q_product_atom[enum_mapping_number] = q_atom
|
|
411
|
+
|
|
412
|
+
for enum_atom in enum_product.iterateAtoms():
|
|
413
|
+
enum_mapping_number = reaction.atomMappingNumber(enum_atom)
|
|
414
|
+
if enum_mapping_number == 0:
|
|
415
|
+
continue
|
|
416
|
+
rr_atom = enum_r_atom_mapping_number_to_rr_atom.get(enum_mapping_number)
|
|
417
|
+
if not rr_atom:
|
|
418
|
+
continue
|
|
419
|
+
q_product_atom: IndigoObject = enum_r_atom_mapping_number_to_q_product_atom.get(enum_mapping_number)
|
|
420
|
+
if not q_product_atom:
|
|
421
|
+
continue
|
|
422
|
+
if q_product_atom.isRSite():
|
|
423
|
+
r_site = q_product_atom.symbol()
|
|
424
|
+
rr_atom_r_site = q_r_site_to_rr_atom.get(r_site)
|
|
425
|
+
if not rr_atom_r_site:
|
|
426
|
+
still_valid_rr = False
|
|
427
|
+
break
|
|
428
|
+
if rr_atom.index() != rr_atom_r_site.index():
|
|
429
|
+
still_valid_rr = False
|
|
430
|
+
break
|
|
431
|
+
else:
|
|
432
|
+
q_product_atom_mapping_number = q_reaction.atomMappingNumber(q_product_atom)
|
|
433
|
+
if q_product_atom_mapping_number == 0:
|
|
434
|
+
continue
|
|
435
|
+
query_reactant_atom_index = rr_reactant_index_to_query_reactant_index.get(rr_atom.index())
|
|
436
|
+
if query_reactant_atom_index is None:
|
|
437
|
+
still_valid_rr = False
|
|
438
|
+
break
|
|
439
|
+
query_reactant_atom = query_reactant_atom_by_index.get(query_reactant_atom_index)
|
|
440
|
+
query_reactant_atom_mapping_number = q_reaction.atomMappingNumber(query_reactant_atom)
|
|
441
|
+
if q_product_atom_mapping_number != query_reactant_atom_mapping_number:
|
|
442
|
+
still_valid_rr = False
|
|
443
|
+
break
|
|
444
|
+
if still_valid_rr:
|
|
445
|
+
found = rr
|
|
446
|
+
break
|
|
447
|
+
if found:
|
|
448
|
+
ret.append(found)
|
|
449
|
+
else:
|
|
450
|
+
return []
|
|
451
|
+
return ret
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def are_symmetrical_subs(match1: SerializableMoleculeMatch, match2: SerializableMoleculeMatch) -> bool:
|
|
455
|
+
"""
|
|
456
|
+
Check if two SerializableMoleculeMatch objects are symmetrical.
|
|
457
|
+
That is, if we only get the atoms and bonds in the mapping, the two molecules are identical.
|
|
458
|
+
:param match1: The first SerializableMoleculeMatch object.
|
|
459
|
+
:param match2: The second SerializableMoleculeMatch object.
|
|
460
|
+
:return: True if the matches are symmetrical, False otherwise.
|
|
461
|
+
"""
|
|
462
|
+
match1_test = match1.get_matched_molecule_copy()
|
|
463
|
+
match1_atom_indexes = set(match1._query_atom_to_atom.values())
|
|
464
|
+
match1_bond_indexes = set(match1._query_bond_to_bond.values())
|
|
465
|
+
atom_delete_list: list[int] = []
|
|
466
|
+
atom_mirror_list: list[int] = []
|
|
467
|
+
bond_delete_list: list[int] = []
|
|
468
|
+
bond_mirror_list: list[int] = []
|
|
469
|
+
for atom in match1_test.iterateAtoms():
|
|
470
|
+
if atom.index() not in match1_atom_indexes:
|
|
471
|
+
atom_delete_list.append(atom.index())
|
|
472
|
+
else:
|
|
473
|
+
atom_mirror_list.append(atom.index())
|
|
474
|
+
for bond in match1_test.iterateBonds():
|
|
475
|
+
if bond.index() not in match1_bond_indexes:
|
|
476
|
+
bond_delete_list.append(bond.index())
|
|
477
|
+
else:
|
|
478
|
+
bond_mirror_list.append(bond.index())
|
|
479
|
+
match1_test.removeBonds(bond_delete_list)
|
|
480
|
+
match1_test.removeAtoms(atom_delete_list)
|
|
481
|
+
match1_mirror_test = match1.get_matched_molecule_copy()
|
|
482
|
+
match1_mirror_test.removeBonds(bond_mirror_list)
|
|
483
|
+
match1_mirror_test.removeAtoms(atom_mirror_list)
|
|
484
|
+
|
|
485
|
+
match2_test = match2.get_matched_molecule_copy()
|
|
486
|
+
match2_atom_indexes = set(match2._query_atom_to_atom.values())
|
|
487
|
+
match2_bond_indexes = set(match2._query_bond_to_bond.values())
|
|
488
|
+
atom_delete_list = []
|
|
489
|
+
bond_delete_list = []
|
|
490
|
+
atom_mirror_list = []
|
|
491
|
+
bond_mirror_list = []
|
|
492
|
+
for atom in match2_test.iterateAtoms():
|
|
493
|
+
if atom.index() not in match2_atom_indexes:
|
|
494
|
+
atom_delete_list.append(atom.index())
|
|
495
|
+
else:
|
|
496
|
+
atom_mirror_list.append(atom.index())
|
|
497
|
+
for bond in match2_test.iterateBonds():
|
|
498
|
+
if bond.index() not in match2_bond_indexes:
|
|
499
|
+
bond_delete_list.append(bond.index())
|
|
500
|
+
else:
|
|
501
|
+
bond_mirror_list.append(bond.index())
|
|
502
|
+
match2_test.removeBonds(bond_delete_list)
|
|
503
|
+
match2_test.removeAtoms(atom_delete_list)
|
|
504
|
+
match2_mirror_test = match2.get_matched_molecule_copy()
|
|
505
|
+
match2_mirror_test.removeBonds(bond_mirror_list)
|
|
506
|
+
match2_mirror_test.removeAtoms(atom_mirror_list)
|
|
507
|
+
|
|
508
|
+
return match1_test.canonicalSmiles() == match2_test.canonicalSmiles() and \
|
|
509
|
+
match1_mirror_test.canonicalSmiles() == match2_mirror_test.canonicalSmiles()
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def replace_r_site_with_wildcards(mol: IndigoObject) -> IndigoObject:
|
|
513
|
+
"""
|
|
514
|
+
This will be used to replace molecule's R sites with wildcard *.
|
|
515
|
+
The substructure matcher at molecular level will not touch R sites. Therefore if we are to preserve mapping with bonds we need to replace R sites with wildcards.
|
|
516
|
+
:param mol: The molecule to process.
|
|
517
|
+
:return: A cloned molecule with R sites replaced by wildcards.
|
|
518
|
+
"""
|
|
519
|
+
ret = mol.clone()
|
|
520
|
+
for atom in ret.iterateAtoms():
|
|
521
|
+
if atom.isRSite():
|
|
522
|
+
atom.resetAtom("*")
|
|
523
|
+
return ret
|
|
@@ -6,6 +6,7 @@ from sapiopylib.rest.CustomReportService import CustomReportManager
|
|
|
6
6
|
from sapiopylib.rest.DataMgmtService import DataMgmtServer
|
|
7
7
|
from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria, CustomReport, RawReportTerm, ReportColumn
|
|
8
8
|
from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType
|
|
9
|
+
# noinspection PyProtectedMember
|
|
9
10
|
from sapiopylib.rest.utils.autopaging import SapioPyAutoPager, PagerResultCriteriaType, _default_report_page_size, \
|
|
10
11
|
_default_record_page_size
|
|
11
12
|
from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
|
|
@@ -61,6 +62,12 @@ class CustomReportDictAutoPager(_DictReportPagerBase):
|
|
|
61
62
|
def __init__(self, user: UserIdentifier, report_criteria: CustomReportCriteria,
|
|
62
63
|
page_number: int = 0, page_size: int = _default_report_page_size):
|
|
63
64
|
"""
|
|
65
|
+
IMPORTANT NOTICE: Custom reports that are not single data type (i.e. they have terms or columns from multiple
|
|
66
|
+
data types) may not be 100% time accurate. Such reports use the system's ancestor table to retrieve the
|
|
67
|
+
relationships, and this table takes some time to update after relationships are updated, especially for more
|
|
68
|
+
populous data types. If you need 100% time accurate results to the current state of the records and
|
|
69
|
+
relationships in the database, you should query for the records directly instead of using a custom report.
|
|
70
|
+
|
|
64
71
|
:param user: The current webhook context or a user object to send requests from.
|
|
65
72
|
:param report_criteria: The custom report criteria to run.
|
|
66
73
|
:param page_number: The page number to start on. The first page is page 0.
|
|
@@ -82,6 +89,12 @@ class SystemReportDictAutoPager(_DictReportPagerBase):
|
|
|
82
89
|
def __init__(self, user: UserIdentifier, report_name: str,
|
|
83
90
|
page_number: int = 0, page_size: int = _default_report_page_size):
|
|
84
91
|
"""
|
|
92
|
+
IMPORTANT NOTICE: Custom reports that are not single data type (i.e. they have terms or columns from multiple
|
|
93
|
+
data types) may not be 100% time accurate. Such reports use the system's ancestor table to retrieve the
|
|
94
|
+
relationships, and this table takes some time to update after relationships are updated, especially for more
|
|
95
|
+
populous data types. If you need 100% time accurate results to the current state of the records and
|
|
96
|
+
relationships in the database, you should query for the records directly instead of using a custom report.
|
|
97
|
+
|
|
85
98
|
:param user: The current webhook context or a user object to send requests from.
|
|
86
99
|
:param report_name: The name of the system report to run.
|
|
87
100
|
:param page_number: The page number to start on. The first page is page 0.
|
|
@@ -153,7 +166,7 @@ class _RecordReportPagerBase(SapioPyAutoPager[CustomReportCriteria, WrappedType
|
|
|
153
166
|
if id_index == -1:
|
|
154
167
|
raise SapioException(f"This report does not contain a Record ID column for the given record model type "
|
|
155
168
|
f"{self._data_type}.")
|
|
156
|
-
ids:
|
|
169
|
+
ids: set[int] = {row[id_index] for row in report.result_table}
|
|
157
170
|
for row in self._rec_handler.query_models_by_id(self._query_type, ids, page_size=report.page_size):
|
|
158
171
|
queue.put(row)
|
|
159
172
|
if report.has_next_page:
|
|
@@ -172,6 +185,12 @@ class CustomReportRecordAutoPager(_RecordReportPagerBase):
|
|
|
172
185
|
wrapper_type: type[WrappedType] | str, page_number: int = 0,
|
|
173
186
|
page_size: int = _default_record_page_size):
|
|
174
187
|
"""
|
|
188
|
+
IMPORTANT NOTICE: Custom reports that are not single data type (i.e. they have terms or columns from multiple
|
|
189
|
+
data types) may not be 100% time accurate. Such reports use the system's ancestor table to retrieve the
|
|
190
|
+
relationships, and this table takes some time to update after relationships are updated, especially for more
|
|
191
|
+
populous data types. If you need 100% time accurate results to the current state of the records and
|
|
192
|
+
relationships in the database, you should query for the records directly instead of using a custom report.
|
|
193
|
+
|
|
175
194
|
:param user: The current webhook context or a user object to send requests from.
|
|
176
195
|
:param report_criteria: The custom report criteria to run.
|
|
177
196
|
:param wrapper_type: The record model wrapper type or data type name of the records being searched for.
|
|
@@ -197,6 +216,12 @@ class SystemReportRecordAutoPager(_RecordReportPagerBase):
|
|
|
197
216
|
def __init__(self, user: UserIdentifier, report_name: str, wrapper_type: type[WrappedType] | str,
|
|
198
217
|
page_number: int = 0, page_size: int = _default_record_page_size):
|
|
199
218
|
"""
|
|
219
|
+
IMPORTANT NOTICE: Custom reports that are not single data type (i.e. they have terms or columns from multiple
|
|
220
|
+
data types) may not be 100% time accurate. Such reports use the system's ancestor table to retrieve the
|
|
221
|
+
relationships, and this table takes some time to update after relationships are updated, especially for more
|
|
222
|
+
populous data types. If you need 100% time accurate results to the current state of the records and
|
|
223
|
+
relationships in the database, you should query for the records directly instead of using a custom report.
|
|
224
|
+
|
|
200
225
|
:param user: The current webhook context or a user object to send requests from.
|
|
201
226
|
:param report_name: The name of the system report to run.
|
|
202
227
|
:param wrapper_type: The record model wrapper type or data type name of the records being searched for.
|