ocelescope-module-ocelot 0.1.0__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.
- ocelescope_module_ocelot-0.1.0/PKG-INFO +6 -0
- ocelescope_module_ocelot-0.1.0/pyproject.toml +16 -0
- ocelescope_module_ocelot-0.1.0/src/ocelescope_module_ocelot/__init__.py +0 -0
- ocelescope_module_ocelot-0.1.0/src/ocelescope_module_ocelot/models.py +27 -0
- ocelescope_module_ocelot-0.1.0/src/ocelescope_module_ocelot/module.py +19 -0
- ocelescope_module_ocelot-0.1.0/src/ocelescope_module_ocelot/routes.py +70 -0
- ocelescope_module_ocelot-0.1.0/src/ocelescope_module_ocelot/util.py +336 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ocelescope-module-ocelot"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "An ocelescope module for giving paginated exploration in ocelescope"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = ["ocelescope-backend>=0.1.1"]
|
|
7
|
+
|
|
8
|
+
[project.entry-points."ocelescope_backend.modules"]
|
|
9
|
+
ocelot = "ocelescope_module_ocelot.module:Ocelot"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["uv_build>=0.11.20,<0.12.0"]
|
|
13
|
+
build-backend = "uv_build"
|
|
14
|
+
|
|
15
|
+
[tool.uv]
|
|
16
|
+
package = true
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OcelEntity(BaseModel):
|
|
9
|
+
id: str
|
|
10
|
+
timestamp: Optional[datetime] = None
|
|
11
|
+
attributes: Dict[str, Any]
|
|
12
|
+
relations: Dict[str, List[str]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PaginatedResponse(BaseModel):
|
|
16
|
+
page: int
|
|
17
|
+
page_size: int
|
|
18
|
+
total_pages: int
|
|
19
|
+
total_items: int
|
|
20
|
+
items: List[OcelEntity]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class EntityTableColumn:
|
|
25
|
+
accessor: str
|
|
26
|
+
type: Literal["attribute", "relation"]
|
|
27
|
+
title: str | None = None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from fastapi import FastAPI
|
|
2
|
+
from ocelescope_backend.app.modules import Module, ModuleMeta
|
|
3
|
+
from packaging.version import Version
|
|
4
|
+
|
|
5
|
+
from ocelescope_module_ocelot.routes import router
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Ocelot(Module):
|
|
9
|
+
meta = ModuleMeta(key="ocelot", version=Version("1.0"))
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def create_app(cls) -> FastAPI:
|
|
13
|
+
app = FastAPI(
|
|
14
|
+
title="Ocelot", version=str(cls.meta.version), docs_url=None, redoc_url=None
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
app.include_router(router)
|
|
18
|
+
|
|
19
|
+
return app
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Query
|
|
4
|
+
from ocelescope_backend.app.dependencies import ApiOcel
|
|
5
|
+
|
|
6
|
+
from ocelescope_module_ocelot.models import EntityTableColumn, PaginatedResponse
|
|
7
|
+
from ocelescope_module_ocelot.util import (
|
|
8
|
+
get_activity_columns_def,
|
|
9
|
+
get_object_columns_def,
|
|
10
|
+
get_paginated_event_table,
|
|
11
|
+
get_paginated_object_table,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get(
|
|
18
|
+
"/{ocel_id}/events",
|
|
19
|
+
response_model=PaginatedResponse,
|
|
20
|
+
operation_id="paginatedEvents",
|
|
21
|
+
)
|
|
22
|
+
def get_events(
|
|
23
|
+
ocel: ApiOcel,
|
|
24
|
+
type: Annotated[str, Query(description="Activity name")],
|
|
25
|
+
page: Annotated[int, Query(ge=1)] = 1,
|
|
26
|
+
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
|
|
27
|
+
sort_by: Annotated[str | None, Query()] = None,
|
|
28
|
+
ascending: Annotated[bool, Query()] = True,
|
|
29
|
+
):
|
|
30
|
+
return get_paginated_event_table(
|
|
31
|
+
ocel=ocel,
|
|
32
|
+
activity=type,
|
|
33
|
+
page_index=page,
|
|
34
|
+
page_size=page_size,
|
|
35
|
+
sort_by=sort_by,
|
|
36
|
+
ascending=ascending,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.get(
|
|
41
|
+
"/{ocel_id}/objects",
|
|
42
|
+
response_model=PaginatedResponse,
|
|
43
|
+
operation_id="paginatedObjects",
|
|
44
|
+
)
|
|
45
|
+
def get_objects(
|
|
46
|
+
ocel: ApiOcel,
|
|
47
|
+
type: Annotated[str, Query(description="Object type name")],
|
|
48
|
+
page: Annotated[int, Query(ge=1)] = 1,
|
|
49
|
+
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
|
|
50
|
+
sort_by: Annotated[str | None, Query()] = None,
|
|
51
|
+
ascending: Annotated[bool, Query()] = True,
|
|
52
|
+
):
|
|
53
|
+
return get_paginated_object_table(
|
|
54
|
+
ocel=ocel,
|
|
55
|
+
object_type=type,
|
|
56
|
+
page_index=page,
|
|
57
|
+
page_size=page_size,
|
|
58
|
+
sort_by=sort_by,
|
|
59
|
+
ascending=ascending,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get("/{ocel_id}/objects/columns", operation_id="objectColumns")
|
|
64
|
+
def get_object_columns(ocel: ApiOcel, type_name: str) -> list[EntityTableColumn]:
|
|
65
|
+
return get_object_columns_def(ocel, type_name)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get("/{ocel_id}/events/columns", operation_id="activityColumns")
|
|
69
|
+
def get_activity_columns(ocel: ApiOcel, type_name: str) -> list[EntityTableColumn]:
|
|
70
|
+
return get_activity_columns_def(ocel, type_name)
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import Any, Iterable, TypeVar
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from ocelescope import OCEL
|
|
7
|
+
from ocelescope.ocel.constants import (
|
|
8
|
+
ACTIVITY_COL,
|
|
9
|
+
E2O_ACTIVITY,
|
|
10
|
+
E2O_OBJECT_TYPE,
|
|
11
|
+
O2O_SOURCE_TYPE,
|
|
12
|
+
)
|
|
13
|
+
from ocelescope.ocel.constants.pm4py import (
|
|
14
|
+
E2O_QUALIFIER,
|
|
15
|
+
EID_COL,
|
|
16
|
+
O2O_QUALIFIER,
|
|
17
|
+
O2O_SOURCE_ID,
|
|
18
|
+
O2O_TARGET_ID,
|
|
19
|
+
O2O_TARGET_TYPE,
|
|
20
|
+
OID_COL,
|
|
21
|
+
OTYPE_COL,
|
|
22
|
+
TIMESTAMP_COL,
|
|
23
|
+
)
|
|
24
|
+
from ocelescope.util.pandas import coerce_series
|
|
25
|
+
|
|
26
|
+
from ocelescope_module_ocelot.models import (
|
|
27
|
+
EntityTableColumn,
|
|
28
|
+
OcelEntity,
|
|
29
|
+
PaginatedResponse,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_python_scalar(value: Any) -> Any:
|
|
36
|
+
if isinstance(value, np.generic):
|
|
37
|
+
return value.item()
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def none_or_python_value(value: Any) -> Any | None:
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
if isinstance(value, str) and not value.strip():
|
|
45
|
+
return None
|
|
46
|
+
if pd.api.types.is_scalar(value) and pd.isna(value):
|
|
47
|
+
return None
|
|
48
|
+
return to_python_scalar(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def first_present(values: Iterable[Any]) -> Any | None:
|
|
52
|
+
for value in values:
|
|
53
|
+
cleaned_value = none_or_python_value(value)
|
|
54
|
+
if cleaned_value is not None:
|
|
55
|
+
return cleaned_value
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def relation_accessor(parts: Iterable[Any]) -> str:
|
|
60
|
+
return str(first_present(parts))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def paginate_df(
|
|
64
|
+
df: pd.DataFrame,
|
|
65
|
+
page_size: int,
|
|
66
|
+
page_index: int,
|
|
67
|
+
) -> tuple[pd.DataFrame, int, int]:
|
|
68
|
+
if page_size <= 0:
|
|
69
|
+
raise ValueError("page_size must be greater than 0")
|
|
70
|
+
if page_index <= 0:
|
|
71
|
+
raise ValueError("page_index must be greater than 0")
|
|
72
|
+
|
|
73
|
+
total_items = len(df)
|
|
74
|
+
total_pages = math.ceil(total_items / page_size) if total_items else 0
|
|
75
|
+
|
|
76
|
+
if total_pages > 0 and page_index > total_pages:
|
|
77
|
+
raise ValueError(f"page_index {page_index} exceeds total_pages {total_pages}")
|
|
78
|
+
|
|
79
|
+
start = (page_index - 1) * page_size
|
|
80
|
+
end = page_index * page_size
|
|
81
|
+
return df.iloc[start:end], total_items, total_pages
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def serialize_object_attribute(value: Any) -> Any:
|
|
85
|
+
if isinstance(value, (str, bytes, dict)) or not isinstance(value, Iterable):
|
|
86
|
+
return to_python_scalar(value)
|
|
87
|
+
|
|
88
|
+
return first_present(reversed(value))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_object_columns_def(
|
|
92
|
+
ocel: OCEL,
|
|
93
|
+
object_type: str,
|
|
94
|
+
) -> list[EntityTableColumn]:
|
|
95
|
+
typed_o2o = ocel.o2o.typed_df
|
|
96
|
+
|
|
97
|
+
relation_rows = typed_o2o.loc[
|
|
98
|
+
typed_o2o[O2O_SOURCE_TYPE].eq(object_type)
|
|
99
|
+
].drop_duplicates([O2O_TARGET_TYPE, O2O_QUALIFIER])[
|
|
100
|
+
[O2O_TARGET_TYPE, O2O_QUALIFIER]
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
relation_columns = [
|
|
104
|
+
EntityTableColumn(
|
|
105
|
+
accessor=relation_accessor([qualifier, target_object_type]),
|
|
106
|
+
type="relation",
|
|
107
|
+
title=f"{qualifier} ({target_object_type})",
|
|
108
|
+
)
|
|
109
|
+
for _, target_object_type, qualifier in relation_rows.itertuples()
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
attribute_columns = [
|
|
113
|
+
EntityTableColumn(accessor=attribute, type="attribute")
|
|
114
|
+
for _, attribute in ocel.attributes.get_object_summary(
|
|
115
|
+
object_types=[object_type]
|
|
116
|
+
).index
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
return [
|
|
120
|
+
EntityTableColumn(accessor="id", type="relation", title="#"),
|
|
121
|
+
*attribute_columns,
|
|
122
|
+
*relation_columns,
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_activity_columns_def(
|
|
127
|
+
ocel: OCEL,
|
|
128
|
+
activity_name: str,
|
|
129
|
+
) -> list[EntityTableColumn]:
|
|
130
|
+
typed_e2o = ocel.e2o.df
|
|
131
|
+
|
|
132
|
+
relation_rows = typed_e2o.loc[
|
|
133
|
+
typed_e2o[E2O_ACTIVITY].eq(activity_name)
|
|
134
|
+
].drop_duplicates([E2O_OBJECT_TYPE, E2O_QUALIFIER])[
|
|
135
|
+
[E2O_OBJECT_TYPE, E2O_QUALIFIER]
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
relation_columns = [
|
|
139
|
+
EntityTableColumn(
|
|
140
|
+
accessor=relation_accessor([qualifier, object_type]),
|
|
141
|
+
type="relation",
|
|
142
|
+
title=f"{qualifier} ({object_type})",
|
|
143
|
+
)
|
|
144
|
+
for _, object_type, qualifier in relation_rows.itertuples()
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
attribute_columns = [
|
|
148
|
+
EntityTableColumn(accessor=attribute, type="attribute")
|
|
149
|
+
for _, attribute in ocel.attributes.get_activity_summary(
|
|
150
|
+
activities=[activity_name]
|
|
151
|
+
).index
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
return [
|
|
155
|
+
EntityTableColumn(accessor="id", title="#", type="attribute"),
|
|
156
|
+
EntityTableColumn(
|
|
157
|
+
accessor="timestamp",
|
|
158
|
+
title="Timestamp",
|
|
159
|
+
type="attribute",
|
|
160
|
+
),
|
|
161
|
+
*attribute_columns,
|
|
162
|
+
*relation_columns,
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_sort_by_key(sort_by, id_string):
|
|
167
|
+
match sort_by:
|
|
168
|
+
case "id":
|
|
169
|
+
return id_string
|
|
170
|
+
case "timestamp":
|
|
171
|
+
return TIMESTAMP_COL
|
|
172
|
+
case _:
|
|
173
|
+
return sort_by
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_paginated_event_table(
|
|
177
|
+
ocel: OCEL,
|
|
178
|
+
page_size: int,
|
|
179
|
+
page_index: int,
|
|
180
|
+
activity: str,
|
|
181
|
+
sort_by: str | None = None,
|
|
182
|
+
ascending: bool = True,
|
|
183
|
+
) -> PaginatedResponse:
|
|
184
|
+
events_table = ocel.events.df.loc[ocel.events.df[ACTIVITY_COL].eq(activity)]
|
|
185
|
+
|
|
186
|
+
if sort_by is not None:
|
|
187
|
+
events_table = events_table.sort_values(
|
|
188
|
+
by=get_sort_by_key(sort_by, EID_COL), ascending=ascending
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
events_table, total_entities, total_pages = paginate_df(
|
|
192
|
+
events_table,
|
|
193
|
+
page_size=page_size,
|
|
194
|
+
page_index=page_index,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
events_table = events_table.dropna(axis=1, how="all")
|
|
198
|
+
|
|
199
|
+
e2o_table = (
|
|
200
|
+
ocel.e2o.df.loc[ocel.e2o.df[EID_COL].isin(events_table[EID_COL])]
|
|
201
|
+
.groupby([EID_COL, E2O_QUALIFIER, OTYPE_COL])[OID_COL]
|
|
202
|
+
.agg(list)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
e2o_entity_ids = set(e2o_table.index.get_level_values(0))
|
|
206
|
+
|
|
207
|
+
return PaginatedResponse(
|
|
208
|
+
page=page_index,
|
|
209
|
+
page_size=page_size,
|
|
210
|
+
total_pages=total_pages,
|
|
211
|
+
total_items=total_entities,
|
|
212
|
+
items=[
|
|
213
|
+
OcelEntity(
|
|
214
|
+
id=str(entity_id),
|
|
215
|
+
timestamp=row[TIMESTAMP_COL],
|
|
216
|
+
attributes={
|
|
217
|
+
str(column_name): to_python_scalar(value)
|
|
218
|
+
for column_name, value in row.items()
|
|
219
|
+
if column_name not in [TIMESTAMP_COL, ACTIVITY_COL]
|
|
220
|
+
},
|
|
221
|
+
relations={
|
|
222
|
+
relation_accessor(index[1:]): objects
|
|
223
|
+
for index, objects in e2o_table.loc[[str(entity_id)]].items()
|
|
224
|
+
}
|
|
225
|
+
if entity_id in e2o_entity_ids
|
|
226
|
+
else {},
|
|
227
|
+
)
|
|
228
|
+
for entity_id, row in events_table.set_index(EID_COL).iterrows()
|
|
229
|
+
],
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_paginated_object_table(
|
|
234
|
+
ocel: OCEL,
|
|
235
|
+
page_size: int,
|
|
236
|
+
page_index: int,
|
|
237
|
+
object_type: str,
|
|
238
|
+
sort_by: str | None = None,
|
|
239
|
+
ascending: bool = True,
|
|
240
|
+
) -> PaginatedResponse:
|
|
241
|
+
object_table = ocel.objects.df.loc[
|
|
242
|
+
ocel.objects.df[OTYPE_COL].eq(object_type)
|
|
243
|
+
].set_index(OID_COL)
|
|
244
|
+
|
|
245
|
+
if sort_by:
|
|
246
|
+
if sort_by in ocel.objects.dynamic_attribute_names:
|
|
247
|
+
changes = (
|
|
248
|
+
ocel.objects.changes.loc[
|
|
249
|
+
ocel.objects.changes[OTYPE_COL].eq(object_type)
|
|
250
|
+
& ocel.objects.changes["ocel:field"].eq(sort_by),
|
|
251
|
+
[OID_COL, sort_by],
|
|
252
|
+
]
|
|
253
|
+
.drop_duplicates(OID_COL, keep="last")
|
|
254
|
+
.set_index(OID_COL)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if sort_by not in object_table:
|
|
258
|
+
object_table[sort_by] = None
|
|
259
|
+
|
|
260
|
+
object_table.update(changes)
|
|
261
|
+
|
|
262
|
+
object_table[sort_by] = coerce_series(object_table[sort_by])
|
|
263
|
+
object_table = object_table.sort_values(
|
|
264
|
+
by=get_sort_by_key(sort_by, OID_COL), ascending=ascending
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
object_table, total_objects, total_pages = paginate_df(
|
|
268
|
+
object_table,
|
|
269
|
+
page_size=page_size,
|
|
270
|
+
page_index=page_index,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
changes_table = ocel.objects.changes.loc[
|
|
274
|
+
ocel.objects.changes[OID_COL].isin(object_table.index)
|
|
275
|
+
].dropna(axis=1, how="all")
|
|
276
|
+
|
|
277
|
+
if not changes_table.empty:
|
|
278
|
+
dynamic_attributes = changes_table["ocel:field"].drop_duplicates()
|
|
279
|
+
dynamic_attributes_aggr = {field: (field, list) for field in dynamic_attributes}
|
|
280
|
+
|
|
281
|
+
changes_table = changes_table.groupby(OID_COL).agg(
|
|
282
|
+
**dynamic_attributes_aggr,
|
|
283
|
+
**{TIMESTAMP_COL: (TIMESTAMP_COL, list)},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
object_table = object_table.merge(
|
|
287
|
+
changes_table,
|
|
288
|
+
how="left",
|
|
289
|
+
left_index=True,
|
|
290
|
+
right_index=True,
|
|
291
|
+
suffixes=("", "_new"),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
for column_name in dynamic_attributes:
|
|
295
|
+
object_table[column_name] = object_table[
|
|
296
|
+
f"{column_name}_new"
|
|
297
|
+
].combine_first(object_table[column_name].astype(object))
|
|
298
|
+
|
|
299
|
+
object_table = object_table.drop(
|
|
300
|
+
columns=[f"{column_name}_new" for column_name in dynamic_attributes]
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
typed_o2o = ocel.o2o.typed_df
|
|
304
|
+
o2o_table = (
|
|
305
|
+
typed_o2o.loc[typed_o2o[O2O_SOURCE_ID].isin(object_table.index)]
|
|
306
|
+
.groupby([O2O_SOURCE_ID, O2O_QUALIFIER, O2O_TARGET_TYPE])[O2O_TARGET_ID]
|
|
307
|
+
.agg(list)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
object_table = object_table.dropna(axis=1, how="all").drop(columns=[OTYPE_COL])
|
|
311
|
+
|
|
312
|
+
o2o_entity_ids = set(o2o_table.index.get_level_values(0))
|
|
313
|
+
|
|
314
|
+
return PaginatedResponse(
|
|
315
|
+
page=page_index,
|
|
316
|
+
page_size=page_size,
|
|
317
|
+
total_pages=total_pages,
|
|
318
|
+
total_items=total_objects,
|
|
319
|
+
items=[
|
|
320
|
+
OcelEntity(
|
|
321
|
+
id=str(entity_id),
|
|
322
|
+
attributes={
|
|
323
|
+
str(column_name): serialize_object_attribute(value)
|
|
324
|
+
for column_name, value in row.items()
|
|
325
|
+
if column_name != TIMESTAMP_COL
|
|
326
|
+
},
|
|
327
|
+
relations={
|
|
328
|
+
relation_accessor(index[1:]): list(objects)
|
|
329
|
+
for index, objects in o2o_table.loc[[str(entity_id)]].items()
|
|
330
|
+
}
|
|
331
|
+
if entity_id in o2o_entity_ids
|
|
332
|
+
else {},
|
|
333
|
+
)
|
|
334
|
+
for entity_id, row in object_table.iterrows()
|
|
335
|
+
],
|
|
336
|
+
)
|