lamindb 1.0.4__py3-none-any.whl → 1.1.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 (78) hide show
  1. lamindb/__init__.py +14 -5
  2. lamindb/_artifact.py +174 -57
  3. lamindb/_can_curate.py +27 -8
  4. lamindb/_collection.py +85 -51
  5. lamindb/_feature.py +177 -41
  6. lamindb/_finish.py +222 -81
  7. lamindb/_from_values.py +83 -98
  8. lamindb/_parents.py +4 -4
  9. lamindb/_query_set.py +59 -17
  10. lamindb/_record.py +171 -53
  11. lamindb/_run.py +4 -4
  12. lamindb/_save.py +33 -10
  13. lamindb/_schema.py +135 -38
  14. lamindb/_storage.py +1 -1
  15. lamindb/_tracked.py +106 -0
  16. lamindb/_transform.py +21 -8
  17. lamindb/_ulabel.py +5 -14
  18. lamindb/base/validation.py +2 -6
  19. lamindb/core/__init__.py +13 -14
  20. lamindb/core/_context.py +39 -36
  21. lamindb/core/_data.py +29 -25
  22. lamindb/core/_describe.py +1 -1
  23. lamindb/core/_django.py +1 -1
  24. lamindb/core/_feature_manager.py +54 -44
  25. lamindb/core/_label_manager.py +4 -4
  26. lamindb/core/_mapped_collection.py +20 -7
  27. lamindb/core/datasets/__init__.py +6 -1
  28. lamindb/core/datasets/_core.py +12 -11
  29. lamindb/core/datasets/_small.py +66 -20
  30. lamindb/core/exceptions.py +1 -90
  31. lamindb/core/loaders.py +7 -13
  32. lamindb/core/relations.py +6 -4
  33. lamindb/core/storage/_anndata_accessor.py +41 -0
  34. lamindb/core/storage/_backed_access.py +2 -2
  35. lamindb/core/storage/_pyarrow_dataset.py +25 -15
  36. lamindb/core/storage/_tiledbsoma.py +56 -12
  37. lamindb/core/storage/paths.py +41 -22
  38. lamindb/core/subsettings/_creation_settings.py +4 -16
  39. lamindb/curators/__init__.py +2168 -833
  40. lamindb/curators/_cellxgene_schemas/__init__.py +26 -0
  41. lamindb/curators/_cellxgene_schemas/schema_versions.yml +104 -0
  42. lamindb/errors.py +96 -0
  43. lamindb/integrations/_vitessce.py +3 -3
  44. lamindb/migrations/0069_squashed.py +76 -75
  45. lamindb/migrations/0075_lamindbv1_part5.py +4 -5
  46. lamindb/migrations/0082_alter_feature_dtype.py +21 -0
  47. lamindb/migrations/0083_alter_feature_is_type_alter_flextable_is_type_and_more.py +94 -0
  48. lamindb/migrations/0084_alter_schemafeature_feature_and_more.py +35 -0
  49. lamindb/migrations/0085_alter_feature_is_type_alter_flextable_is_type_and_more.py +63 -0
  50. lamindb/migrations/0086_various.py +95 -0
  51. lamindb/migrations/0087_rename__schemas_m2m_artifact_feature_sets_and_more.py +41 -0
  52. lamindb/migrations/0088_schema_components.py +273 -0
  53. lamindb/migrations/0088_squashed.py +4372 -0
  54. lamindb/models.py +423 -156
  55. {lamindb-1.0.4.dist-info → lamindb-1.1.0.dist-info}/METADATA +10 -7
  56. lamindb-1.1.0.dist-info/RECORD +95 -0
  57. lamindb/curators/_spatial.py +0 -528
  58. lamindb/migrations/0052_squashed.py +0 -1261
  59. lamindb/migrations/0053_alter_featureset_hash_alter_paramvalue_created_by_and_more.py +0 -57
  60. lamindb/migrations/0054_alter_feature_previous_runs_and_more.py +0 -35
  61. lamindb/migrations/0055_artifact_type_artifactparamvalue_and_more.py +0 -61
  62. lamindb/migrations/0056_rename_ulabel_ref_is_name_artifactulabel_label_ref_is_name_and_more.py +0 -22
  63. lamindb/migrations/0057_link_models_latest_report_and_others.py +0 -356
  64. lamindb/migrations/0058_artifact__actions_collection__actions.py +0 -22
  65. lamindb/migrations/0059_alter_artifact__accessor_alter_artifact__hash_type_and_more.py +0 -31
  66. lamindb/migrations/0060_alter_artifact__actions.py +0 -22
  67. lamindb/migrations/0061_alter_collection_meta_artifact_alter_run_environment_and_more.py +0 -45
  68. lamindb/migrations/0062_add_is_latest_field.py +0 -32
  69. lamindb/migrations/0063_populate_latest_field.py +0 -45
  70. lamindb/migrations/0064_alter_artifact_version_alter_collection_version_and_more.py +0 -33
  71. lamindb/migrations/0065_remove_collection_feature_sets_and_more.py +0 -22
  72. lamindb/migrations/0066_alter_artifact__feature_values_and_more.py +0 -352
  73. lamindb/migrations/0067_alter_featurevalue_unique_together_and_more.py +0 -20
  74. lamindb/migrations/0068_alter_artifactulabel_unique_together_and_more.py +0 -20
  75. lamindb/migrations/0069_alter_artifact__accessor_alter_artifact__hash_type_and_more.py +0 -1294
  76. lamindb-1.0.4.dist-info/RECORD +0 -102
  77. {lamindb-1.0.4.dist-info → lamindb-1.1.0.dist-info}/LICENSE +0 -0
  78. {lamindb-1.0.4.dist-info → lamindb-1.1.0.dist-info}/WHEEL +0 -0
lamindb/_finish.py CHANGED
@@ -1,22 +1,84 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import builtins
3
4
  import re
4
5
  from datetime import datetime, timezone
6
+ from time import sleep
5
7
  from typing import TYPE_CHECKING
6
8
 
7
9
  import lamindb_setup as ln_setup
8
10
  from lamin_utils import logger
11
+ from lamin_utils._logger import LEVEL_TO_COLORS, LEVEL_TO_ICONS, RESET_COLOR
9
12
  from lamindb_setup.core.hashing import hash_file
10
13
 
11
- from lamindb.core.exceptions import NotebookNotSaved
12
14
  from lamindb.models import Artifact, Run, Transform
13
15
 
16
+ is_run_from_ipython = getattr(builtins, "__IPYTHON__", False)
17
+
14
18
  if TYPE_CHECKING:
15
19
  from pathlib import Path
16
20
 
17
21
 
18
22
  def get_save_notebook_message() -> str:
19
- return f"Please save the notebook in your editor (shortcut `{get_shortcut()}`) within 2 sec before calling `finish()`"
23
+ # do not add bold() or any other complicated characters as then we can't match this
24
+ # easily anymore in an html to strip it out
25
+ return f"please hit {get_shortcut()} to save the notebook in your editor"
26
+
27
+
28
+ def get_save_notebook_message_retry() -> str:
29
+ return f"{get_save_notebook_message()} and re-run finish()"
30
+
31
+
32
+ # this code was originally in nbproject by the same authors
33
+ def check_consecutiveness(
34
+ nb, calling_statement: str = None, silent_success: bool = True
35
+ ) -> bool:
36
+ """Check whether code cells have been executed consecutively.
37
+
38
+ Needs to be called in the last code cell of a notebook.
39
+ Otherwise raises `RuntimeError`.
40
+
41
+ Returns cell transitions that violate execution at increments of 1 as a list
42
+ of tuples.
43
+
44
+ Args:
45
+ nb: Notebook content.
46
+ calling_statement: The statement that calls this function.
47
+ """
48
+ cells = nb.cells
49
+
50
+ violations = []
51
+ prev = 0
52
+
53
+ ccount = 0 # need to initialize because notebook might note have code cells
54
+ # and below, we check if ccount is None
55
+ for cell in cells:
56
+ cell_source = "".join(cell["source"])
57
+ if cell["cell_type"] != "code" or cell_source == "":
58
+ continue
59
+
60
+ if calling_statement is not None and calling_statement in cell_source:
61
+ continue
62
+
63
+ ccount = cell["execution_count"]
64
+ if ccount is None or prev is None or ccount - prev != 1:
65
+ violations.append((prev, ccount))
66
+
67
+ prev = ccount
68
+
69
+ # ignore the very last code cell of the notebook
70
+ # `check_consecutiveness` is being run during publish if `last_cell`` is True
71
+ # hence, that cell has ccount is None
72
+ if ccount is None:
73
+ violations.pop()
74
+
75
+ any_violations = len(violations) > 0
76
+ if any_violations:
77
+ logger.warning(f"cells {violations} were not run consecutively")
78
+ elif not silent_success:
79
+ logger.success("cell execution numbers increase consecutively")
80
+
81
+ return not any_violations
20
82
 
21
83
 
22
84
  def get_shortcut() -> str:
@@ -34,7 +96,7 @@ def save_run_logs(run: Run, save_run: bool = False) -> None:
34
96
  if logs_path.exists():
35
97
  if run.report is not None:
36
98
  logger.important("overwriting run.report")
37
- artifact = Artifact(
99
+ artifact = Artifact( # type: ignore
38
100
  logs_path,
39
101
  description=f"log streams of run {run.uid}",
40
102
  _branch_code=0,
@@ -64,11 +126,11 @@ def prepare_notebook(
64
126
  if strip_title:
65
127
  lines.pop(i)
66
128
  cell["source"] = "\n".join(lines)
67
- # strip resaved finish error if present
129
+ # strip logging message about saving notebook in editor
68
130
  # this is normally the last cell
69
131
  if cell["cell_type"] == "code" and ".finish(" in cell["source"]:
70
132
  for output in cell["outputs"]:
71
- if output.get("ename", None) == "NotebookNotSaved":
133
+ if "to save the notebook in your editor" in output.get("text", ""):
72
134
  cell["outputs"] = []
73
135
  break
74
136
  return None
@@ -97,7 +159,7 @@ def notebook_to_report(notebook_path: Path, output_path: Path) -> None:
97
159
  output_path.write_text(html, encoding="utf-8")
98
160
 
99
161
 
100
- def notebook_to_script(
162
+ def notebook_to_script( # type: ignore
101
163
  transform: Transform, notebook_path: Path, script_path: Path | None = None
102
164
  ) -> None | str:
103
165
  import jupytext
@@ -114,7 +176,6 @@ def notebook_to_script(
114
176
  script_path.write_text(py_content)
115
177
 
116
178
 
117
- # removes NotebookNotSaved error message from notebook html
118
179
  def clean_r_notebook_html(file_path: Path) -> tuple[str | None, Path]:
119
180
  import re
120
181
 
@@ -129,15 +190,15 @@ def clean_r_notebook_html(file_path: Path) -> tuple[str | None, Path]:
129
190
  cleaned_content = re.sub(pattern_title, "", cleaned_content)
130
191
  cleaned_content = re.sub(pattern_h1, "", cleaned_content)
131
192
  # remove error message from content
132
- if "NotebookNotSaved" in cleaned_content:
133
- orig_error_message = f"NotebookNotSaved: {get_save_notebook_message()}"
193
+ if "to save the notebook in your editor" in cleaned_content:
194
+ orig_error_message = f"! {get_save_notebook_message_retry()}"
134
195
  # coming up with the regex for this is a bit tricky due to all the
135
196
  # escape characters we'd need to insert into the message; hence,
136
197
  # we do this with a replace() instead
137
198
  cleaned_content = cleaned_content.replace(orig_error_message, "")
138
- if "NotebookNotSaved" in cleaned_content:
199
+ if "to save the notebook in your editor" in cleaned_content:
139
200
  orig_error_message = orig_error_message.replace(
140
- " `finish()`", "\n`finish()`"
201
+ " finish()", "\nfinish()"
141
202
  ) # RStudio might insert a newline
142
203
  cleaned_content = cleaned_content.replace(orig_error_message, "")
143
204
  cleaned_path = file_path.parent / (f"{file_path.stem}.cleaned{file_path.suffix}")
@@ -145,6 +206,35 @@ def clean_r_notebook_html(file_path: Path) -> tuple[str | None, Path]:
145
206
  return title_text, cleaned_path
146
207
 
147
208
 
209
+ def check_filepath_recently_saved(filepath: Path, is_finish_retry: bool) -> bool:
210
+ # the recently_saved_time needs to be very low for the first check
211
+ # because an accidental save (e.g. via auto-save) might otherwise lead
212
+ # to upload of an outdated notebook
213
+ # also see implementation for R notebooks below
214
+ offset_saved_time = 0.3 if not is_finish_retry else 20
215
+ for retry in range(30):
216
+ recently_saved_time = offset_saved_time + retry # sleep time is 1 sec
217
+ if get_seconds_since_modified(filepath) > recently_saved_time:
218
+ if retry == 0:
219
+ prefix = f"{LEVEL_TO_COLORS[20]}{LEVEL_TO_ICONS[20]}{RESET_COLOR}"
220
+ print(f"{prefix} {get_save_notebook_message()}", end=" ")
221
+ elif retry == 9:
222
+ print(".", end="\n")
223
+ elif retry == 4:
224
+ print(". still waiting ", end="")
225
+ else:
226
+ print(".", end="")
227
+ sleep(1)
228
+ else:
229
+ if retry > 0:
230
+ prefix = f"{LEVEL_TO_COLORS[25]}{LEVEL_TO_ICONS[25]}{RESET_COLOR}"
231
+ print(f" {prefix}")
232
+ # filepath was recently saved, return True
233
+ return True
234
+ # if we arrive here, no save event occured, return False
235
+ return False
236
+
237
+
148
238
  def save_context_core(
149
239
  *,
150
240
  run: Run | None,
@@ -153,6 +243,7 @@ def save_context_core(
153
243
  finished_at: bool = False,
154
244
  ignore_non_consecutive: bool | None = None,
155
245
  from_cli: bool = False,
246
+ is_retry: bool = False,
156
247
  ) -> str | None:
157
248
  import lamindb as ln
158
249
  from lamindb.models import (
@@ -167,12 +258,29 @@ def save_context_core(
167
258
  is_r_notebook = filepath.suffix in {".qmd", ".Rmd"}
168
259
  source_code_path = filepath
169
260
  report_path: Path | None = None
170
- # for notebooks, we need more work
171
- if is_ipynb:
261
+ save_source_code_and_report = True
262
+ if is_run_from_ipython: # python notebooks in interactive session
263
+ import nbproject
264
+
265
+ # it might be that the user modifies the title just before ln.finish()
266
+ if (nbproject_title := nbproject.meta.live.title) != transform.description:
267
+ transform.description = nbproject_title
268
+ transform.save()
269
+ if not ln_setup._TESTING:
270
+ save_source_code_and_report = check_filepath_recently_saved(
271
+ filepath, is_retry
272
+ )
273
+ if not save_source_code_and_report and not is_retry:
274
+ logger.warning(get_save_notebook_message_retry())
275
+ return "retry"
276
+ elif not save_source_code_and_report:
277
+ logger.warning(
278
+ "the notebook on disk wasn't saved within the last 10 sec"
279
+ )
280
+ if is_ipynb: # could be from CLI outside interactive session
172
281
  try:
173
282
  import jupytext # noqa: F401
174
283
  from nbproject.dev import (
175
- check_consecutiveness,
176
284
  read_notebook,
177
285
  )
178
286
  except ImportError:
@@ -185,7 +293,9 @@ def save_context_core(
185
293
  )
186
294
  if not is_consecutive:
187
295
  response = "n" # ignore_non_consecutive == False
188
- if ignore_non_consecutive is None:
296
+ if ignore_non_consecutive is None: # only print warning
297
+ response = "y" # we already printed the warning
298
+ else: # ask user to confirm
189
299
  response = input(
190
300
  " Do you still want to proceed with finishing? (y/n) "
191
301
  )
@@ -210,34 +320,45 @@ def save_context_core(
210
320
  logger.warning(
211
321
  f"no html report found; to attach one, create an .html export for your {filepath.suffix} file and then run: lamin save {filepath}"
212
322
  )
213
- if report_path is not None and not from_cli:
214
- if get_seconds_since_modified(report_path) > 2 and not ln_setup._TESTING:
215
- # this can happen when auto-knitting an html with RStudio
216
- raise NotebookNotSaved(get_save_notebook_message())
323
+ if report_path is not None and is_r_notebook and not from_cli: # R notebooks
324
+ # see comment above in check_filepath_recently_saved
325
+ recently_saved_time = 0.3 if not is_retry else 20
326
+ if get_seconds_since_modified(report_path) > recently_saved_time:
327
+ # the automated retry solution of Jupyter notebooks does not work in RStudio because the execution of the notebook cell
328
+ # seems to block the event loop of the frontend
329
+ if not is_retry:
330
+ logger.warning(get_save_notebook_message_retry())
331
+ return "retry"
332
+ else:
333
+ logger.warning(
334
+ "the notebook on disk hasn't been saved within the last 20 sec"
335
+ )
336
+ save_source_code_and_report = False
217
337
  ln.settings.creation.artifact_silence_missing_run_warning = True
218
- # track source code
219
- hash, _ = hash_file(source_code_path) # ignore hash_type for now
220
- if transform.hash is not None:
221
- # check if the hash of the transform source code matches
222
- # (for scripts, we already run the same logic in track() - we can deduplicate the call at some point)
223
- if hash != transform.hash:
224
- response = input(
225
- f"You are about to overwrite existing source code (hash '{transform.hash}') for Transform('{transform.uid}')."
226
- f" Proceed? (y/n)"
227
- )
228
- if response == "y":
229
- transform.source_code = source_code_path.read_text()
230
- transform.hash = hash
338
+ # save source code
339
+ if save_source_code_and_report:
340
+ hash, _ = hash_file(source_code_path) # ignore hash_type for now
341
+ if transform.hash is not None:
342
+ # check if the hash of the transform source code matches
343
+ # (for scripts, we already run the same logic in track() - we can deduplicate the call at some point)
344
+ if hash != transform.hash:
345
+ response = input(
346
+ f"You are about to overwrite existing source code (hash '{transform.hash}') for Transform('{transform.uid}')."
347
+ f" Proceed? (y/n)"
348
+ )
349
+ if response == "y":
350
+ transform.source_code = source_code_path.read_text()
351
+ transform.hash = hash
352
+ else:
353
+ logger.warning("Please re-run `ln.track()` to make a new version")
354
+ return "rerun-the-notebook"
231
355
  else:
232
- logger.warning("Please re-run `ln.track()` to make a new version")
233
- return "rerun-the-notebook"
356
+ logger.debug("source code is already saved")
234
357
  else:
235
- logger.debug("source code is already saved")
236
- else:
237
- transform.source_code = source_code_path.read_text()
238
- transform.hash = hash
358
+ transform.source_code = source_code_path.read_text()
359
+ transform.hash = hash
239
360
 
240
- # track environment
361
+ # track run environment
241
362
  if run is not None:
242
363
  env_path = ln_setup.settings.cache_dir / f"run_env_pip_{run.uid}.txt"
243
364
  if env_path.exists():
@@ -250,7 +371,7 @@ def save_context_core(
250
371
  artifact = ln.Artifact.filter(hash=hash, _branch_code=0).one_or_none()
251
372
  new_env_artifact = artifact is None
252
373
  if new_env_artifact:
253
- artifact = ln.Artifact(
374
+ artifact = ln.Artifact( # type: ignore
254
375
  env_path,
255
376
  description="requirements.txt",
256
377
  _branch_code=0,
@@ -263,50 +384,57 @@ def save_context_core(
263
384
 
264
385
  # set finished_at
265
386
  if finished_at and run is not None:
266
- run.finished_at = datetime.now(timezone.utc)
387
+ if not from_cli:
388
+ update_finished_at = True
389
+ else:
390
+ update_finished_at = run.finished_at is None
391
+ if update_finished_at:
392
+ run.finished_at = datetime.now(timezone.utc)
267
393
 
268
394
  # track logs
269
395
  if run is not None and not from_cli and not is_ipynb and not is_r_notebook:
270
396
  save_run_logs(run)
271
397
 
272
398
  # track report and set is_consecutive
273
- if run is not None:
274
- if report_path is not None:
275
- if is_r_notebook:
276
- title_text, report_path = clean_r_notebook_html(report_path)
277
- if title_text is not None:
278
- transform.description = title_text
279
- if run.report_id is not None:
280
- hash, _ = hash_file(report_path) # ignore hash_type for now
281
- if hash != run.report.hash:
282
- response = input(
283
- f"You are about to overwrite an existing report (hash '{run.report.hash}') for Run('{run.uid}'). Proceed? (y/n)"
284
- )
285
- if response == "y":
286
- run.report.replace(report_path)
287
- run.report.save(upload=True, print_progress=False)
399
+ if save_source_code_and_report:
400
+ if run is not None:
401
+ if report_path is not None:
402
+ if is_r_notebook:
403
+ title_text, report_path = clean_r_notebook_html(report_path)
404
+ if title_text is not None:
405
+ transform.description = title_text
406
+ if run.report_id is not None:
407
+ hash, _ = hash_file(report_path) # ignore hash_type for now
408
+ if hash != run.report.hash:
409
+ response = input(
410
+ f"You are about to overwrite an existing report (hash '{run.report.hash}') for Run('{run.uid}'). Proceed? (y/n)"
411
+ )
412
+ if response == "y":
413
+ run.report.replace(report_path)
414
+ run.report.save(upload=True, print_progress=False)
415
+ else:
416
+ logger.important("keeping old report")
288
417
  else:
289
- logger.important("keeping old report")
418
+ logger.important("report is already saved")
290
419
  else:
291
- logger.important("report is already saved")
292
- else:
293
- report_file = ln.Artifact(
294
- report_path,
295
- description=f"Report of run {run.uid}",
296
- _branch_code=0, # hidden file
297
- run=False,
420
+ report_file = ln.Artifact( # type: ignore
421
+ report_path,
422
+ description=f"Report of run {run.uid}",
423
+ _branch_code=0, # hidden file
424
+ run=False,
425
+ )
426
+ report_file.save(upload=True, print_progress=False)
427
+ run.report = report_file
428
+ if is_r_notebook:
429
+ # this is the "cleaned" report
430
+ report_path.unlink()
431
+ logger.debug(
432
+ f"saved transform.latest_run.report: {transform.latest_run.report}"
298
433
  )
299
- report_file.save(upload=True, print_progress=False)
300
- run.report = report_file
301
- if is_r_notebook:
302
- # this is the "cleaned" report
303
- report_path.unlink()
304
- logger.debug(
305
- f"saved transform.latest_run.report: {transform.latest_run.report}"
306
- )
307
- run._is_consecutive = is_consecutive
434
+ run._is_consecutive = is_consecutive
308
435
 
309
- # save both run & transform records if we arrive here
436
+ # save both run & transform records if we arrive here
437
+ if run is not None:
310
438
  run.save()
311
439
  transform.save()
312
440
 
@@ -318,19 +446,32 @@ def save_context_core(
318
446
  hours = seconds // 3600
319
447
  minutes = (seconds % 3600) // 60
320
448
  secs = seconds % 60
321
- formatted_run_time = f"{days}d {hours}h {minutes}m {secs}s"
449
+ formatted_run_time = (
450
+ f"{days}d"
451
+ if days != 0
452
+ else "" + f"{hours}h"
453
+ if hours != 0
454
+ else "" + f"{minutes}m"
455
+ if minutes != 0
456
+ else "" + f"{secs}s"
457
+ )
322
458
 
323
459
  logger.important(
324
460
  f"finished Run('{run.uid[:8]}') after {formatted_run_time} at {format_field_value(run.finished_at)}"
325
461
  )
326
462
  if ln_setup.settings.instance.is_on_hub:
327
- identifier = ln_setup.settings.instance.slug
328
- logger.important(
329
- f"go to: https://lamin.ai/{identifier}/transform/{transform.uid}"
330
- )
331
- if not from_cli:
463
+ instance_slug = ln_setup.settings.instance.slug
464
+ if save_source_code_and_report:
465
+ logger.important(
466
+ f"go to: https://lamin.ai/{instance_slug}/transform/{transform.uid}"
467
+ )
468
+ if not from_cli and save_source_code_and_report:
332
469
  thing = "notebook" if (is_ipynb or is_r_notebook) else "script"
333
470
  logger.important(
334
- f"if you want to update your {thing} without re-running it, use `lamin save {filepath}`"
471
+ f"to update your {thing} from the CLI, run: lamin save {filepath}"
335
472
  )
473
+ if not save_source_code_and_report:
474
+ logger.warning(
475
+ f"did *not* save source code and report -- to do so, run: lamin save {filepath}"
476
+ )
336
477
  return None