port-ocean 0.17.7__py3-none-any.whl → 0.18.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.
Potentially problematic release.
This version of port-ocean might be problematic. Click here for more details.
- port_ocean/clients/port/mixins/entities.py +21 -6
- port_ocean/core/integrations/mixins/sync_raw.py +132 -24
- port_ocean/core/models.py +4 -0
- port_ocean/core/utils/utils.py +80 -4
- port_ocean/tests/core/handlers/mixins/test_sync_raw.py +309 -2
- port_ocean/tests/core/utils/test_resolve_entities_diff.py +559 -0
- {port_ocean-0.17.7.dist-info → port_ocean-0.18.0.dist-info}/METADATA +2 -3
- {port_ocean-0.17.7.dist-info → port_ocean-0.18.0.dist-info}/RECORD +11 -10
- {port_ocean-0.17.7.dist-info → port_ocean-0.18.0.dist-info}/LICENSE.md +0 -0
- {port_ocean-0.17.7.dist-info → port_ocean-0.18.0.dist-info}/WHEEL +0 -0
- {port_ocean-0.17.7.dist-info → port_ocean-0.18.0.dist-info}/entry_points.txt +0 -0
|
@@ -98,10 +98,22 @@ class EntityClientMixin:
|
|
|
98
98
|
if result_entity.is_using_search_identifier:
|
|
99
99
|
return None
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
return self._reduce_entity(result_entity)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _reduce_entity(entity: Entity) -> Entity:
|
|
105
|
+
"""
|
|
106
|
+
Reduces an entity to only keep identifier, blueprint and processed relations.
|
|
107
|
+
This helps save memory by removing unnecessary data.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
entity: The entity to reduce
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Entity: A new entity with only the essential data
|
|
114
|
+
"""
|
|
103
115
|
reduced_entity = Entity(
|
|
104
|
-
identifier=
|
|
116
|
+
identifier=entity.identifier, blueprint=entity.blueprint
|
|
105
117
|
)
|
|
106
118
|
|
|
107
119
|
# Turning dict typed relations (raw search relations) is required
|
|
@@ -109,7 +121,7 @@ class EntityClientMixin:
|
|
|
109
121
|
# and ignore the ones that don't as they weren't upserted
|
|
110
122
|
reduced_entity.relations = {
|
|
111
123
|
key: None if isinstance(relation, dict) else relation
|
|
112
|
-
for key, relation in
|
|
124
|
+
for key, relation in entity.relations.items()
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
return reduced_entity
|
|
@@ -202,7 +214,10 @@ class EntityClientMixin:
|
|
|
202
214
|
)
|
|
203
215
|
|
|
204
216
|
async def search_entities(
|
|
205
|
-
self,
|
|
217
|
+
self,
|
|
218
|
+
user_agent_type: UserAgentType,
|
|
219
|
+
query: dict[Any, Any] | None = None,
|
|
220
|
+
parameters_to_include: list[str] | None = None,
|
|
206
221
|
) -> list[Entity]:
|
|
207
222
|
default_query = {
|
|
208
223
|
"combinator": "and",
|
|
@@ -232,7 +247,7 @@ class EntityClientMixin:
|
|
|
232
247
|
headers=await self.auth.headers(user_agent_type),
|
|
233
248
|
params={
|
|
234
249
|
"exclude_calculated_properties": "true",
|
|
235
|
-
"include": ["blueprint", "identifier"],
|
|
250
|
+
"include": parameters_to_include or ["blueprint", "identifier"],
|
|
236
251
|
},
|
|
237
252
|
extensions={"retryable": True},
|
|
238
253
|
)
|
|
@@ -28,7 +28,7 @@ from port_ocean.core.ocean_types import (
|
|
|
28
28
|
RAW_ITEM,
|
|
29
29
|
CalculationResult,
|
|
30
30
|
)
|
|
31
|
-
from port_ocean.core.utils.utils import zip_and_sum, gather_and_split_errors_from_results
|
|
31
|
+
from port_ocean.core.utils.utils import resolve_entities_diff, zip_and_sum, gather_and_split_errors_from_results
|
|
32
32
|
from port_ocean.exceptions.core import OceanAbortException
|
|
33
33
|
|
|
34
34
|
SEND_RAW_DATA_EXAMPLES_AMOUNT = 5
|
|
@@ -130,24 +130,128 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
|
|
|
130
130
|
)
|
|
131
131
|
)
|
|
132
132
|
|
|
133
|
+
def _construct_search_query_for_entities(self, entities: list[Entity]) -> dict:
|
|
134
|
+
"""Create a query to search for entities by their identifiers.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
entities (list[Entity]): List of entities to search for.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
dict: Query structure for searching entities by identifier.
|
|
141
|
+
"""
|
|
142
|
+
return {
|
|
143
|
+
"combinator": "and",
|
|
144
|
+
"rules": [
|
|
145
|
+
{
|
|
146
|
+
"combinator": "or",
|
|
147
|
+
"rules": [
|
|
148
|
+
{
|
|
149
|
+
"property": "$identifier",
|
|
150
|
+
"operator": "=",
|
|
151
|
+
"value": entity.identifier,
|
|
152
|
+
}
|
|
153
|
+
for entity in entities
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async def _map_entities_compared_with_port(
|
|
160
|
+
self,
|
|
161
|
+
entities: list[Entity],
|
|
162
|
+
resource: ResourceConfig,
|
|
163
|
+
user_agent_type: UserAgentType,
|
|
164
|
+
) -> list[Entity]:
|
|
165
|
+
if not entities:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
if entities[0].is_using_search_identifier or entities[0].is_using_search_relation:
|
|
169
|
+
return entities
|
|
170
|
+
|
|
171
|
+
BATCH_SIZE = 50
|
|
172
|
+
entities_at_port_with_properties = []
|
|
173
|
+
|
|
174
|
+
# Process entities in batches
|
|
175
|
+
for start_index in range(0, len(entities), BATCH_SIZE):
|
|
176
|
+
entities_batch = entities[start_index:start_index + BATCH_SIZE]
|
|
177
|
+
batch_results = await self._fetch_entities_batch_from_port(
|
|
178
|
+
entities_batch,
|
|
179
|
+
resource,
|
|
180
|
+
user_agent_type
|
|
181
|
+
)
|
|
182
|
+
entities_at_port_with_properties.extend(batch_results)
|
|
183
|
+
|
|
184
|
+
logger.info("Got entities from port with properties and relations", port_entities=len(entities_at_port_with_properties))
|
|
185
|
+
|
|
186
|
+
if len(entities_at_port_with_properties) > 0:
|
|
187
|
+
return resolve_entities_diff(entities, entities_at_port_with_properties)
|
|
188
|
+
return entities
|
|
189
|
+
|
|
190
|
+
async def _fetch_entities_batch_from_port(
|
|
191
|
+
self,
|
|
192
|
+
entities_batch: list[Entity],
|
|
193
|
+
resource: ResourceConfig,
|
|
194
|
+
user_agent_type: UserAgentType,
|
|
195
|
+
) -> list[Entity]:
|
|
196
|
+
query = self._construct_search_query_for_entities(entities_batch)
|
|
197
|
+
return await ocean.port_client.search_entities(
|
|
198
|
+
user_agent_type,
|
|
199
|
+
parameters_to_include=["blueprint", "identifier"] + (
|
|
200
|
+
["title"] if resource.port.entity.mappings.title != None else []
|
|
201
|
+
) + (
|
|
202
|
+
["team"] if resource.port.entity.mappings.team != None else []
|
|
203
|
+
) + [
|
|
204
|
+
f"properties.{prop}" for prop in resource.port.entity.mappings.properties
|
|
205
|
+
] + [
|
|
206
|
+
f"relations.{relation}" for relation in resource.port.entity.mappings.relations
|
|
207
|
+
],
|
|
208
|
+
query=query
|
|
209
|
+
)
|
|
210
|
+
|
|
133
211
|
async def _register_resource_raw(
|
|
134
212
|
self,
|
|
135
213
|
resource: ResourceConfig,
|
|
136
214
|
results: list[dict[Any, Any]],
|
|
137
215
|
user_agent_type: UserAgentType,
|
|
138
216
|
parse_all: bool = False,
|
|
139
|
-
send_raw_data_examples_amount: int = 0
|
|
217
|
+
send_raw_data_examples_amount: int = 0
|
|
140
218
|
) -> CalculationResult:
|
|
141
219
|
objects_diff = await self._calculate_raw(
|
|
142
220
|
[(resource, results)], parse_all, send_raw_data_examples_amount
|
|
143
221
|
)
|
|
144
|
-
modified_objects =
|
|
145
|
-
|
|
222
|
+
modified_objects = []
|
|
223
|
+
|
|
224
|
+
if ocean.app.is_saas():
|
|
225
|
+
try:
|
|
226
|
+
changed_entities = await self._map_entities_compared_with_port(
|
|
227
|
+
objects_diff[0].entity_selector_diff.passed,
|
|
228
|
+
resource,
|
|
229
|
+
user_agent_type
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if changed_entities:
|
|
233
|
+
logger.info("Upserting changed entities", changed_entities=len(changed_entities),
|
|
234
|
+
total_entities=len(objects_diff[0].entity_selector_diff.passed))
|
|
235
|
+
await self.entities_state_applier.upsert(
|
|
236
|
+
changed_entities, user_agent_type
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
logger.info("Entities in batch didn't changed since last sync, skipping", total_entities=len(objects_diff[0].entity_selector_diff.passed))
|
|
240
|
+
|
|
241
|
+
modified_objects = [ocean.port_client._reduce_entity(entity) for entity in objects_diff[0].entity_selector_diff.passed]
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.warning(f"Failed to resolve batch entities with Port, falling back to upserting all entities: {str(e)}")
|
|
244
|
+
modified_objects = await self.entities_state_applier.upsert(
|
|
245
|
+
objects_diff[0].entity_selector_diff.passed, user_agent_type
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
modified_objects = await self.entities_state_applier.upsert(
|
|
249
|
+
objects_diff[0].entity_selector_diff.passed, user_agent_type
|
|
146
250
|
)
|
|
147
251
|
return CalculationResult(
|
|
148
252
|
objects_diff[0].entity_selector_diff._replace(passed=modified_objects),
|
|
149
253
|
errors=objects_diff[0].errors,
|
|
150
|
-
misonfigured_entity_keys=objects_diff[0].misonfigured_entity_keys
|
|
254
|
+
misonfigured_entity_keys=objects_diff[0].misonfigured_entity_keys
|
|
151
255
|
)
|
|
152
256
|
|
|
153
257
|
async def _unregister_resource_raw(
|
|
@@ -186,14 +290,17 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
|
|
|
186
290
|
send_raw_data_examples_amount = (
|
|
187
291
|
SEND_RAW_DATA_EXAMPLES_AMOUNT if ocean.config.send_raw_data_examples else 0
|
|
188
292
|
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
293
|
+
|
|
294
|
+
passed_entities = []
|
|
295
|
+
if raw_results:
|
|
296
|
+
calculation_result = await self._register_resource_raw(
|
|
297
|
+
resource_config,
|
|
298
|
+
raw_results,
|
|
299
|
+
user_agent_type,
|
|
300
|
+
send_raw_data_examples_amount=send_raw_data_examples_amount
|
|
301
|
+
)
|
|
302
|
+
errors.extend(calculation_result.errors)
|
|
303
|
+
passed_entities = list(calculation_result.entity_selector_diff.passed)
|
|
197
304
|
|
|
198
305
|
for generator in async_generators:
|
|
199
306
|
try:
|
|
@@ -203,14 +310,14 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
|
|
|
203
310
|
0, send_raw_data_examples_amount - len(passed_entities)
|
|
204
311
|
)
|
|
205
312
|
|
|
206
|
-
|
|
313
|
+
calculation_result = await self._register_resource_raw(
|
|
207
314
|
resource_config,
|
|
208
315
|
items,
|
|
209
316
|
user_agent_type,
|
|
210
|
-
send_raw_data_examples_amount=send_raw_data_examples_amount
|
|
317
|
+
send_raw_data_examples_amount=send_raw_data_examples_amount
|
|
211
318
|
)
|
|
212
|
-
errors.extend(
|
|
213
|
-
passed_entities.extend(
|
|
319
|
+
errors.extend(calculation_result.errors)
|
|
320
|
+
passed_entities.extend(calculation_result.entity_selector_diff.passed)
|
|
214
321
|
except* OceanAbortException as error:
|
|
215
322
|
errors.append(error)
|
|
216
323
|
|
|
@@ -446,9 +553,6 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
|
|
|
446
553
|
|
|
447
554
|
try:
|
|
448
555
|
did_fetched_current_state = True
|
|
449
|
-
entities_at_port = await ocean.port_client.search_entities(
|
|
450
|
-
user_agent_type
|
|
451
|
-
)
|
|
452
556
|
except httpx.HTTPError as e:
|
|
453
557
|
logger.warning(
|
|
454
558
|
"Failed to fetch the current state of entities at Port. "
|
|
@@ -461,6 +565,7 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
|
|
|
461
565
|
|
|
462
566
|
creation_results: list[tuple[list[Entity], list[Exception]]] = []
|
|
463
567
|
|
|
568
|
+
|
|
464
569
|
try:
|
|
465
570
|
for resource in app_config.resources:
|
|
466
571
|
# create resource context per resource kind, so resync method could have access to the resource
|
|
@@ -471,7 +576,6 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
|
|
|
471
576
|
)
|
|
472
577
|
|
|
473
578
|
event.on_abort(lambda: task.cancel())
|
|
474
|
-
|
|
475
579
|
creation_results.append(await task)
|
|
476
580
|
|
|
477
581
|
await self.sort_and_upsert_failed_entities(user_agent_type)
|
|
@@ -487,7 +591,7 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
|
|
|
487
591
|
return
|
|
488
592
|
|
|
489
593
|
logger.info("Starting resync diff calculation")
|
|
490
|
-
|
|
594
|
+
generated_entities, errors = zip_and_sum(creation_results) or [
|
|
491
595
|
[],
|
|
492
596
|
[],
|
|
493
597
|
]
|
|
@@ -504,10 +608,14 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
|
|
|
504
608
|
logger.error(message, exc_info=error_group)
|
|
505
609
|
else:
|
|
506
610
|
logger.info(
|
|
507
|
-
f"Running resync diff calculation, number of entities
|
|
611
|
+
f"Running resync diff calculation, number of entities created during sync: {len(generated_entities)}"
|
|
612
|
+
)
|
|
613
|
+
entities_at_port = await ocean.port_client.search_entities(
|
|
614
|
+
user_agent_type
|
|
508
615
|
)
|
|
509
616
|
await self.entities_state_applier.delete_diff(
|
|
510
|
-
{"before": entities_at_port, "after":
|
|
617
|
+
{"before": entities_at_port, "after": generated_entities},
|
|
511
618
|
user_agent_type,
|
|
512
619
|
)
|
|
620
|
+
|
|
513
621
|
logger.info("Resync finished successfully")
|
port_ocean/core/models.py
CHANGED
|
@@ -43,6 +43,10 @@ class Entity(BaseModel):
|
|
|
43
43
|
def is_using_search_identifier(self) -> bool:
|
|
44
44
|
return isinstance(self.identifier, dict)
|
|
45
45
|
|
|
46
|
+
@property
|
|
47
|
+
def is_using_search_relation(self) -> bool:
|
|
48
|
+
return any(isinstance(relation, dict) for relation in self.relations.values())
|
|
49
|
+
|
|
46
50
|
|
|
47
51
|
class BlueprintRelation(BaseModel):
|
|
48
52
|
many: bool
|
port_ocean/core/utils/utils.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
2
4
|
from typing import Iterable, Any, TypeVar, Callable, Awaitable
|
|
3
5
|
|
|
4
6
|
from loguru import logger
|
|
5
7
|
from pydantic import parse_obj_as, ValidationError
|
|
6
8
|
|
|
9
|
+
|
|
7
10
|
from port_ocean.clients.port.client import PortClient
|
|
8
11
|
from port_ocean.core.models import Entity, Runtime
|
|
9
12
|
from port_ocean.core.models import EntityPortDiff
|
|
@@ -76,10 +79,7 @@ async def gather_and_split_errors_from_results(
|
|
|
76
79
|
return valid_items, errors
|
|
77
80
|
|
|
78
81
|
|
|
79
|
-
def get_port_diff(
|
|
80
|
-
before: Iterable[Entity],
|
|
81
|
-
after: Iterable[Entity],
|
|
82
|
-
) -> EntityPortDiff:
|
|
82
|
+
def get_port_diff(before: Iterable[Entity], after: Iterable[Entity]) -> EntityPortDiff:
|
|
83
83
|
before_dict = {}
|
|
84
84
|
after_dict = {}
|
|
85
85
|
created = []
|
|
@@ -107,3 +107,79 @@ def get_port_diff(
|
|
|
107
107
|
deleted.append(obj)
|
|
108
108
|
|
|
109
109
|
return EntityPortDiff(created=created, modified=modified, deleted=deleted)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def are_teams_different(
|
|
113
|
+
first_team: str | None | list[Any], second_team: str | None | list[Any]
|
|
114
|
+
) -> bool:
|
|
115
|
+
if isinstance(first_team, list) and isinstance(second_team, list):
|
|
116
|
+
return sorted(first_team) != sorted(second_team)
|
|
117
|
+
return first_team != second_team
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def are_entities_fields_equal(
|
|
121
|
+
first_entity_field: dict[str, Any], second_entity_field: dict[str, Any]
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""
|
|
124
|
+
Compare two entity fields by serializing them to JSON and comparing their SHA-256 hashes.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
first_entity_field: First entity field dictionary to compare
|
|
128
|
+
second_entity_field: Second entity field dictionary to compare
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
bool: True if the entity fields have identical content
|
|
132
|
+
"""
|
|
133
|
+
first_props = json.dumps(first_entity_field, sort_keys=True)
|
|
134
|
+
second_props = json.dumps(second_entity_field, sort_keys=True)
|
|
135
|
+
first_hash = hashlib.sha256(first_props.encode()).hexdigest()
|
|
136
|
+
second_hash = hashlib.sha256(second_props.encode()).hexdigest()
|
|
137
|
+
return first_hash == second_hash
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def are_entities_different(first_entity: Entity, second_entity: Entity) -> bool:
|
|
141
|
+
if first_entity.title != second_entity.title:
|
|
142
|
+
return True
|
|
143
|
+
if are_teams_different(first_entity.team, second_entity.team):
|
|
144
|
+
return True
|
|
145
|
+
if not are_entities_fields_equal(first_entity.properties, second_entity.properties):
|
|
146
|
+
return True
|
|
147
|
+
if not are_entities_fields_equal(first_entity.relations, second_entity.relations):
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolve_entities_diff(
|
|
154
|
+
source_entities: list[Entity], target_entities: list[Entity]
|
|
155
|
+
) -> list[Entity]:
|
|
156
|
+
"""
|
|
157
|
+
Maps the entities into filtered list of source entities, excluding matches found in target that needs to be upserted
|
|
158
|
+
Args:
|
|
159
|
+
source_entities: List of entities from third party source
|
|
160
|
+
target_entities: List of existing Port entities
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
list[Entity]: Filtered list of source entities, excluding matches found in target
|
|
164
|
+
"""
|
|
165
|
+
target_entities_dict = {}
|
|
166
|
+
source_entities_dict = {}
|
|
167
|
+
changed_entities = []
|
|
168
|
+
|
|
169
|
+
for entity in target_entities:
|
|
170
|
+
key = (entity.identifier, entity.blueprint)
|
|
171
|
+
target_entities_dict[key] = entity
|
|
172
|
+
|
|
173
|
+
for entity in source_entities:
|
|
174
|
+
if entity.is_using_search_identifier or entity.is_using_search_relation:
|
|
175
|
+
return source_entities
|
|
176
|
+
key = (entity.identifier, entity.blueprint)
|
|
177
|
+
source_entities_dict[key] = entity
|
|
178
|
+
|
|
179
|
+
entity_at_target = target_entities_dict.get(key, None)
|
|
180
|
+
if entity_at_target is None:
|
|
181
|
+
changed_entities.append(entity)
|
|
182
|
+
elif are_entities_different(entity, target_entities_dict[key]):
|
|
183
|
+
changed_entities.append(entity)
|
|
184
|
+
|
|
185
|
+
return changed_entities
|