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.

@@ -98,10 +98,22 @@ class EntityClientMixin:
98
98
  if result_entity.is_using_search_identifier:
99
99
  return None
100
100
 
101
- # In order to save memory we'll keep only the identifier, blueprint and relations of the
102
- # upserted entity result for later calculations
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=result_entity.identifier, blueprint=result_entity.blueprint
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 result_entity.relations.items()
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, user_agent_type: UserAgentType, query: dict[Any, Any] | None = None
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 = await self.entities_state_applier.upsert(
145
- objects_diff[0].entity_selector_diff.passed, user_agent_type
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
- all_entities, register_errors,_ = await self._register_resource_raw(
190
- resource_config,
191
- raw_results,
192
- user_agent_type,
193
- send_raw_data_examples_amount=send_raw_data_examples_amount,
194
- )
195
- errors.extend(register_errors)
196
- passed_entities = list(all_entities.passed)
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
- entities, register_errors,_ = await self._register_resource_raw(
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(register_errors)
213
- passed_entities.extend(entities.passed)
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
- flat_created_entities, errors = zip_and_sum(creation_results) or [
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 at Port before resync: {len(entities_at_port)}, number of entities created during sync: {len(flat_created_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": flat_created_entities},
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
@@ -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