albert 1.10.0rc2__py3-none-any.whl → 1.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. albert/__init__.py +1 -1
  2. albert/client.py +5 -0
  3. albert/collections/custom_templates.py +3 -0
  4. albert/collections/data_templates.py +118 -264
  5. albert/collections/entity_types.py +19 -3
  6. albert/collections/inventory.py +1 -1
  7. albert/collections/notebooks.py +154 -26
  8. albert/collections/parameters.py +1 -0
  9. albert/collections/property_data.py +384 -280
  10. albert/collections/reports.py +4 -0
  11. albert/collections/synthesis.py +292 -0
  12. albert/collections/tasks.py +2 -1
  13. albert/collections/worksheets.py +3 -0
  14. albert/core/shared/models/base.py +3 -1
  15. albert/core/shared/models/patch.py +1 -1
  16. albert/resources/batch_data.py +4 -2
  17. albert/resources/cas.py +3 -1
  18. albert/resources/custom_fields.py +3 -1
  19. albert/resources/data_templates.py +60 -12
  20. albert/resources/inventory.py +6 -4
  21. albert/resources/lists.py +3 -1
  22. albert/resources/notebooks.py +12 -7
  23. albert/resources/parameter_groups.py +3 -1
  24. albert/resources/property_data.py +64 -5
  25. albert/resources/sheets.py +16 -14
  26. albert/resources/synthesis.py +61 -0
  27. albert/resources/tags.py +3 -1
  28. albert/resources/tasks.py +4 -7
  29. albert/resources/workflows.py +4 -2
  30. albert/utils/data_template.py +392 -37
  31. albert/utils/property_data.py +638 -0
  32. albert/utils/tasks.py +3 -3
  33. {albert-1.10.0rc2.dist-info → albert-1.11.0.dist-info}/METADATA +1 -1
  34. {albert-1.10.0rc2.dist-info → albert-1.11.0.dist-info}/RECORD +36 -33
  35. {albert-1.10.0rc2.dist-info → albert-1.11.0.dist-info}/WHEEL +0 -0
  36. {albert-1.10.0rc2.dist-info → albert-1.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,10 +1,20 @@
1
+ import mimetypes
2
+ from pathlib import Path, PurePosixPath
3
+
1
4
  from pydantic import TypeAdapter, validate_call
2
5
 
3
6
  from albert.collections.base import BaseCollection
7
+ from albert.collections.files import FileCollection
8
+ from albert.collections.synthesis import SynthesisCollection
9
+ from albert.core.base import BaseAlbertModel
4
10
  from albert.core.session import AlbertSession
5
- from albert.core.shared.identifiers import NotebookId, ProjectId, TaskId
6
- from albert.exceptions import AlbertException, NotFoundError
11
+ from albert.core.shared.identifiers import NotebookId, ProjectId, SynthesisId, TaskId
12
+ from albert.exceptions import AlbertException
13
+ from albert.resources.files import FileNamespace
7
14
  from albert.resources.notebooks import (
15
+ AttachesBlock,
16
+ ImageBlock,
17
+ KetcherBlock,
8
18
  Notebook,
9
19
  NotebookBlock,
10
20
  NotebookCopyInfo,
@@ -15,6 +25,13 @@ from albert.resources.notebooks import (
15
25
  )
16
26
 
17
27
 
28
+ class _KetcherUpdateAction(BaseAlbertModel):
29
+ synthesis_id: SynthesisId
30
+ data: str
31
+ png: str
32
+ smiles: str
33
+
34
+
18
35
  class NotebookCollection(BaseCollection):
19
36
  """NotebookCollection is a collection class for managing Notebook entities in the Albert platform."""
20
37
 
@@ -32,6 +49,8 @@ class NotebookCollection(BaseCollection):
32
49
  """
33
50
  super().__init__(session=session)
34
51
  self.base_path = f"/api/{NotebookCollection._api_version}/notebooks"
52
+ self._files = FileCollection(session=session)
53
+ self._synthesis = SynthesisCollection(session=session)
35
54
 
36
55
  @validate_call
37
56
  def get_by_id(self, *, id: NotebookId) -> Notebook:
@@ -139,6 +158,10 @@ class NotebookCollection(BaseCollection):
139
158
  If a block in the Notebook does not already exist on Albert, it will be created.
140
159
  *Note: The order of the Blocks in your Notebook matter and will be used in the updated Notebook!*
141
160
 
161
+ !!! warning
162
+ Updating existing Ketcher blocks is not supported. To change a Ketcher block, delete it and
163
+ create a new one instead.
164
+
142
165
 
143
166
  Parameters
144
167
  ----------
@@ -149,12 +172,33 @@ class NotebookCollection(BaseCollection):
149
172
  -------
150
173
  Notebook
151
174
  The updated notebook object as returned by the server.
175
+
176
+ Examples
177
+ --------
178
+ !!! example "Add a Ketcher block from SMILES"
179
+ ```python
180
+ notebook = client.notebooks.get_by_id(id="NTB123")
181
+ notebook.blocks.append(
182
+ KetcherBlock(content=KetcherContent(smiles="CCO"))
183
+ )
184
+ notebook = client.notebooks.update_block_content(notebook=notebook)
185
+ ```
152
186
  """
153
- put_data = self._generate_put_block_payload(notebook=notebook)
187
+ if notebook.id is None:
188
+ raise AlbertException("Notebook id is required to update block content.")
189
+ put_data, ketcher_updates = self._generate_put_block_payload(notebook=notebook)
154
190
  url = f"{self.base_path}/{notebook.id}/content"
155
191
 
156
192
  self.session.put(url, json=put_data.model_dump(mode="json", by_alias=True))
157
193
 
194
+ for action in ketcher_updates:
195
+ self._synthesis.update_canvas_data(
196
+ synthesis_id=action.synthesis_id,
197
+ smiles=action.smiles,
198
+ data=action.data,
199
+ png=action.png,
200
+ )
201
+ self._synthesis.create_reactant_productant_table(synthesis_id=action.synthesis_id)
158
202
  return self.get_by_id(id=notebook.id)
159
203
 
160
204
  @validate_call
@@ -176,29 +220,33 @@ class NotebookCollection(BaseCollection):
176
220
  response = self.session.get(f"{self.base_path}/{notebook_id}/blocks/{block_id}")
177
221
  return TypeAdapter(NotebookBlock).validate_python(response.json())
178
222
 
179
- def _generate_put_block_payload(self, *, notebook: Notebook) -> PutBlockPayload:
180
- data = list()
181
- seen_ids = set()
223
+ def _generate_put_block_payload(
224
+ self, *, notebook: Notebook
225
+ ) -> tuple[PutBlockPayload, list[_KetcherUpdateAction]]:
226
+ data: list[PutBlockDatum] = []
227
+ seen_ids: set[str] = set()
182
228
  previous_block_id = ""
183
- # Update the Blocks in the Notebook
229
+ ketcher_updates: list[_KetcherUpdateAction] = []
230
+ existing_blocks = {b.id: b for b in self.get_by_id(id=notebook.id).blocks}
184
231
  for block in notebook.blocks:
185
232
  if block.id in seen_ids:
186
- # This check keeps a user from corrupting the Notebook data.
187
233
  msg = f"You have Notebook blocks with duplicate ids. [id={block.id}]"
188
234
  raise AlbertException(msg)
189
- try:
190
- existing_block = self.get_block_by_id(notebook_id=notebook.id, block_id=block.id)
191
- if type(block) is not type(existing_block):
192
- # This check keeps a user from corrupting the Notebook data.
193
- msg = (
194
- f"Cannot convert an existing block type into another block type. "
195
- f"Instead, please instantiate a new block, and remove the old block "
196
- f"from the Notebook object. [existing_block_type={type(existing_block)}, "
197
- f"new_block_type={type(block)}]"
198
- )
199
- raise AlbertException(msg)
200
- except NotFoundError:
201
- pass
235
+ existing_block = existing_blocks.get(block.id)
236
+ if existing_block and type(block) is not type(existing_block):
237
+ msg = (
238
+ f"Cannot convert an existing block type into another block type. "
239
+ f"Instead, please instantiate a new block, and remove the old block "
240
+ f"from the Notebook object. [existing_block_type={type(existing_block)}, "
241
+ f"new_block_type={type(block)}]"
242
+ )
243
+ raise AlbertException(msg)
244
+
245
+ if isinstance(block, KetcherBlock) and existing_block is None:
246
+ ketcher_updates.append(self._prepare_ketcher_block(notebook=notebook, block=block))
247
+ elif isinstance(block, (AttachesBlock | ImageBlock)):
248
+ self._prepare_file_block(notebook=notebook, block=block)
249
+
202
250
  put_datum = PutBlockDatum(
203
251
  id=block.id,
204
252
  type=block.type,
@@ -207,16 +255,96 @@ class NotebookCollection(BaseCollection):
207
255
  previous_block_id=previous_block_id,
208
256
  )
209
257
  seen_ids.add(put_datum.id)
210
- previous_block_id = put_datum.id # Ensure the Block ordering is consecutive
258
+ previous_block_id = put_datum.id
211
259
  data.append(put_datum)
212
260
 
213
- # Delete the Blocks not present in the new Notebook object
214
- existing_notebook = self.get_by_id(id=notebook.id)
215
- for block in existing_notebook.blocks:
261
+ for block in existing_blocks.values():
216
262
  if block.id not in seen_ids:
217
263
  data.append(PutBlockDatum(id=block.id, operation=PutOperation.DELETE))
218
264
 
219
- return PutBlockPayload(data=data)
265
+ return PutBlockPayload(data=data), ketcher_updates
266
+
267
+ def _prepare_file_block(
268
+ self, *, notebook: Notebook, block: AttachesBlock | ImageBlock
269
+ ) -> None:
270
+ content = block.content
271
+ file_path = content.file_path
272
+ file_key = content.file_key
273
+ if file_path is None:
274
+ if file_key:
275
+ file_name = PurePosixPath(file_key).name
276
+ if "/" not in file_key:
277
+ content.file_key = f"{notebook.id}/{block.id}/{file_key}"
278
+ if content.format is None:
279
+ content.format = (
280
+ mimetypes.guess_type(file_name)[0] or "application/octet-stream"
281
+ )
282
+ if isinstance(block, AttachesBlock) and content.title is None:
283
+ content.title = file_name or None
284
+ return
285
+
286
+ path = Path(file_path)
287
+ if file_key and "/" not in file_key:
288
+ file_key = f"{notebook.id}/{block.id}/{file_key}"
289
+ elif not file_key:
290
+ file_key = f"{notebook.id}/{block.id}/{path.name}"
291
+
292
+ content.file_key = file_key
293
+ file_name = PurePosixPath(file_key).name
294
+ if content.format is None:
295
+ content.format = mimetypes.guess_type(file_name)[0] or "application/octet-stream"
296
+ if isinstance(block, AttachesBlock) and content.title is None:
297
+ content.title = file_name or None
298
+
299
+ with path.open("rb") as handle:
300
+ self._files.sign_and_upload_file(
301
+ data=handle,
302
+ name=file_key,
303
+ namespace=FileNamespace(content.namespace),
304
+ content_type=content.format,
305
+ )
306
+
307
+ def _prepare_ketcher_block(
308
+ self, *, notebook: Notebook, block: KetcherBlock
309
+ ) -> _KetcherUpdateAction:
310
+ """
311
+ Prepare a Ketcher block for creation.
312
+
313
+ Updates to existing Ketcher blocks are not supported. To change a Ketcher
314
+ block, delete it and create a new one instead.
315
+ """
316
+ content = block.content
317
+ smiles = content.smiles or ""
318
+ data = content.data
319
+ png = content.png
320
+
321
+ if content.synthesis_id is None:
322
+ if not smiles:
323
+ raise AlbertException("smiles is required to create a Ketcher block.")
324
+ name = "Chemical Draw Block"
325
+ synthesis = self._synthesis.create(
326
+ parent_id=notebook.id, name=name, block_id=block.id, smiles=smiles or None
327
+ )
328
+ content.synthesis_id = synthesis.id
329
+ content.s3_key = synthesis.s3_key or content.s3_key
330
+
331
+ canvas_data = synthesis.canvas_data or {}
332
+ data = data or canvas_data.get("data")
333
+ png = png or canvas_data.get("png")
334
+
335
+ content.id = block.id
336
+ content.block_id = block.id
337
+ content.state_type = "project"
338
+ content.smiles = smiles
339
+ content.data = data
340
+ content.png = png
341
+
342
+ return _KetcherUpdateAction(
343
+ synthesis_id=content.synthesis_id,
344
+ data=data or "",
345
+ png=png or "",
346
+ smiles=smiles or "",
347
+ )
220
348
 
221
349
  def copy(self, *, notebook_copy_info: NotebookCopyInfo, type: NotebookCopyType) -> Notebook:
222
350
  """Create a copy of a Notebook into a specified parent
@@ -98,6 +98,7 @@ class ParameterCollection(BaseCollection):
98
98
  url = f"{self.base_path}/{id}"
99
99
  self.session.delete(url)
100
100
 
101
+ @validate_call
101
102
  def get_all(
102
103
  self,
103
104
  *,