evo-objects 0.6.1__tar.gz → 0.6.3__tar.gz

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 (50) hide show
  1. {evo_objects-0.6.1 → evo_objects-0.6.3}/PKG-INFO +38 -2
  2. {evo_objects-0.6.1 → evo_objects-0.6.3}/pyproject.toml +1 -1
  3. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/__init__.py +12 -1
  4. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/_grid.py +3 -2
  5. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/_model.py +18 -3
  6. evo_objects-0.6.3/src/evo/objects/typed/_prefetch.py +72 -0
  7. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/attributes.py +50 -8
  8. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/base.py +15 -1
  9. evo_objects-0.6.3/src/evo/objects/typed/downhole_collection.py +589 -0
  10. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/utils/__init__.py +3 -0
  11. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/utils/data.py +107 -6
  12. evo_objects-0.6.3/src/evo/objects/utils/downhole.py +76 -0
  13. evo_objects-0.6.1/src/evo/objects/typed/downhole_collection.py +0 -303
  14. {evo_objects-0.6.1 → evo_objects-0.6.3}/.gitignore +0 -0
  15. {evo_objects-0.6.1 → evo_objects-0.6.3}/LICENSE.md +0 -0
  16. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/__init__.py +0 -0
  17. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/_model_config.py +0 -0
  18. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/client/__init__.py +0 -0
  19. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/client/api_client.py +0 -0
  20. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/client/object_client.py +0 -0
  21. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/client/parse.py +0 -0
  22. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/data.py +0 -0
  23. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/endpoints/__init__.py +0 -0
  24. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/endpoints/api/__init__.py +0 -0
  25. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/endpoints/api/data_api.py +0 -0
  26. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/endpoints/api/metadata_api.py +0 -0
  27. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/endpoints/api/objects_api.py +0 -0
  28. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/endpoints/api/stages_api.py +0 -0
  29. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/endpoints/models.py +0 -0
  30. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/exceptions.py +0 -0
  31. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/io.py +0 -0
  32. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/parquet/__init__.py +0 -0
  33. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/parquet/loader.py +0 -0
  34. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/py.typed +0 -0
  35. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/_data.py +0 -0
  36. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/_downhole.py +0 -0
  37. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/_utils.py +0 -0
  38. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/block_model_ref.py +0 -0
  39. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/downhole_intervals.py +0 -0
  40. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/exceptions.py +0 -0
  41. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/pointset.py +0 -0
  42. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/regular_grid.py +0 -0
  43. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/regular_masked_grid.py +0 -0
  44. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/spatial.py +0 -0
  45. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/tensor_grid.py +0 -0
  46. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/types.py +0 -0
  47. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/typed/variogram.py +0 -0
  48. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/utils/table_formats.py +0 -0
  49. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/utils/tables.py +0 -0
  50. {evo_objects-0.6.1 → evo_objects-0.6.3}/src/evo/objects/utils/types.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: evo-objects
3
- Version: 0.6.1
3
+ Version: 0.6.3
4
4
  Summary: Python SDK for using the Seequent Evo Geoscience Object API
5
5
  Project-URL: Source, https://github.com/SeequentEvo/evo-python-sdk
6
6
  Project-URL: Tracker, https://github.com/SeequentEvo/evo-python-sdk/issues
@@ -95,6 +95,42 @@ You can also get a list of all objects. Internally, this recursively calls the `
95
95
 
96
96
  Check out the other methods on the `ObjectAPIClient` for more details on how to upload and download objects, or get object versions.
97
97
 
98
+ ### Typed downhole collections
99
+
100
+ `DownholeCollection` provides a DataFrame-based API for creating and reading downhole objects. A collection can contain
101
+ distance tables, interval tables, or both. `hole_index` is an integer key in the object's `hole_id` lookup table, not a
102
+ row position or a pandas categorical code. The creation helper uses dense zero-based keys in sorted hole-ID order unless
103
+ an explicit `hole_indices` mapping is supplied. Persisted objects may use arbitrary integer keys. Collection holes may
104
+ contain a subset or repeated chunks.
105
+
106
+ ```python
107
+ import pandas as pd
108
+
109
+ from evo.objects.typed import DownholeCollection, DownholeCollectionData
110
+ from evo.objects.typed.downhole_collection import IntervalCollection
111
+ from evo.objects.utils.downhole import hole_chunks_from_ids
112
+
113
+ intervals = pd.DataFrame({"from": [0.0], "to": [1.5], "lithology": ["sandstone"]})
114
+ collections = [
115
+ IntervalCollection(
116
+ name="geology",
117
+ holes=hole_chunks_from_ids(pd.Series(["DH-01"])),
118
+ table=intervals,
119
+ unit="m", # Collection coordinate units are set explicitly here.
120
+ )
121
+ ]
122
+
123
+ # Build DownholeCollectionData with matching path, location-hole chunks, and collar properties,
124
+ # then create it with: await DownholeCollection.create(context, data)
125
+ ```
126
+
127
+ Use `await dhc.location.to_dataframe()` and `await dhc.location.path_to_dataframe()` to read collars and paths.
128
+ Distance and interval tables provide `to_dataframe()` and `to_dataframe_by_hole()`. Before reading a large object, call
129
+ `await dhc.prefetch_collections("geology")` to warm only the requested collection data (and location data by default).
130
+ Attribute descriptions for ordinary attributes round-trip through `DataFrame.attrs["attribute_descriptions"]`.
131
+ Collection coordinate units come only from `DistanceCollection.unit` or `IntervalCollection.unit`; unit metadata on the
132
+ distance or `from` column is ignored.
133
+
98
134
  ## Contributing
99
135
 
100
136
  For instructions on contributing to the development of this library, please refer to the [evo-python-sdk documentation](https://github.com/seequentevo/evo-python-sdk).
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "evo-objects"
3
3
  description = "Python SDK for using the Seequent Evo Geoscience Object API"
4
- version = "0.6.1"
4
+ version = "0.6.3"
5
5
  requires-python = ">=3.10"
6
6
  license-files = ["LICENSE.md"]
7
7
  dynamic = ["readme"]
@@ -12,17 +12,24 @@
12
12
  from ._grid import BlockModelData, BlockModelGeometry
13
13
  from .attributes import (
14
14
  Attribute,
15
+ AttributeDescription,
15
16
  Attributes,
16
17
  BlockModelAttribute,
17
18
  BlockModelAttributes,
18
19
  BlockModelPendingAttribute,
19
20
  PendingAttribute,
21
+ Unit,
20
22
  )
21
23
  from .base import BaseObject, object_from_path, object_from_reference, object_from_uuid
22
24
  from .block_model_ref import (
23
25
  BlockModel,
24
26
  )
25
- from .downhole_collection import DownholeCollection, DownholeCollectionData
27
+ from .downhole_collection import (
28
+ DistanceCollection,
29
+ DownholeCollection,
30
+ DownholeCollectionData,
31
+ IntervalCollection,
32
+ )
26
33
  from .downhole_intervals import DownholeIntervals, DownholeIntervalsData
27
34
  from .pointset import (
28
35
  Locations,
@@ -70,6 +77,7 @@ from .variogram import (
70
77
 
71
78
  __all__ = [
72
79
  "Attribute",
80
+ "AttributeDescription",
73
81
  "Attributes",
74
82
  "BaseObject",
75
83
  "BaseSpatialObject",
@@ -82,6 +90,7 @@ __all__ = [
82
90
  "BoundingBox",
83
91
  "CoordinateReferenceSystem",
84
92
  "CubicStructure",
93
+ "DistanceCollection",
85
94
  "DownholeCollection",
86
95
  "DownholeCollectionData",
87
96
  "DownholeIntervals",
@@ -92,6 +101,7 @@ __all__ = [
92
101
  "ExponentialStructure",
93
102
  "GaussianStructure",
94
103
  "GeneralisedCauchyStructure",
104
+ "IntervalCollection",
95
105
  "LinearStructure",
96
106
  "Locations",
97
107
  "MaskedCells",
@@ -110,6 +120,7 @@ __all__ = [
110
120
  "SpheroidalStructure",
111
121
  "Tensor3DGrid",
112
122
  "Tensor3DGridData",
123
+ "Unit",
113
124
  "Variogram",
114
125
  "VariogramCurveData",
115
126
  "VariogramData",
@@ -19,6 +19,7 @@ from typing import Annotated
19
19
  from uuid import UUID
20
20
 
21
21
  import pandas as pd
22
+ from pydantic import AliasChoices, Field
22
23
 
23
24
  from evo.common import IFeedback
24
25
  from evo.common.utils import NoFeedback
@@ -198,8 +199,8 @@ class BlockModelGeometry:
198
199
 
199
200
  model_type: str
200
201
  origin: Point3
201
- n_blocks: Size3i
202
- block_size: Size3d
202
+ n_blocks: Annotated[Size3i, Field(validation_alias=AliasChoices("n_blocks", "n_parent_blocks"))]
203
+ block_size: Annotated[Size3d, Field(validation_alias=AliasChoices("block_size", "parent_block_size"))]
203
204
  rotation: Rotation | None = None
204
205
 
205
206
 
@@ -450,15 +450,30 @@ class SchemaList(Sequence[_M]):
450
450
  if get_origin(base) is SchemaList:
451
451
  args = get_args(base)
452
452
  if args:
453
- cls._item_type = args[0]
453
+ item_type = args[0]
454
+ if isinstance(item_type, type):
455
+ cls._item_type = item_type
456
+ elif "_resolve_item_type" not in cls.__dict__ or "_data_to_schema" not in cls.__dict__:
457
+ raise TypeError(
458
+ "SchemaList with a non-class item type must override _resolve_item_type() and _data_to_schema()"
459
+ )
454
460
  break
455
461
 
456
462
  def __getitem__(self, index: int) -> _M:
457
- return self._item_type(self._context, self._document[index])
463
+ document = self._document[index]
464
+ return self._resolve_item_type(document)(self._context, document)
458
465
 
459
466
  def __iter__(self):
460
467
  for item in self._document:
461
- yield self._item_type(self._context, item)
468
+ yield self._resolve_item_type(item)(self._context, item)
469
+
470
+ @classmethod
471
+ def _resolve_item_type(cls, document: dict[str, Any]) -> type[_M]:
472
+ """Resolve the schema model used for a document item.
473
+
474
+ Subclasses with heterogeneous document items can override this hook.
475
+ """
476
+ return cls._item_type
462
477
 
463
478
  def __len__(self) -> int:
464
479
  return len(self._document)
@@ -0,0 +1,72 @@
1
+ # Copyright © 2026 Bentley Systems, Incorporated
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ # http://www.apache.org/licenses/LICENSE-2.0
6
+ # Unless required by applicable law or agreed to in writing, software
7
+ # distributed under the License is distributed on an "AS IS" BASIS,
8
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9
+ # See the License for the specific language governing permissions and
10
+ # limitations under the License.
11
+
12
+ """Helpers for warming typed-object data in the local cache."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from collections.abc import Sequence
18
+ from typing import Any
19
+
20
+ from evo.common import IFeedback
21
+ from evo.common.utils import NoFeedback
22
+ from evo.objects import DownloadedObject
23
+ from evo.objects.io import _CACHE_SCOPE
24
+
25
+
26
+ def collect_data_ids(documents: Any) -> list[str]:
27
+ """Collect de-duplicated ``data`` references from document values."""
28
+ ids: list[str] = []
29
+
30
+ def visit(value: Any) -> None:
31
+ if isinstance(value, dict):
32
+ for key, nested in value.items():
33
+ if key == "data" and isinstance(nested, str) and nested not in ids:
34
+ ids.append(nested)
35
+ else:
36
+ visit(nested)
37
+ elif isinstance(value, (list, tuple)):
38
+ for nested in value:
39
+ visit(nested)
40
+
41
+ visit(documents)
42
+ return ids
43
+
44
+
45
+ async def prefetch_object_data(
46
+ obj: DownloadedObject,
47
+ *,
48
+ data_ids: Sequence[str] | None = None,
49
+ max_concurrent: int = 8,
50
+ fb: IFeedback = NoFeedback,
51
+ ) -> None:
52
+ """Warm cache entries referenced by an object, downloading each ID at most once."""
53
+ if max_concurrent < 1:
54
+ raise ValueError("max_concurrent must be at least 1")
55
+ cache = obj.get_cache()
56
+ if cache is None:
57
+ raise ValueError("prefetch requires an IContext with a cache")
58
+ identifiers = list(dict.fromkeys(data_ids if data_ids is not None else collect_data_ids(obj.as_dict())))
59
+ if not identifiers:
60
+ return
61
+ cache_location = cache.get_location(environment=obj.get_environment(), scope=_CACHE_SCOPE)
62
+ identifiers = [identifier for identifier in identifiers if not (cache_location / identifier).exists()]
63
+ if not identifiers:
64
+ return
65
+ contexts = list(obj.prepare_data_download(identifiers))
66
+ semaphore = asyncio.Semaphore(max_concurrent)
67
+
68
+ async def download(context: Any) -> None:
69
+ async with semaphore:
70
+ await context.download_to_cache(cache, obj.get_connector().transport, fb=fb)
71
+
72
+ await asyncio.gather(*(download(context) for context in contexts))
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import typing
15
15
  import uuid
16
16
  from dataclasses import dataclass
17
- from typing import TYPE_CHECKING, Annotated, Any
17
+ from typing import TYPE_CHECKING, Annotated, Any, Protocol, runtime_checkable
18
18
  from uuid import UUID
19
19
 
20
20
  import pandas as pd
@@ -41,11 +41,13 @@ if TYPE_CHECKING:
41
41
 
42
42
  __all__ = [
43
43
  "Attribute",
44
+ "AttributeDescription",
44
45
  "Attributes",
45
46
  "BlockModelAttribute",
46
47
  "BlockModelAttributes",
47
48
  "BlockModelPendingAttribute",
48
49
  "PendingAttribute",
50
+ "Unit",
49
51
  ]
50
52
 
51
53
 
@@ -53,6 +55,13 @@ class UnSupportedDataTypeError(Exception):
53
55
  """An unsupported data type was encountered while processing data."""
54
56
 
55
57
 
58
+ @runtime_checkable
59
+ class Unit(Protocol):
60
+ """A schema unit enum represented by its string value."""
61
+
62
+ value: str
63
+
64
+
56
65
  def _infer_attribute_type_from_series(series: pd.Series) -> str:
57
66
  """Infer the attribute type from a Pandas Series.
58
67
 
@@ -89,13 +98,20 @@ _attribute_table_formats = {
89
98
  class AttributeDescription:
90
99
  discipline: str = ""
91
100
  type: str = ""
92
- unit: str | None = None
101
+ unit: str | Unit | None = None
93
102
  scale: str | None = None
94
103
  extensions: dict[str, typing.Any] | None = None
95
104
  tags: dict[str, str] | None = None
96
105
 
97
- def to_schema(self):
98
- result = {
106
+ def __post_init__(self) -> None:
107
+ if self.unit is None or isinstance(self.unit, str):
108
+ return
109
+ if not isinstance(self.unit, Unit) or not isinstance(self.unit.value, str):
110
+ raise TypeError("unit must be a str, a Unit with a string value, or None")
111
+ self.unit = self.unit.value
112
+
113
+ def to_schema(self) -> dict[str, Any]:
114
+ result: dict[str, Any] = {
99
115
  "discipline": self.discipline,
100
116
  "type": self.type,
101
117
  }
@@ -117,6 +133,7 @@ class Attribute(SchemaModel):
117
133
  _attribute_type: Annotated[str, SchemaLocation("attribute_type")]
118
134
  _key: Annotated[str | None, SchemaLocation("key")]
119
135
  _data: Annotated[str, SchemaLocation("values.data")]
136
+ _attribute_description: Annotated[dict[str, Any] | None, SchemaLocation("attribute_description")]
120
137
 
121
138
  @property
122
139
  def key(self) -> str:
@@ -132,6 +149,12 @@ class Attribute(SchemaModel):
132
149
  """The type of this attribute."""
133
150
  return self._attribute_type
134
151
 
152
+ @property
153
+ def attribute_description(self) -> AttributeDescription | None:
154
+ """Optional descriptive metadata associated with this attribute."""
155
+ raw = self._attribute_description
156
+ return AttributeDescription(**raw) if raw else None
157
+
135
158
  @property
136
159
  def exists(self) -> bool:
137
160
  """Whether this attribute exists on the object.
@@ -241,7 +264,7 @@ class Attributes(SchemaList[Attribute]):
241
264
  attribute descriptions are attached to the DataFrame's `attrs` attribute.
242
265
 
243
266
  >>> df.attrs
244
- {'attribute_description': {<column names>: <AttributeDescription>}, ...}
267
+ {'attribute_descriptions': {<column names>: <AttributeDescription>}, ...}
245
268
  """
246
269
 
247
270
  _schema_path: str | None = None
@@ -320,8 +343,8 @@ class Attributes(SchemaList[Attribute]):
320
343
  if attr_desc is not None:
321
344
  if not isinstance(attr_desc, AttributeDescription):
322
345
  raise TypeError("attribute description must be a AttributeDescription.")
323
- if attr_desc.unit is not None:
324
- attr_doc["attribute_description"] = attr_desc.to_schema()
346
+ if description := attr_desc.to_schema():
347
+ attr_doc["attribute_description"] = description
325
348
 
326
349
  attributes_list.append(attr_doc)
327
350
 
@@ -337,7 +360,15 @@ class Attributes(SchemaList[Attribute]):
337
360
  """
338
361
  attributes = [self[key] for key in keys] if keys else list(self)
339
362
  parts = [await attribute.to_dataframe(fb=fb_part) for attribute, fb_part in iter_with_fb(attributes, fb)]
340
- return pd.concat(parts, axis=1) if len(parts) > 0 else pd.DataFrame()
363
+ result = pd.concat(parts, axis=1) if len(parts) > 0 else pd.DataFrame()
364
+ descriptions = {
365
+ attribute.name: description
366
+ for attribute in attributes
367
+ if isinstance(attribute, Attribute) and (description := attribute.attribute_description) is not None
368
+ }
369
+ if descriptions:
370
+ result.attrs["attribute_descriptions"] = descriptions
371
+ return result
341
372
 
342
373
  async def append_attribute(self, df: pd.DataFrame, fb: IFeedback = NoFeedback):
343
374
  """Add a new attribute to the object.
@@ -601,3 +632,14 @@ class Category(SchemaModel):
601
632
  if self._context.is_data_modified(self._data):
602
633
  raise DataLoaderError("Data was modified since the object was downloaded")
603
634
  return await self._obj.download_category_dataframe(self.as_dict(), fb=fb)
635
+
636
+ async def to_indexed_dataframe(self, fb: IFeedback = NoFeedback) -> pd.DataFrame:
637
+ """Load the persisted category lookup as ``[key, value]`` rows.
638
+
639
+ Pandas categorical codes are dense positional values and must not be used
640
+ as schema lookup keys. This method is intended for joins involving an
641
+ index column such as ``hole_index``.
642
+ """
643
+ if self._context.is_data_modified(self._data):
644
+ raise DataLoaderError("Data was modified since the object was downloaded")
645
+ return await self._obj.download_dataframe(self.as_dict()["table"], fb=fb)
@@ -14,15 +14,18 @@ from __future__ import annotations
14
14
  import copy
15
15
  import sys
16
16
  import weakref
17
+ from collections.abc import Sequence
17
18
  from dataclasses import dataclass
18
19
  from typing import Annotated, Any, ClassVar
19
20
  from uuid import UUID
20
21
 
21
22
  from evo import jmespath
22
- from evo.common import IContext, StaticContext
23
+ from evo.common import IContext, IFeedback, StaticContext
24
+ from evo.common.utils import NoFeedback
23
25
  from evo.objects import DownloadedObject, ObjectMetadata, ObjectReference, ObjectSchema, SchemaVersion
24
26
 
25
27
  from ._model import ModelContext, SchemaLocation, SchemaModel
28
+ from ._prefetch import prefetch_object_data
26
29
  from ._utils import (
27
30
  create_geoscience_object,
28
31
  replace_geoscience_object,
@@ -416,6 +419,17 @@ class BaseObject(_BaseObject):
416
419
  tags: Annotated[dict[str, str], SchemaLocation("tags")] = {}
417
420
  extensions: Annotated[dict, SchemaLocation("extensions")] = {}
418
421
 
422
+ async def prefetch(
423
+ self,
424
+ *,
425
+ data_ids: Sequence[str] | None = None,
426
+ max_concurrent: int = 8,
427
+ fb: IFeedback = NoFeedback,
428
+ ) -> None:
429
+ """Warm cached data files referenced by this object."""
430
+
431
+ await prefetch_object_data(self._obj, data_ids=data_ids, max_concurrent=max_concurrent, fb=fb)
432
+
419
433
  @classmethod
420
434
  def create(
421
435
  cls,