sapiopycommons 2025.5.7a514__py3-none-any.whl → 2025.5.12a519__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/api/fielddefinitions/proto/fields_pb2.py +43 -0
- sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2.pyi +31 -0
- sapiopycommons/ai/api/fielddefinitions/proto/fields_pb2_grpc.py +24 -0
- sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.py +123 -0
- sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2.pyi +598 -0
- sapiopycommons/ai/api/fielddefinitions/proto/velox_field_def_pb2_grpc.py +24 -0
- sapiopycommons/ai/api/plan/proto/step_output_pb2.py +45 -0
- sapiopycommons/ai/api/plan/proto/step_output_pb2.pyi +42 -0
- sapiopycommons/ai/api/plan/proto/step_output_pb2_grpc.py +24 -0
- sapiopycommons/ai/api/plan/proto/step_pb2.py +43 -0
- sapiopycommons/ai/api/plan/proto/step_pb2.pyi +43 -0
- sapiopycommons/ai/api/plan/proto/step_pb2_grpc.py +24 -0
- sapiopycommons/ai/api/plan/script/proto/script_pb2.py +53 -0
- sapiopycommons/ai/api/plan/script/proto/script_pb2.pyi +99 -0
- sapiopycommons/ai/api/plan/script/proto/script_pb2_grpc.py +153 -0
- sapiopycommons/ai/api/plan/tool/proto/entry_pb2.py +57 -0
- sapiopycommons/ai/api/plan/tool/proto/entry_pb2.pyi +96 -0
- sapiopycommons/ai/api/plan/tool/proto/entry_pb2_grpc.py +24 -0
- sapiopycommons/ai/api/plan/tool/proto/tool_pb2.py +67 -0
- sapiopycommons/ai/api/plan/tool/proto/tool_pb2.pyi +220 -0
- sapiopycommons/ai/api/plan/tool/proto/tool_pb2_grpc.py +154 -0
- sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.py +39 -0
- sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2.pyi +32 -0
- sapiopycommons/ai/api/session/proto/sapio_conn_info_pb2_grpc.py +24 -0
- sapiopycommons/ai/protobuf_utils.py +454 -0
- sapiopycommons/ai/tool_service_base.py +740 -0
- sapiopycommons/callbacks/callback_util.py +64 -116
- sapiopycommons/callbacks/field_builder.py +0 -2
- sapiopycommons/customreport/auto_pagers.py +1 -2
- sapiopycommons/customreport/term_builder.py +1 -1
- sapiopycommons/datatype/pseudo_data_types.py +326 -349
- sapiopycommons/eln/experiment_handler.py +719 -336
- sapiopycommons/eln/plate_designer.py +2 -7
- sapiopycommons/files/file_util.py +4 -4
- sapiopycommons/general/accession_service.py +2 -2
- sapiopycommons/general/aliases.py +1 -4
- sapiopycommons/general/html_formatter.py +456 -0
- sapiopycommons/general/sapio_links.py +12 -4
- sapiopycommons/processtracking/custom_workflow_handler.py +1 -2
- sapiopycommons/recordmodel/record_handler.py +27 -357
- sapiopycommons/rules/eln_rule_handler.py +1 -8
- sapiopycommons/rules/on_save_rule_handler.py +1 -8
- sapiopycommons/webhook/webhook_handlers.py +0 -3
- sapiopycommons/webhook/webservice_handlers.py +2 -2
- {sapiopycommons-2025.5.7a514.dist-info → sapiopycommons-2025.5.12a519.dist-info}/METADATA +2 -2
- sapiopycommons-2025.5.12a519.dist-info/RECORD +91 -0
- sapiopycommons/eln/experiment_cache.py +0 -188
- sapiopycommons/eln/experiment_step_factory.py +0 -476
- sapiopycommons/eln/step_creation.py +0 -236
- sapiopycommons/general/data_structure_util.py +0 -115
- sapiopycommons-2025.5.7a514.dist-info/RECORD +0 -67
- {sapiopycommons-2025.5.7a514.dist-info → sapiopycommons-2025.5.12a519.dist-info}/WHEEL +0 -0
- {sapiopycommons-2025.5.7a514.dist-info → sapiopycommons-2025.5.12a519.dist-info}/licenses/LICENSE +0 -0
|
@@ -167,7 +167,7 @@ class PlateDesignerEntry(ElnEntryStep):
|
|
|
167
167
|
self._designer_elements_by_plate = {}
|
|
168
168
|
self._designer_elements_by_plate.clear()
|
|
169
169
|
for element in self._designer_elements:
|
|
170
|
-
plate_id: int = element.
|
|
170
|
+
plate_id: int = element.get(WellElement.PLATE_RECORD_ID__FIELD)
|
|
171
171
|
self._designer_elements_by_plate.setdefault(plate_id, []).append(element)
|
|
172
172
|
return self._designer_elements
|
|
173
173
|
|
|
@@ -194,8 +194,7 @@ class PlateDesignerEntry(ElnEntryStep):
|
|
|
194
194
|
return self._designer_elements_by_plate[plate]
|
|
195
195
|
|
|
196
196
|
def create_well_element(self, sample: RecordModel, plate: RecordModel, location: PlateLocation | None = None,
|
|
197
|
-
|
|
198
|
-
-> WrappedType | PyRecordModel:
|
|
197
|
+
wrapper_type: type[WrappedType] | None = None) -> WrappedType | PyRecordModel:
|
|
199
198
|
"""
|
|
200
199
|
Create a new plate designer well element for the input sample and plate. A record model manager store and commit
|
|
201
200
|
must be called to save this new well element to the server.
|
|
@@ -204,7 +203,6 @@ class PlateDesignerEntry(ElnEntryStep):
|
|
|
204
203
|
:param plate: The plate that the element is for. Must exist in the system (i.e. have a >0 record ID).
|
|
205
204
|
:param location: The location of the well element. If not provided, the row and column position fields of the
|
|
206
205
|
sample will be used.
|
|
207
|
-
:param layer: The layer that the well element is on.
|
|
208
206
|
:param wrapper_type: The record model wrapper to use for the plate designer well element. If not provided, the
|
|
209
207
|
returned record will be a PyRecordModel instead of a WrappedRecordModel.
|
|
210
208
|
:return: The newly created PlateDesignerWellElementModel.
|
|
@@ -218,8 +216,6 @@ class PlateDesignerEntry(ElnEntryStep):
|
|
|
218
216
|
raise SapioException("Sample record must be of type Sample.")
|
|
219
217
|
if AliasUtil.to_data_type_name(plate) != "Plate":
|
|
220
218
|
raise SapioException("Plate record must be of type Plate.")
|
|
221
|
-
if layer < 1:
|
|
222
|
-
raise SapioException("Layer must be greater than 0.")
|
|
223
219
|
|
|
224
220
|
dt: type[WrappedType] | str = wrapper_type if wrapper_type else WellElement.DATA_TYPE_NAME
|
|
225
221
|
plate_id: int = AliasUtil.to_record_id(plate)
|
|
@@ -229,7 +225,6 @@ class PlateDesignerEntry(ElnEntryStep):
|
|
|
229
225
|
WellElement.ROW_POSITION__FIELD: location.row_pos if location else sample.get_field_value("RowPosition"),
|
|
230
226
|
WellElement.COL_POSITION__FIELD: str(location.col_pos) if location else sample.get_field_value("ColPosition"),
|
|
231
227
|
WellElement.SOURCE_DATA_TYPE_NAME__FIELD: "Sample",
|
|
232
|
-
WellElement.LAYER__FIELD: layer,
|
|
233
228
|
}
|
|
234
229
|
element = self._rec_handler.add_models_with_data(dt, [fields])[0]
|
|
235
230
|
|
|
@@ -327,10 +327,10 @@ class FileUtil:
|
|
|
327
327
|
:param files: A dictionary of file name to file data as a string or bytes.
|
|
328
328
|
:return: The bytes for a zip file containing the input files.
|
|
329
329
|
"""
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
330
|
+
zip_buffer: io.BytesIO = io.BytesIO()
|
|
331
|
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
332
|
+
for file_name, file_data in files.items():
|
|
333
|
+
zip_file.writestr(file_name, file_data)
|
|
334
334
|
return zip_buffer.getvalue()
|
|
335
335
|
|
|
336
336
|
# Deprecated functions:
|
|
@@ -199,7 +199,7 @@ class AccessionRequestId(AbstractAccessionServiceOperator):
|
|
|
199
199
|
|
|
200
200
|
Properties:
|
|
201
201
|
numberOfCharacters: Number of characters maximum in the request ID.
|
|
202
|
-
accessorName: This is a legacy variable from drum.getNextIdListByMapName(), which allows setting different "accessorName" from old system. We need this for
|
|
202
|
+
accessorName: This is a legacy variable from drum.getNextIdListByMapName(), which allows setting different "accessorName" from old system. We need this for compability patch for converting these to the new preference format.
|
|
203
203
|
"""
|
|
204
204
|
_num_of_characters: int
|
|
205
205
|
_accessor_name: str
|
|
@@ -341,7 +341,7 @@ class AccessionService:
|
|
|
341
341
|
def get_affixed_id_in_batch(self, data_type_name: str, data_field_name: str, num_ids: int, prefix: str | None,
|
|
342
342
|
suffix: str | None, num_digits: int | None, start_num: int = 1) -> list[str]:
|
|
343
343
|
"""
|
|
344
|
-
Get the batch affixed IDs that are maximal in cache and
|
|
344
|
+
Get the batch affixed IDs that are maximal in cache and contiguious for a particular datatype.datafield under a given format.
|
|
345
345
|
:param data_type_name: The datatype name to look for max ID
|
|
346
346
|
:param data_field_name: The datafield name to look for max ID
|
|
347
347
|
:param num_ids: The number of IDs to accession.
|
|
@@ -7,7 +7,6 @@ from sapiopylib.rest.pojo.datatype.FieldDefinition import FieldType, AbstractVel
|
|
|
7
7
|
from sapiopylib.rest.pojo.eln.ElnExperiment import ElnExperiment
|
|
8
8
|
from sapiopylib.rest.pojo.eln.ExperimentEntry import ExperimentEntry
|
|
9
9
|
from sapiopylib.rest.pojo.eln.SapioELNEnums import ElnBaseDataType
|
|
10
|
-
from sapiopylib.rest.pojo.eln.eln_headings import ElnExperimentTab
|
|
11
10
|
from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
|
|
12
11
|
from sapiopylib.rest.utils.Protocols import ElnExperimentProtocol, ElnEntryStep
|
|
13
12
|
from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel, AbstractRecordModel
|
|
@@ -220,9 +219,7 @@ class AliasUtil:
|
|
|
220
219
|
# noinspection PyTypeChecker
|
|
221
220
|
fields: FieldMap = record.get_fields()
|
|
222
221
|
else:
|
|
223
|
-
|
|
224
|
-
# macros get translated to valid field values.
|
|
225
|
-
fields: FieldMap = {f: record.fields.get(f) for f in record.fields}
|
|
222
|
+
fields: FieldMap = record.fields.copy_to_dict()
|
|
226
223
|
# PR-47457: Only include the record ID if the caller requests it, since including the record ID can break
|
|
227
224
|
# callbacks in certain circumstances if the record ID is negative.
|
|
228
225
|
if include_record_id:
|
|
@@ -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"
|
|
@@ -105,8 +105,7 @@ class QueueItemHandler:
|
|
|
105
105
|
"""
|
|
106
106
|
self.user = AliasUtil.to_sapio_user(context)
|
|
107
107
|
self.rec_handler = RecordHandler(self.user)
|
|
108
|
-
|
|
109
|
-
if isinstance(context, SapioWebhookContext) and context.context_data:
|
|
108
|
+
if isinstance(context, SapioWebhookContext):
|
|
110
109
|
self.context = ProcessQueueContext(context)
|
|
111
110
|
else:
|
|
112
111
|
self.context = None
|