sapiopycommons 2025.3.21a458__py3-none-any.whl → 2025.3.25a459__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/ai/__init__.py +0 -0
- sapiopycommons/ai/tool_of_tools.py +917 -0
- sapiopycommons/callbacks/callback_util.py +25 -17
- sapiopycommons/customreport/auto_pagers.py +28 -18
- sapiopycommons/datatype/attachment_util.py +4 -2
- sapiopycommons/datatype/data_fields.py +22 -0
- sapiopycommons/eln/experiment_handler.py +1112 -184
- sapiopycommons/eln/experiment_report_util.py +8 -3
- sapiopycommons/eln/experiment_tags.py +7 -0
- sapiopycommons/eln/plate_designer.py +159 -59
- sapiopycommons/general/html_formatter.py +456 -0
- sapiopycommons/general/sapio_links.py +12 -4
- sapiopycommons/processtracking/custom_workflow_handler.py +42 -27
- sapiopycommons/recordmodel/record_handler.py +187 -130
- sapiopycommons/rules/eln_rule_handler.py +33 -29
- sapiopycommons/rules/on_save_rule_handler.py +33 -29
- {sapiopycommons-2025.3.21a458.dist-info → sapiopycommons-2025.3.25a459.dist-info}/METADATA +1 -1
- {sapiopycommons-2025.3.21a458.dist-info → sapiopycommons-2025.3.25a459.dist-info}/RECORD +20 -16
- {sapiopycommons-2025.3.21a458.dist-info → sapiopycommons-2025.3.25a459.dist-info}/WHEEL +0 -0
- {sapiopycommons-2025.3.21a458.dist-info → sapiopycommons-2025.3.25a459.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HtmlFormatter:
|
|
8
|
+
"""
|
|
9
|
+
A class for formatting text in HTML with tag classes supported by the client.
|
|
10
|
+
"""
|
|
11
|
+
TIMESTAMP_TEXT__CSS_CLASS_NAME: Final[str] = "timestamp-text"
|
|
12
|
+
HEADER_1_TEXT__CSS_CLASS_NAME: Final[str] = "header1-text"
|
|
13
|
+
HEADER_2_TEXT__CSS_CLASS_NAME: Final[str] = "header2-text"
|
|
14
|
+
HEADER_3_TEXT__CSS_CLASS_NAME: Final[str] = "header3-text"
|
|
15
|
+
BODY_TEXT__CSS_CLASS_NAME: Final[str] = "body-text"
|
|
16
|
+
CAPTION_TEXT__CSS_CLASS_NAME: Final[str] = "caption-text"
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def timestamp(text: str) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Given a text string, return that same text string HTML formatted using the timestamp CSS class.
|
|
22
|
+
|
|
23
|
+
:param text: The text to format.
|
|
24
|
+
:return: The HTML formatted text.
|
|
25
|
+
"""
|
|
26
|
+
return f'<span class="{HtmlFormatter.TIMESTAMP_TEXT__CSS_CLASS_NAME}">{text}</span>'
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def header_1(text: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Given a text string, return that same text string HTML formatted using the header 1 CSS class.
|
|
32
|
+
|
|
33
|
+
:param text: The text to format.
|
|
34
|
+
:return: The HTML formatted text.
|
|
35
|
+
"""
|
|
36
|
+
return f'<span class="{HtmlFormatter.HEADER_1_TEXT__CSS_CLASS_NAME}">{text}</span>'
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def header_2(text: str) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Given a text string, return that same text string HTML formatted using the header 2 CSS class.
|
|
42
|
+
|
|
43
|
+
:param text: The text to format.
|
|
44
|
+
:return: The HTML formatted text.
|
|
45
|
+
"""
|
|
46
|
+
return f'<span class="{HtmlFormatter.HEADER_2_TEXT__CSS_CLASS_NAME}">{text}</span>'
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def header_3(text: str) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Given a text string, return that same text string HTML formatted using the header 3 CSS class.
|
|
52
|
+
|
|
53
|
+
:param text: The text to format.
|
|
54
|
+
:return: The HTML formatted text.
|
|
55
|
+
"""
|
|
56
|
+
return f'<span class="{HtmlFormatter.HEADER_3_TEXT__CSS_CLASS_NAME}">{text}</span>'
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def body(text: str) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Given a text string, return that same text string HTML formatted using the body text CSS class.
|
|
62
|
+
|
|
63
|
+
:param text: The text to format.
|
|
64
|
+
:return: The HTML formatted text.
|
|
65
|
+
"""
|
|
66
|
+
return f'<span class="{HtmlFormatter.BODY_TEXT__CSS_CLASS_NAME}">{text}</span>'
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def caption(text: str) -> str:
|
|
70
|
+
"""
|
|
71
|
+
Given a text string, return that same text string HTML formatted using the caption text CSS class.
|
|
72
|
+
|
|
73
|
+
:param text: The text to format.
|
|
74
|
+
:return: The HTML formatted text.
|
|
75
|
+
"""
|
|
76
|
+
return f'<span class="{HtmlFormatter.CAPTION_TEXT__CSS_CLASS_NAME}">{text}</span>'
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def replace_newlines(text: str) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Given a text string, return that same text string HTML formatted with newlines replaced by HTML line breaks.
|
|
82
|
+
|
|
83
|
+
:param text: The text to format.
|
|
84
|
+
:return: The HTML formatted text.
|
|
85
|
+
"""
|
|
86
|
+
return re.sub("\r?\n", "<br>", text)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def scrub_html(text: str) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Given a string that contains HTML, return that same string with all HTML removed.
|
|
92
|
+
|
|
93
|
+
:param text: The HTML string to scrub.
|
|
94
|
+
:return: The scrubbed text.
|
|
95
|
+
"""
|
|
96
|
+
if not text:
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
from bs4 import BeautifulSoup
|
|
100
|
+
return BeautifulSoup(text, "html.parser").get_text()
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def scrub_markdown(text: str) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Given a string that contains markdown, return that same string with all markdown removed.
|
|
106
|
+
|
|
107
|
+
:param text: The markdown string to scrub.
|
|
108
|
+
:return: The scrubbed text.
|
|
109
|
+
"""
|
|
110
|
+
if not text:
|
|
111
|
+
return ""
|
|
112
|
+
|
|
113
|
+
# --- Remove Headers ---
|
|
114
|
+
# Level 1-6 headers (# to ######)
|
|
115
|
+
text = re.sub(r"^#{1,6}\s*(.*)$", r"\1", text, flags=re.MULTILINE).strip()
|
|
116
|
+
|
|
117
|
+
# --- Remove Emphasis ---
|
|
118
|
+
# Bold (**text** or __text__)
|
|
119
|
+
text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
|
|
120
|
+
text = re.sub(r"__(.*?)__", r"\1", text)
|
|
121
|
+
|
|
122
|
+
# Italic (*text* or _text_)
|
|
123
|
+
text = re.sub(r"\*(.*?)\*", r"\1", text)
|
|
124
|
+
text = re.sub(r"_(.*?)_", r"\1", text)
|
|
125
|
+
|
|
126
|
+
# --- Remove Strikethrough ---
|
|
127
|
+
# Strikethrough (~~text~~)
|
|
128
|
+
text = re.sub(r"~~(.*?)~~", r"\1", text)
|
|
129
|
+
|
|
130
|
+
# --- Remove Links ---
|
|
131
|
+
# Links ([text](url))
|
|
132
|
+
text = re.sub(r"\[(.*?)]\((.*?)\)", r"\1", text)
|
|
133
|
+
|
|
134
|
+
# --- Remove Images ---
|
|
135
|
+
# Images ()
|
|
136
|
+
text = re.sub(r"!\\[(.*?)\\]\\((.*?)\\)", "", text) # remove the entire image tag
|
|
137
|
+
|
|
138
|
+
# --- Remove Code ---
|
|
139
|
+
# Inline code (`code`)
|
|
140
|
+
text = re.sub(r"`(.*?)`", r"\1", text)
|
|
141
|
+
|
|
142
|
+
# Code blocks (```code```)
|
|
143
|
+
text = re.sub(r"```.*?```", "", text, flags=re.DOTALL) # multiline code blocks
|
|
144
|
+
|
|
145
|
+
# --- Remove Lists ---
|
|
146
|
+
# Unordered lists (* item, - item, + item)
|
|
147
|
+
text = re.sub(r"(?m)^[*\-+]\s+", "", text)
|
|
148
|
+
|
|
149
|
+
# Ordered lists (1. item)
|
|
150
|
+
text = re.sub(r"(?m)^\d+\.\s+", "", text)
|
|
151
|
+
|
|
152
|
+
# --- Remove Blockquotes ---
|
|
153
|
+
# Blockquotes (> text)
|
|
154
|
+
text = re.sub(r"(?m)^>\s+", "", text)
|
|
155
|
+
|
|
156
|
+
# --- Remove Horizontal Rules ---
|
|
157
|
+
# Horizontal rules (---, ***, ___)
|
|
158
|
+
text = re.sub(r"(?m)^[-_*]{3,}\s*$", "", text) # Remove horizontal rules
|
|
159
|
+
|
|
160
|
+
# --- Remove HTML tags (basic)---
|
|
161
|
+
# This is a very simple HTML tag removal, it does not handle nested tags or attributes properly.
|
|
162
|
+
text = re.sub(r"<[^>]*>", "", text)
|
|
163
|
+
|
|
164
|
+
# --- Remove escaped characters ---
|
|
165
|
+
text = re.sub(r"\\([!\"#$%&'()*+,./:;<=>?@\\[]^_`{|}~-])", r"\1", text)
|
|
166
|
+
|
|
167
|
+
return text
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def convert_markdown_to_html(text: str) -> str:
|
|
171
|
+
"""
|
|
172
|
+
Given a markdown string, convert it to HTML and return the HTML string.
|
|
173
|
+
|
|
174
|
+
:param text: The markdown string to convert.
|
|
175
|
+
:return: The HTML string.
|
|
176
|
+
"""
|
|
177
|
+
if not text:
|
|
178
|
+
return ""
|
|
179
|
+
|
|
180
|
+
# Replace newlines with break tags and tabs with em spaces.
|
|
181
|
+
text = text.replace("\r\n", "<br>").replace("\n", "<br>").replace("\t", " ")
|
|
182
|
+
|
|
183
|
+
# Format code blocks to maintain indentation.
|
|
184
|
+
text = HtmlFormatter.format_code_blocks(text, "<br>")
|
|
185
|
+
|
|
186
|
+
# Convert any other markdown to HTML.
|
|
187
|
+
text = HtmlFormatter._convert_markdown_by_line(text)
|
|
188
|
+
|
|
189
|
+
return text
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def format_code_blocks(text: str, newline: str = "\n") -> str:
|
|
193
|
+
"""
|
|
194
|
+
Locate each markdown code block in the given text and format it with HTML code and preformatting tags
|
|
195
|
+
to maintain indentation and add language-specific syntax highlighting.
|
|
196
|
+
|
|
197
|
+
:param text: The text to format.
|
|
198
|
+
:param newline: The newline character to expect in the input text.
|
|
199
|
+
:return: The formatted text.
|
|
200
|
+
"""
|
|
201
|
+
# Extract all the code blocks from the text
|
|
202
|
+
code_blocks = HtmlFormatter.extract_code_blocks(text, newline)
|
|
203
|
+
if not code_blocks:
|
|
204
|
+
return text
|
|
205
|
+
|
|
206
|
+
# Iterate through the code blocks, adding them to the text with the <pre><code> </code></pre>
|
|
207
|
+
# so that indentation is preserved.
|
|
208
|
+
current_index = 0
|
|
209
|
+
formatted = []
|
|
210
|
+
for code_block in code_blocks:
|
|
211
|
+
formatted.append(text[current_index:code_block.start_index])
|
|
212
|
+
formatted.append(code_block.to_html())
|
|
213
|
+
current_index = code_block.end_index
|
|
214
|
+
# Append the rest of the text after the last code block.
|
|
215
|
+
formatted.append(text[current_index:])
|
|
216
|
+
return "".join(formatted)
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def sanitize_code_blocks(text: str, newline: str = "\n") -> str:
|
|
220
|
+
"""
|
|
221
|
+
Given the input text, remove all code blocks while leaving all other text unchanged.
|
|
222
|
+
For use in any location where we don't want to display code (because it's scary).
|
|
223
|
+
|
|
224
|
+
:param text: The text to sanitize.
|
|
225
|
+
:param newline: The newline character to expect in the input text.
|
|
226
|
+
:return: The sanitized text.
|
|
227
|
+
"""
|
|
228
|
+
code_blocks = HtmlFormatter.extract_code_blocks(text, newline)
|
|
229
|
+
|
|
230
|
+
if not code_blocks:
|
|
231
|
+
return text
|
|
232
|
+
|
|
233
|
+
current_index = 0
|
|
234
|
+
formatted_text = []
|
|
235
|
+
|
|
236
|
+
for block in code_blocks:
|
|
237
|
+
formatted_text.append(text[current_index: block.start_index])
|
|
238
|
+
current_index = block.end_index
|
|
239
|
+
|
|
240
|
+
formatted_text.append(text[current_index:])
|
|
241
|
+
|
|
242
|
+
return "".join(formatted_text)
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def extract_code_blocks(text: str, newline: str = "\n") -> list[CodeBlock]:
|
|
246
|
+
"""
|
|
247
|
+
Extract all code blocks from the given response.
|
|
248
|
+
|
|
249
|
+
:param text: The text to extract the code blocks from.
|
|
250
|
+
:param newline: The newline character to expect in the input text.
|
|
251
|
+
:return: A list of code blocks.
|
|
252
|
+
"""
|
|
253
|
+
code: list[CodeBlock] = []
|
|
254
|
+
current_index = 0
|
|
255
|
+
while current_index < len(text):
|
|
256
|
+
code_block = HtmlFormatter.next_code_block(text, current_index, newline)
|
|
257
|
+
if code_block is None:
|
|
258
|
+
break
|
|
259
|
+
code.append(code_block)
|
|
260
|
+
current_index = code_block.end_index
|
|
261
|
+
return code
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def next_code_block(text: str, start_index: int, newline: str = "\n") -> CodeBlock | None:
|
|
265
|
+
"""
|
|
266
|
+
Extract the next code block from the given response, starting at the given index.
|
|
267
|
+
|
|
268
|
+
:param text: The text to extract the code block from.
|
|
269
|
+
:param start_index: The index to start searching for the code block at.
|
|
270
|
+
:param newline: The newline character to expect in the input text.
|
|
271
|
+
:return: The extracted code block. Null if no code block is found after the start index.
|
|
272
|
+
"""
|
|
273
|
+
# Find the start of the next code block.
|
|
274
|
+
start_tag = text.find("```", start_index)
|
|
275
|
+
if start_tag == -1:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
# Extract the language from the starting tag of the code block.
|
|
279
|
+
first_line = text.find(newline, start_tag)
|
|
280
|
+
if first_line == -1:
|
|
281
|
+
return None
|
|
282
|
+
language = text[start_tag + 3:first_line].strip()
|
|
283
|
+
first_line += len(newline)
|
|
284
|
+
|
|
285
|
+
# Find the end of the code block.
|
|
286
|
+
code: str
|
|
287
|
+
end_tag = text.find("```", first_line)
|
|
288
|
+
# If there is no end to the code block, just return the rest of the text as a code block.
|
|
289
|
+
if end_tag == -1:
|
|
290
|
+
end_tag = len(text)
|
|
291
|
+
code = text[first_line:end_tag]
|
|
292
|
+
else:
|
|
293
|
+
code = text[first_line:end_tag]
|
|
294
|
+
end_tag += 3
|
|
295
|
+
return CodeBlock(code, language, start_tag, end_tag)
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _convert_markdown_by_line(text: str) -> str:
|
|
299
|
+
"""
|
|
300
|
+
Convert markdown to HTML for each line in the given markdown text. Line breaks are expected to be represented
|
|
301
|
+
by break tags already.
|
|
302
|
+
|
|
303
|
+
:param text: The markdown text to convert.
|
|
304
|
+
:return: The HTML text.
|
|
305
|
+
"""
|
|
306
|
+
html = []
|
|
307
|
+
lines = text.split("<br>")
|
|
308
|
+
|
|
309
|
+
in_unordered_list = False
|
|
310
|
+
in_ordered_list = False
|
|
311
|
+
|
|
312
|
+
for line in lines:
|
|
313
|
+
# Skip code blocks, as these have already been formatted.
|
|
314
|
+
# Also skip empty lines.
|
|
315
|
+
if "</code></pre>" in line or not line.strip():
|
|
316
|
+
html.append(line + "<br>")
|
|
317
|
+
continue
|
|
318
|
+
processed_line = HtmlFormatter._process_line(line.strip())
|
|
319
|
+
|
|
320
|
+
# Handle headings
|
|
321
|
+
if processed_line.startswith("# "):
|
|
322
|
+
HtmlFormatter._close_lists(html, in_unordered_list, in_ordered_list)
|
|
323
|
+
in_unordered_list = False
|
|
324
|
+
in_ordered_list = False
|
|
325
|
+
html.append(HtmlFormatter.header_1(processed_line[2:].strip()) + "<br>")
|
|
326
|
+
elif processed_line.startswith("## "):
|
|
327
|
+
HtmlFormatter._close_lists(html, in_unordered_list, in_ordered_list)
|
|
328
|
+
in_unordered_list = False
|
|
329
|
+
in_ordered_list = False
|
|
330
|
+
html.append(HtmlFormatter.header_2(processed_line[3:].strip()) + "<br>")
|
|
331
|
+
elif processed_line.startswith("### "):
|
|
332
|
+
HtmlFormatter._close_lists(html, in_unordered_list, in_ordered_list)
|
|
333
|
+
in_unordered_list = False
|
|
334
|
+
in_ordered_list = False
|
|
335
|
+
html.append(HtmlFormatter.header_3(processed_line[4:].strip()) + "<br>")
|
|
336
|
+
# Handle unordered lists
|
|
337
|
+
elif processed_line.startswith("* "):
|
|
338
|
+
if not in_unordered_list:
|
|
339
|
+
HtmlFormatter._close_lists(html, False, in_ordered_list) # Close any previous ordered list.
|
|
340
|
+
in_ordered_list = False
|
|
341
|
+
html.append("<ul>")
|
|
342
|
+
in_unordered_list = True
|
|
343
|
+
html.append("<li>" + HtmlFormatter.body(processed_line[2:].strip()) + "</li>")
|
|
344
|
+
# Handle ordered lists
|
|
345
|
+
elif re.match(r"^\d+\. .*", processed_line): # Matches "1. text"
|
|
346
|
+
if not in_ordered_list:
|
|
347
|
+
HtmlFormatter._close_lists(html, in_unordered_list, False) # Close any previous unordered list.
|
|
348
|
+
in_unordered_list = False
|
|
349
|
+
html.append("<ol>")
|
|
350
|
+
in_ordered_list = True
|
|
351
|
+
html.append(
|
|
352
|
+
"<li>" + HtmlFormatter.body(processed_line[processed_line.find('.') + 2:].strip()) + "</li>")
|
|
353
|
+
|
|
354
|
+
# Handle regular paragraphs
|
|
355
|
+
else:
|
|
356
|
+
HtmlFormatter._close_lists(html, in_unordered_list, in_ordered_list)
|
|
357
|
+
in_unordered_list = False
|
|
358
|
+
in_ordered_list = False
|
|
359
|
+
html.append(HtmlFormatter.body(processed_line.strip()) + "<br>")
|
|
360
|
+
|
|
361
|
+
# Close any open lists at the end
|
|
362
|
+
HtmlFormatter._close_lists(html, in_unordered_list, in_ordered_list)
|
|
363
|
+
|
|
364
|
+
return "".join(html)
|
|
365
|
+
|
|
366
|
+
@staticmethod
|
|
367
|
+
def _close_lists(text: list, in_unordered_list: bool, in_ordered_list: bool):
|
|
368
|
+
"""
|
|
369
|
+
Close any open unordered or ordered lists in the given HTML string.
|
|
370
|
+
|
|
371
|
+
:param text: The HTML string to append to.
|
|
372
|
+
:param in_unordered_list: Whether an unordered list is currently open.
|
|
373
|
+
:param in_ordered_list: Whether an ordered list is currently open.
|
|
374
|
+
"""
|
|
375
|
+
if in_unordered_list:
|
|
376
|
+
text.append("</ul>")
|
|
377
|
+
if in_ordered_list:
|
|
378
|
+
text.append("</ol>")
|
|
379
|
+
|
|
380
|
+
@staticmethod
|
|
381
|
+
def _process_line(line: str) -> str:
|
|
382
|
+
"""
|
|
383
|
+
Process a single line of markdown text and convert it to HTML.
|
|
384
|
+
|
|
385
|
+
:param line: The line of markdown text to process.
|
|
386
|
+
:return: The HTML formatted line.
|
|
387
|
+
"""
|
|
388
|
+
# Bold: **text**
|
|
389
|
+
line = re.sub(r"\*\*(.*?)\*\*", r"<strong>\1</strong>", line)
|
|
390
|
+
|
|
391
|
+
# Italic: *text*
|
|
392
|
+
line = re.sub(r"\*(.*?)\*", r"<em>\1</em>", line)
|
|
393
|
+
|
|
394
|
+
# Code: `text`
|
|
395
|
+
line = re.sub(r"`(.*?)`", r"<code>\1</code>", line)
|
|
396
|
+
|
|
397
|
+
return line
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class CodeBlock:
|
|
401
|
+
"""
|
|
402
|
+
A class representing a code block extracted from a response.
|
|
403
|
+
"""
|
|
404
|
+
def __init__(self, code: str, language: str, start_index: int, end_index: int):
|
|
405
|
+
"""
|
|
406
|
+
:param code: The text of the code block.
|
|
407
|
+
:param language: The language of the code block.
|
|
408
|
+
:param start_index: The index of the first character of the code block in the original response.
|
|
409
|
+
:param end_index: The index after the last character of the code block in the original response.
|
|
410
|
+
"""
|
|
411
|
+
if code is None:
|
|
412
|
+
raise ValueError("Code cannot be None")
|
|
413
|
+
if language is None:
|
|
414
|
+
language = ""
|
|
415
|
+
if start_index < 0 or end_index < 0 or start_index > end_index:
|
|
416
|
+
raise ValueError("Invalid start or end index")
|
|
417
|
+
|
|
418
|
+
# Replace em spaces within code blocks with quadruple spaces and break tags with newlines.
|
|
419
|
+
# Code editors that the code is copy/pasted into might not recognize em spaces as valid indentation,
|
|
420
|
+
# and the library that adds the language-specific syntax highlighting expects newlines instead of break
|
|
421
|
+
# tags.
|
|
422
|
+
if "<br>" in code:
|
|
423
|
+
code = code.replace("<br>", "\n")
|
|
424
|
+
if " " in code:
|
|
425
|
+
code = code.replace(" ", " ")
|
|
426
|
+
# We don't want mixed whitespace, so replace all tabs with quad spaces.
|
|
427
|
+
if "\t" in code:
|
|
428
|
+
code = code.replace("\t", " ")
|
|
429
|
+
|
|
430
|
+
self.code = code
|
|
431
|
+
self.language = language.strip()
|
|
432
|
+
self.start_index = start_index
|
|
433
|
+
self.end_index = end_index
|
|
434
|
+
|
|
435
|
+
def to_html(self) -> str:
|
|
436
|
+
"""
|
|
437
|
+
:return: The HTML representation of this code block.
|
|
438
|
+
"""
|
|
439
|
+
start_tag: str
|
|
440
|
+
if self.language:
|
|
441
|
+
lang_class = f'class="language-{self.language}"'
|
|
442
|
+
start_tag = f"<pre {lang_class}><code {lang_class}>"
|
|
443
|
+
else:
|
|
444
|
+
start_tag = "<pre><code>"
|
|
445
|
+
end_tag = "</code></pre>"
|
|
446
|
+
|
|
447
|
+
return start_tag + self.code + end_tag
|
|
448
|
+
|
|
449
|
+
def to_markdown(self) -> str:
|
|
450
|
+
"""
|
|
451
|
+
:return: The markdown representation of this code block.
|
|
452
|
+
"""
|
|
453
|
+
start_tag = f"```{self.language}\n"
|
|
454
|
+
end_tag = "```" if self.code.endswith("\n") else "\n```"
|
|
455
|
+
|
|
456
|
+
return start_tag + self.code + end_tag
|
|
@@ -10,7 +10,8 @@ class SapioNavigationLinker:
|
|
|
10
10
|
Given a URL to a system's webservice API (example: https://company.exemplareln.com/webservice/api), construct
|
|
11
11
|
URLs for navigation links to various locations in the system.
|
|
12
12
|
"""
|
|
13
|
-
|
|
13
|
+
client_url: str
|
|
14
|
+
webservice_url: str
|
|
14
15
|
|
|
15
16
|
def __init__(self, url: str | SapioUser | SapioWebhookContext):
|
|
16
17
|
"""
|
|
@@ -21,7 +22,14 @@ class SapioNavigationLinker:
|
|
|
21
22
|
url = url.user.url
|
|
22
23
|
elif isinstance(url, SapioUser):
|
|
23
24
|
url = url.url
|
|
24
|
-
self.
|
|
25
|
+
self.webservice_url = url.rstrip("/")
|
|
26
|
+
self.client_url = url.rstrip("/").replace('webservice/api', 'veloxClient')
|
|
27
|
+
|
|
28
|
+
def homepage(self) -> str:
|
|
29
|
+
"""
|
|
30
|
+
:return: A URL for navigating to the system's homepage.
|
|
31
|
+
"""
|
|
32
|
+
return self.client_url + "/#view=homepage"
|
|
25
33
|
|
|
26
34
|
def data_record(self, record_identifier: RecordIdentifier, data_type_name: DataTypeIdentifier | None = None) -> str:
|
|
27
35
|
"""
|
|
@@ -39,7 +47,7 @@ class SapioNavigationLinker:
|
|
|
39
47
|
if not data_type_name:
|
|
40
48
|
raise SapioException("Unable to create a data record link without a data type name. "
|
|
41
49
|
"Only a record ID was provided.")
|
|
42
|
-
return self.
|
|
50
|
+
return self.client_url + f"/#dataType={data_type_name};recordId={record_id};view=dataRecord"
|
|
43
51
|
|
|
44
52
|
def experiment(self, experiment: ExperimentIdentifier) -> str:
|
|
45
53
|
"""
|
|
@@ -47,4 +55,4 @@ class SapioNavigationLinker:
|
|
|
47
55
|
object, experiment protocol, or a notebook ID.
|
|
48
56
|
:return: A URL for navigating to the input experiment.
|
|
49
57
|
"""
|
|
50
|
-
return self.
|
|
58
|
+
return self.client_url + f"/#notebookExperimentId={AliasUtil.to_notebook_id(experiment)};view=eln"
|
|
@@ -3,6 +3,7 @@ from typing import Iterable
|
|
|
3
3
|
from sapiopylib.rest.User import SapioUser
|
|
4
4
|
from sapiopylib.rest.pojo.CustomReport import CustomReportCriteria
|
|
5
5
|
from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
|
|
6
|
+
from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel
|
|
6
7
|
from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedType
|
|
7
8
|
|
|
8
9
|
from sapiopycommons.customreport.auto_pagers import CustomReportDictAutoPager, CustomReportRecordAutoPager
|
|
@@ -109,19 +110,22 @@ class QueueItemHandler:
|
|
|
109
110
|
else:
|
|
110
111
|
self.context = None
|
|
111
112
|
|
|
112
|
-
|
|
113
|
+
# CR-47491: Support providing a data type name string to receive PyRecordModels instead of requiring a WrapperType.
|
|
114
|
+
def get_process_queue_items_from_context(self, wrapper: type[WrappedType] | str,
|
|
113
115
|
context: SapioWebhookContext | ProcessQueueContext | None = None) \
|
|
114
|
-
-> list[WrappedType]:
|
|
116
|
+
-> list[WrappedType] | list[PyRecordModel]:
|
|
115
117
|
"""
|
|
116
118
|
When you launch records from a custom process queue, the process queue items related to the selected records
|
|
117
119
|
are provided as record IDs to the process queue context. Using these record IDs, query for the queue item
|
|
118
120
|
records and wrap them as record models.
|
|
119
121
|
|
|
120
|
-
:param wrapper: The record model wrapper for the process queue items.
|
|
122
|
+
:param wrapper: The record model wrapper or data type name for the process queue items.
|
|
121
123
|
:param context: If this handler was not initialized with a context object, or you wish to retrieve
|
|
122
124
|
data from a different context object than the initializing context, then provide the context to retrieve the
|
|
123
125
|
record IDs from.
|
|
124
126
|
:return: The process queue items corresponding to the record IDs from the context wrapped as record models.
|
|
127
|
+
If a data type name was used instead of a model wrapper, then the returned records will be PyRecordModels
|
|
128
|
+
instead of WrappedRecordModels.
|
|
125
129
|
"""
|
|
126
130
|
if context is None and self.context is not None:
|
|
127
131
|
record_ids: list[int] = self.context.process_queue_item_record_ids
|
|
@@ -175,46 +179,52 @@ class QueueItemHandler:
|
|
|
175
179
|
ret_val[queue_item] = record
|
|
176
180
|
return ret_val
|
|
177
181
|
|
|
178
|
-
def get_queue_items_from_report(self, wrapper: type[WrappedType], criteria: QueueItemReportCriteria) \
|
|
179
|
-
-> list[WrappedType]:
|
|
182
|
+
def get_queue_items_from_report(self, wrapper: type[WrappedType] | None, criteria: QueueItemReportCriteria) \
|
|
183
|
+
-> list[WrappedType] | list[PyRecordModel]:
|
|
180
184
|
"""
|
|
181
185
|
Run a custom report that retrieves every queue item in the system for the given search criteria.
|
|
182
186
|
|
|
183
|
-
:param wrapper: The record model wrapper for the process queue items.
|
|
187
|
+
:param wrapper: The record model wrapper for the process queue items. If not provided, the returned records will
|
|
188
|
+
be PyRecordModels instead of WrappedRecordModels.
|
|
184
189
|
:param criteria: The search criteria to query for queue items with.
|
|
185
190
|
:return: A list of every queue item in the system that matches the search criteria.
|
|
186
191
|
"""
|
|
187
192
|
report = self.build_queue_item_report(criteria)
|
|
188
|
-
|
|
193
|
+
dt: type[WrappedType] | str = wrapper if wrapper else ProcessQueueItemFields.DATA_TYPE_NAME
|
|
194
|
+
return CustomReportRecordAutoPager(self.user, report, dt).get_all_at_once()
|
|
189
195
|
|
|
190
|
-
def get_records_from_item_report(self, wrapper: type[WrappedType],
|
|
191
|
-
criteria: QueueItemReportCriteria = QueueItemReportCriteria())
|
|
196
|
+
def get_records_from_item_report(self, wrapper: type[WrappedType] | str,
|
|
197
|
+
criteria: QueueItemReportCriteria = QueueItemReportCriteria()) \
|
|
198
|
+
-> list[WrappedType] | list[PyRecordModel]:
|
|
192
199
|
"""
|
|
193
200
|
Run a custom report that retrieves for queue items that match the given search criteria, then query for the
|
|
194
201
|
data records that those queue items refer to.
|
|
195
202
|
|
|
196
|
-
:param wrapper: The record model wrapper for the records being queried for.
|
|
203
|
+
:param wrapper: The record model wrapper or data type name for the records being queried for.
|
|
197
204
|
:param criteria: Additional search criteria to filter the results. This function forces the data_type_names
|
|
198
205
|
parameter of the criteria to match the data type of the given record model wrapper.
|
|
199
206
|
:return: A list of all records related to the queue items in the system that match the search criteria.
|
|
207
|
+
If a data type name was used instead of a model wrapper, then the returned records will be PyRecordModels
|
|
208
|
+
instead of WrappedRecordModels.
|
|
200
209
|
"""
|
|
201
210
|
# Don't try to query for process queue items that don't match the data type of this wrapper.
|
|
202
|
-
criteria.data_type_names = [
|
|
211
|
+
criteria.data_type_names = [AliasUtil.to_data_type_name(wrapper)]
|
|
203
212
|
criteria.not_data_type_names = None
|
|
204
213
|
report = self.build_queue_item_report(criteria)
|
|
205
214
|
record_ids: list[int] = [x[ProcessQueueItemFields.DATA_RECORD_ID__FIELD.field_name]
|
|
206
215
|
for x in CustomReportDictAutoPager(self.user, report)]
|
|
207
216
|
return self.rec_handler.query_models_by_id(wrapper, record_ids)
|
|
208
217
|
|
|
209
|
-
def get_queue_items_for_records(self, records: Iterable[SapioRecord], wrapper: type[WrappedType],
|
|
218
|
+
def get_queue_items_for_records(self, records: Iterable[SapioRecord], wrapper: type[WrappedType] | None = None,
|
|
210
219
|
criteria: QueueItemReportCriteria = QueueItemReportCriteria()) \
|
|
211
|
-
-> dict[SapioRecord, list[WrappedType]]:
|
|
220
|
+
-> dict[SapioRecord, list[WrappedType] | list[PyRecordModel]]:
|
|
212
221
|
"""
|
|
213
222
|
Given a list of records, query the system for every process queue item that refers to those records and matches
|
|
214
223
|
the provided search criteria.
|
|
215
224
|
|
|
216
225
|
:param records: The queued records to query for the process queue items of.
|
|
217
|
-
:param wrapper: The record model wrapper for the returned process queue item records.
|
|
226
|
+
:param wrapper: The record model wrapper for the returned process queue item records. If not provided, the
|
|
227
|
+
returned records will be PyRecordModels instead of WrappedRecordModels.
|
|
218
228
|
:param criteria: Additional search criteria to filter the results. This function forces the data_record_ids and
|
|
219
229
|
data_type_names parameters on the criteria to match the given records.
|
|
220
230
|
:return: A dictionary mapping the input records to a list of the process queue items that refer to them. If a
|
|
@@ -226,40 +236,43 @@ class QueueItemHandler:
|
|
|
226
236
|
criteria.not_data_record_ids = None
|
|
227
237
|
criteria.data_type_names = AliasUtil.to_data_type_names(records)
|
|
228
238
|
criteria.not_data_type_names = None
|
|
229
|
-
items: list[WrappedType] = self.get_queue_items_from_report(wrapper, criteria)
|
|
239
|
+
items: list[WrappedType] | list[PyRecordModel] = self.get_queue_items_from_report(wrapper, criteria)
|
|
230
240
|
return self.map_records_to_queue_items(records, items)
|
|
231
241
|
|
|
232
|
-
def get_records_for_queue_items(self, queue_items: Iterable[SapioRecord], wrapper: type[WrappedType]) \
|
|
233
|
-
-> dict[SapioRecord, WrappedType]:
|
|
242
|
+
def get_records_for_queue_items(self, queue_items: Iterable[SapioRecord], wrapper: type[WrappedType] | str) \
|
|
243
|
+
-> dict[SapioRecord, WrappedType | PyRecordModel]:
|
|
234
244
|
"""
|
|
235
245
|
Given a list of process queue items, query the system for the records that those queue items refer to.
|
|
236
246
|
|
|
237
247
|
:param queue_items: The process queue items to query for the referenced records of.
|
|
238
|
-
:param wrapper: The record model wrapper for the records being queried.
|
|
239
|
-
:return: A dictionary mapping the input process queue items to the record tht they refer to.
|
|
248
|
+
:param wrapper: The record model wrapper or data type name for the records being queried.
|
|
249
|
+
:return: A dictionary mapping the input process queue items to the record tht they refer to. If a data type
|
|
250
|
+
name was used instead of a model wrapper, then the returned records will be PyRecordModels instead of
|
|
251
|
+
WrappedRecordModels.
|
|
240
252
|
"""
|
|
241
253
|
record_ids: set[int] = {x.get_field_value(ProcessQueueItemFields.DATA_RECORD_ID__FIELD) for x in queue_items}
|
|
242
|
-
records: list[WrappedType] = self.rec_handler.query_models_by_id(wrapper, record_ids)
|
|
254
|
+
records: list[WrappedType] | list[PyRecordModel] = self.rec_handler.query_models_by_id(wrapper, record_ids)
|
|
243
255
|
return self.map_queue_items_to_records(queue_items, records)
|
|
244
256
|
|
|
245
257
|
def queue_records_for_process(self, records: Iterable[SapioRecord], process: str, step: str,
|
|
246
|
-
wrapper: type[WrappedType]) -> dict[SapioRecord, WrappedType]:
|
|
258
|
+
wrapper: type[WrappedType] | None = None) -> dict[SapioRecord, WrappedType | PyRecordModel]:
|
|
247
259
|
"""
|
|
248
260
|
Given a list of records, create process queue item records for them at the provided process and step names.
|
|
249
261
|
You must store and commit using the record model manager in order for these changes to take effect.
|
|
250
262
|
|
|
251
263
|
:param records: The records to create process queue items for.
|
|
252
|
-
:param wrapper: The record model wrapper for the process queue items being created.
|
|
253
264
|
:param process: The name of the process to queue for.
|
|
254
265
|
:param step: The name of the step in the above process to queue for. This is the "Workflow Name" field of the
|
|
255
266
|
Process Workflow record corresponding to the step you want to assign these records to. For steps that
|
|
256
267
|
launch an experiment, this is the name of the template that will be launched. For the other types of custom
|
|
257
268
|
process steps, this is the "Workflow Name" as defined in the process manager config.
|
|
269
|
+
:param wrapper: The record model wrapper for the process queue items being created. If not provided, the
|
|
270
|
+
returned records will be PyRecordModels instead of WrappedRecordModels.
|
|
258
271
|
:return: A dictionary mapping each input record to the newly created process queue item for that record.
|
|
259
272
|
"""
|
|
260
273
|
ret_val: dict[SapioRecord, WrappedType] = {}
|
|
261
274
|
for record in records:
|
|
262
|
-
item = self.rec_handler.add_model(wrapper)
|
|
275
|
+
item = self.rec_handler.add_model(wrapper if wrapper else ProcessQueueItemFields.DATA_TYPE_NAME)
|
|
263
276
|
item.set_field_values({
|
|
264
277
|
ProcessQueueItemFields.PROCESS_HEADER_NAME__FIELD.field_name: process,
|
|
265
278
|
ProcessQueueItemFields.WORKFLOW_HEADER_NAME__FIELD.field_name: step,
|
|
@@ -271,16 +284,17 @@ class QueueItemHandler:
|
|
|
271
284
|
ret_val[record] = item
|
|
272
285
|
return ret_val
|
|
273
286
|
|
|
274
|
-
def dequeue_records_for_process(self, records: Iterable[SapioRecord], wrapper: type[WrappedType],
|
|
287
|
+
def dequeue_records_for_process(self, records: Iterable[SapioRecord], wrapper: type[WrappedType] | None = None,
|
|
275
288
|
criteria: QueueItemReportCriteria = QueueItemReportCriteria()) \
|
|
276
|
-
-> dict[SapioRecord, list[WrappedType]]:
|
|
289
|
+
-> dict[SapioRecord, list[WrappedType] | list[PyRecordModel]]:
|
|
277
290
|
"""
|
|
278
291
|
Given a list of records, locate the process queue items that refer to them and that match the given search
|
|
279
292
|
criteria and remove them from the queue by setting the ShowInQueue field on the process queue items to false.
|
|
280
293
|
You must store and commit using the record model manager in order for these changes to take effect.
|
|
281
294
|
|
|
282
295
|
:param records: The records to remove from the queue.
|
|
283
|
-
:param wrapper: The record model wrapper for the process queue items being updated.
|
|
296
|
+
:param wrapper: The record model wrapper for the process queue items being updated. If not provided, the
|
|
297
|
+
returned records will be PyRecordModels instead of WrappedRecordModels.
|
|
284
298
|
:param criteria: Additional search criteria to filter the results. This function forces the show_in_queue
|
|
285
299
|
parameter on the criteria to True.
|
|
286
300
|
:return: A dictionary mapping each input record to the queue item records that refer to that record and were
|
|
@@ -289,7 +303,8 @@ class QueueItemHandler:
|
|
|
289
303
|
"""
|
|
290
304
|
# Only locate queue items that are currently visible in the queue.
|
|
291
305
|
criteria.shown_in_queue = True
|
|
292
|
-
dequeue: dict[SapioRecord, list[WrappedType]
|
|
306
|
+
dequeue: dict[SapioRecord, list[WrappedType] | list[PyRecordModel]]
|
|
307
|
+
dequeue = self.get_queue_items_for_records(records, wrapper, criteria)
|
|
293
308
|
for record, items in dequeue.items():
|
|
294
309
|
for item in items:
|
|
295
310
|
item.set_field_value(ProcessQueueItemFields.SHOW_IN_QUEUE__FIELD.field_name, False)
|