port-ocean 0.5.5__py3-none-any.whl → 0.17.8__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.

Files changed (112) hide show
  1. integrations/_infra/Dockerfile.Deb +56 -0
  2. integrations/_infra/Dockerfile.alpine +108 -0
  3. integrations/_infra/Dockerfile.base.builder +26 -0
  4. integrations/_infra/Dockerfile.base.runner +13 -0
  5. integrations/_infra/Dockerfile.dockerignore +94 -0
  6. {port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}} → integrations/_infra}/Makefile +21 -8
  7. integrations/_infra/grpcio.sh +18 -0
  8. integrations/_infra/init.sh +5 -0
  9. port_ocean/bootstrap.py +1 -1
  10. port_ocean/cli/commands/defaults/clean.py +3 -1
  11. port_ocean/cli/commands/new.py +42 -7
  12. port_ocean/cli/commands/sail.py +7 -1
  13. port_ocean/cli/cookiecutter/cookiecutter.json +3 -0
  14. port_ocean/cli/cookiecutter/hooks/post_gen_project.py +20 -3
  15. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.env.example +6 -0
  16. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.port/resources/blueprints.json +41 -0
  17. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.port/resources/port-app-config.yml +16 -0
  18. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.port/spec.yaml +6 -7
  19. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/CHANGELOG.md +1 -1
  20. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/CONTRIBUTING.md +7 -0
  21. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/changelog/.gitignore +1 -0
  22. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/main.py +16 -1
  23. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/pyproject.toml +21 -10
  24. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/tests/test_sample.py +2 -0
  25. port_ocean/clients/port/authentication.py +16 -4
  26. port_ocean/clients/port/client.py +17 -0
  27. port_ocean/clients/port/mixins/blueprints.py +7 -8
  28. port_ocean/clients/port/mixins/entities.py +108 -53
  29. port_ocean/clients/port/mixins/integrations.py +23 -34
  30. port_ocean/clients/port/retry_transport.py +0 -5
  31. port_ocean/clients/port/utils.py +9 -3
  32. port_ocean/config/base.py +16 -16
  33. port_ocean/config/dynamic.py +2 -0
  34. port_ocean/config/settings.py +79 -11
  35. port_ocean/context/event.py +18 -5
  36. port_ocean/context/ocean.py +14 -3
  37. port_ocean/core/defaults/clean.py +10 -3
  38. port_ocean/core/defaults/common.py +25 -9
  39. port_ocean/core/defaults/initialize.py +111 -100
  40. port_ocean/core/event_listener/__init__.py +8 -0
  41. port_ocean/core/event_listener/base.py +49 -10
  42. port_ocean/core/event_listener/factory.py +9 -1
  43. port_ocean/core/event_listener/http.py +11 -3
  44. port_ocean/core/event_listener/kafka.py +24 -5
  45. port_ocean/core/event_listener/once.py +96 -4
  46. port_ocean/core/event_listener/polling.py +16 -14
  47. port_ocean/core/event_listener/webhooks_only.py +41 -0
  48. port_ocean/core/handlers/__init__.py +1 -2
  49. port_ocean/core/handlers/entities_state_applier/base.py +4 -1
  50. port_ocean/core/handlers/entities_state_applier/port/applier.py +29 -87
  51. port_ocean/core/handlers/entities_state_applier/port/order_by_entities_dependencies.py +5 -2
  52. port_ocean/core/handlers/entity_processor/base.py +26 -22
  53. port_ocean/core/handlers/entity_processor/jq_entity_processor.py +253 -45
  54. port_ocean/core/handlers/port_app_config/base.py +55 -15
  55. port_ocean/core/handlers/port_app_config/models.py +24 -5
  56. port_ocean/core/handlers/resync_state_updater/__init__.py +5 -0
  57. port_ocean/core/handlers/resync_state_updater/updater.py +84 -0
  58. port_ocean/core/integrations/base.py +5 -7
  59. port_ocean/core/integrations/mixins/events.py +3 -1
  60. port_ocean/core/integrations/mixins/sync.py +4 -2
  61. port_ocean/core/integrations/mixins/sync_raw.py +209 -74
  62. port_ocean/core/integrations/mixins/utils.py +1 -1
  63. port_ocean/core/models.py +44 -0
  64. port_ocean/core/ocean_types.py +29 -11
  65. port_ocean/core/utils/entity_topological_sorter.py +90 -0
  66. port_ocean/core/utils/utils.py +109 -0
  67. port_ocean/debug_cli.py +5 -0
  68. port_ocean/exceptions/core.py +4 -0
  69. port_ocean/exceptions/port_defaults.py +0 -2
  70. port_ocean/helpers/retry.py +85 -24
  71. port_ocean/log/handlers.py +23 -2
  72. port_ocean/log/logger_setup.py +8 -1
  73. port_ocean/log/sensetive.py +25 -10
  74. port_ocean/middlewares.py +10 -2
  75. port_ocean/ocean.py +57 -24
  76. port_ocean/run.py +10 -5
  77. port_ocean/tests/__init__.py +0 -0
  78. port_ocean/tests/clients/port/mixins/test_entities.py +53 -0
  79. port_ocean/tests/conftest.py +4 -0
  80. port_ocean/tests/core/defaults/test_common.py +166 -0
  81. port_ocean/tests/core/handlers/entity_processor/test_jq_entity_processor.py +350 -0
  82. port_ocean/tests/core/handlers/mixins/test_sync_raw.py +552 -0
  83. port_ocean/tests/core/test_utils.py +73 -0
  84. port_ocean/tests/core/utils/test_entity_topological_sorter.py +99 -0
  85. port_ocean/tests/helpers/__init__.py +0 -0
  86. port_ocean/tests/helpers/fake_port_api.py +191 -0
  87. port_ocean/tests/helpers/fixtures.py +46 -0
  88. port_ocean/tests/helpers/integration.py +31 -0
  89. port_ocean/tests/helpers/ocean_app.py +66 -0
  90. port_ocean/tests/helpers/port_client.py +21 -0
  91. port_ocean/tests/helpers/smoke_test.py +82 -0
  92. port_ocean/tests/log/test_handlers.py +71 -0
  93. port_ocean/tests/test_smoke.py +74 -0
  94. port_ocean/tests/utils/test_async_iterators.py +45 -0
  95. port_ocean/tests/utils/test_cache.py +189 -0
  96. port_ocean/utils/async_iterators.py +109 -0
  97. port_ocean/utils/cache.py +37 -1
  98. port_ocean/utils/misc.py +22 -4
  99. port_ocean/utils/queue_utils.py +88 -0
  100. port_ocean/utils/signal.py +1 -4
  101. port_ocean/utils/time.py +54 -0
  102. {port_ocean-0.5.5.dist-info → port_ocean-0.17.8.dist-info}/METADATA +27 -19
  103. port_ocean-0.17.8.dist-info/RECORD +164 -0
  104. {port_ocean-0.5.5.dist-info → port_ocean-0.17.8.dist-info}/WHEEL +1 -1
  105. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/.dockerignore +0 -94
  106. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/Dockerfile +0 -15
  107. port_ocean/cli/cookiecutter/{{cookiecutter.integration_slug}}/config.yaml +0 -17
  108. port_ocean/core/handlers/entities_state_applier/port/validate_entity_relations.py +0 -40
  109. port_ocean/core/utils.py +0 -65
  110. port_ocean-0.5.5.dist-info/RECORD +0 -129
  111. {port_ocean-0.5.5.dist-info → port_ocean-0.17.8.dist-info}/LICENSE.md +0 -0
  112. {port_ocean-0.5.5.dist-info → port_ocean-0.17.8.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,10 @@
1
1
  import asyncio
2
+ from graphlib import CycleError
2
3
  import inspect
3
4
  import typing
4
5
  from typing import Callable, Awaitable, Any
5
6
 
7
+ import httpx
6
8
  from loguru import logger
7
9
 
8
10
  from port_ocean.clients.port.types import UserAgentType
@@ -22,12 +24,15 @@ from port_ocean.core.ocean_types import (
22
24
  RAW_RESULT,
23
25
  RESYNC_RESULT,
24
26
  RawEntityDiff,
25
- EntityDiff,
26
27
  ASYNC_GENERATOR_RESYNC_TYPE,
28
+ RAW_ITEM,
29
+ CalculationResult,
27
30
  )
28
- from port_ocean.core.utils import zip_and_sum
31
+ from port_ocean.core.utils.utils import zip_and_sum, gather_and_split_errors_from_results
29
32
  from port_ocean.exceptions.core import OceanAbortException
30
33
 
34
+ SEND_RAW_DATA_EXAMPLES_AMOUNT = 5
35
+
31
36
 
32
37
  class SyncRawMixin(HandlerMixin, EventsMixin):
33
38
  """Mixin class for synchronization of raw constructed entities.
@@ -86,7 +91,6 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
86
91
  ) -> tuple[RESYNC_RESULT, list[RAW_RESULT | Exception]]:
87
92
  tasks = []
88
93
  results = []
89
-
90
94
  for task in fns:
91
95
  if inspect.isasyncgenfunction(task):
92
96
  results.append(resync_generator_wrapper(task, resource_config.kind))
@@ -97,21 +101,13 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
97
101
  logger.info(
98
102
  f"Found {len(tasks) + len(results)} resync tasks for {resource_config.kind}"
99
103
  )
100
-
101
- results_with_error = await asyncio.gather(*tasks, return_exceptions=True)
104
+ successful_results, errors = await gather_and_split_errors_from_results(tasks)
102
105
  results.extend(
103
106
  sum(
104
- [
105
- result
106
- for result in results_with_error
107
- if not isinstance(result, Exception)
108
- ],
107
+ successful_results,
109
108
  [],
110
109
  )
111
110
  )
112
- errors = [
113
- result for result in results_with_error if isinstance(result, Exception)
114
- ]
115
111
 
116
112
  logger.info(
117
113
  f"Triggered {len(tasks)} tasks for {resource_config.kind}, failed: {len(errors)}"
@@ -120,12 +116,16 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
120
116
  return results, errors
121
117
 
122
118
  async def _calculate_raw(
123
- self, raw_diff: list[tuple[ResourceConfig, RawEntityDiff]]
124
- ) -> list[EntityDiff]:
125
- logger.info("Calculating diff in entities between states")
119
+ self,
120
+ raw_diff: list[tuple[ResourceConfig, list[RAW_ITEM]]],
121
+ parse_all: bool = False,
122
+ send_raw_data_examples_amount: int = 0,
123
+ ) -> list[CalculationResult]:
126
124
  return await asyncio.gather(
127
125
  *(
128
- self.entity_processor.parse_items(mapping, results)
126
+ self.entity_processor.parse_items(
127
+ mapping, results, parse_all, send_raw_data_examples_amount
128
+ )
129
129
  for mapping, results in raw_diff
130
130
  )
131
131
  )
@@ -135,45 +135,41 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
135
135
  resource: ResourceConfig,
136
136
  results: list[dict[Any, Any]],
137
137
  user_agent_type: UserAgentType,
138
- ) -> list[Entity]:
138
+ parse_all: bool = False,
139
+ send_raw_data_examples_amount: int = 0,
140
+ ) -> CalculationResult:
139
141
  objects_diff = await self._calculate_raw(
140
- [
141
- (
142
- resource,
143
- {
144
- "before": [],
145
- "after": results,
146
- },
147
- )
148
- ]
142
+ [(resource, results)], parse_all, send_raw_data_examples_amount
143
+ )
144
+ modified_objects = await self.entities_state_applier.upsert(
145
+ objects_diff[0].entity_selector_diff.passed, user_agent_type
146
+ )
147
+ return CalculationResult(
148
+ objects_diff[0].entity_selector_diff._replace(passed=modified_objects),
149
+ errors=objects_diff[0].errors,
150
+ misonfigured_entity_keys=objects_diff[0].misonfigured_entity_keys,
149
151
  )
150
-
151
- entities_after: list[Entity] = objects_diff[0]["after"]
152
- await self.entities_state_applier.upsert(entities_after, user_agent_type)
153
- return entities_after
154
152
 
155
153
  async def _unregister_resource_raw(
156
154
  self,
157
155
  resource: ResourceConfig,
158
- results: list[dict[Any, Any]],
156
+ results: list[RAW_ITEM],
159
157
  user_agent_type: UserAgentType,
160
- ) -> list[Entity]:
161
- objects_diff = await self._calculate_raw(
162
- [
163
- (
164
- resource,
165
- {
166
- "before": results,
167
- "after": [],
168
- },
169
- )
170
- ]
171
- )
158
+ ) -> tuple[list[Entity], list[Exception]]:
159
+ if resource.port.entity.mappings.is_using_search_identifier:
160
+ logger.info(
161
+ f"Skip unregistering resource of kind {resource.kind}, as mapping defined with search identifier"
162
+ )
163
+ return [], []
172
164
 
173
- entities_after: list[Entity] = objects_diff[0]["before"]
174
- await self.entities_state_applier.delete(entities_after, user_agent_type)
165
+ objects_diff = await self._calculate_raw([(resource, results)])
166
+ entities_selector_diff, errors, _ = objects_diff[0]
167
+
168
+ await self.entities_state_applier.delete(
169
+ entities_selector_diff.passed, user_agent_type
170
+ )
175
171
  logger.info("Finished unregistering change")
176
- return entities_after
172
+ return entities_selector_diff.passed, errors
177
173
 
178
174
  async def _register_in_batches(
179
175
  self, resource_config: ResourceConfig, user_agent_type: UserAgentType
@@ -187,25 +183,41 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
187
183
  else:
188
184
  async_generators.append(result)
189
185
 
190
- entities = await self._register_resource_raw(
191
- resource_config, raw_results, user_agent_type
186
+ send_raw_data_examples_amount = (
187
+ SEND_RAW_DATA_EXAMPLES_AMOUNT if ocean.config.send_raw_data_examples else 0
192
188
  )
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)
193
197
 
194
198
  for generator in async_generators:
195
199
  try:
196
200
  async for items in generator:
197
- entities.extend(
198
- await self._register_resource_raw(
199
- resource_config, items, user_agent_type
201
+ if send_raw_data_examples_amount > 0:
202
+ send_raw_data_examples_amount = max(
203
+ 0, send_raw_data_examples_amount - len(passed_entities)
200
204
  )
205
+
206
+ entities, register_errors,_ = await self._register_resource_raw(
207
+ resource_config,
208
+ items,
209
+ user_agent_type,
210
+ send_raw_data_examples_amount=send_raw_data_examples_amount,
201
211
  )
212
+ errors.extend(register_errors)
213
+ passed_entities.extend(entities.passed)
202
214
  except* OceanAbortException as error:
203
215
  errors.append(error)
204
216
 
205
217
  logger.info(
206
- f"Finished registering change for {len(results)} raw results for kind: {resource_config.kind}. {len(entities)} entities were affected"
218
+ f"Finished registering change for {len(results)} raw results for kind: {resource_config.kind}. {len(passed_entities)} entities were affected"
207
219
  )
208
- return entities, errors
220
+ return passed_entities, errors
209
221
 
210
222
  async def register_raw(
211
223
  self,
@@ -231,13 +243,63 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
231
243
  resource for resource in config.resources if resource.kind == kind
232
244
  ]
233
245
 
234
- return await asyncio.gather(
235
- *(
236
- self._register_resource_raw(resource, results, user_agent_type)
237
- for resource in resource_mappings
246
+ if not resource_mappings:
247
+ return []
248
+
249
+ diffs, errors, misconfigured_entity_keys = zip(
250
+ *await asyncio.gather(
251
+ *(
252
+ self._register_resource_raw(
253
+ resource, results, user_agent_type, True
254
+ )
255
+ for resource in resource_mappings
256
+ )
257
+ )
258
+ )
259
+
260
+ diffs = list(diffs)
261
+ errors = sum(errors, [])
262
+ misconfigured_entity_keys = list(misconfigured_entity_keys)
263
+
264
+
265
+ if errors:
266
+ message = f"Failed to register {len(errors)} entities. Skipping delete phase due to incomplete state"
267
+ logger.error(message, exc_info=errors)
268
+ raise ExceptionGroup(
269
+ message,
270
+ errors,
271
+ )
272
+
273
+ registered_entities, entities_to_delete = zip_and_sum(diffs)
274
+
275
+ registered_entities_attributes = {
276
+ (entity.identifier, entity.blueprint) for entity in registered_entities
277
+ }
278
+
279
+ filtered_entities_to_delete: list[Entity] = (
280
+ await ocean.port_client.search_batch_entities(
281
+ user_agent_type,
282
+ [
283
+ entity
284
+ for entity in entities_to_delete
285
+ if not entity.is_using_search_identifier
286
+ and (entity.identifier, entity.blueprint)
287
+ not in registered_entities_attributes
288
+ ],
238
289
  )
239
290
  )
240
291
 
292
+ if filtered_entities_to_delete:
293
+ logger.info(
294
+ f"Deleting {len(filtered_entities_to_delete)} entities that didn't pass any of the selectors"
295
+ )
296
+
297
+ await self.entities_state_applier.delete(
298
+ filtered_entities_to_delete, user_agent_type
299
+ )
300
+
301
+ return registered_entities
302
+
241
303
  async def unregister_raw(
242
304
  self,
243
305
  kind: str,
@@ -262,13 +324,25 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
262
324
  resource for resource in config.resources if resource.kind == kind
263
325
  ]
264
326
 
265
- return await asyncio.gather(
266
- *(
267
- self._unregister_resource_raw(resource, results, user_agent_type)
268
- for resource in resource_mappings
327
+ entities, errors = zip_and_sum(
328
+ await asyncio.gather(
329
+ *(
330
+ self._unregister_resource_raw(resource, results, user_agent_type)
331
+ for resource in resource_mappings
332
+ )
269
333
  )
270
334
  )
271
335
 
336
+ if errors:
337
+ message = f"Failed to unregister all entities with {len(errors)} errors"
338
+ logger.error(message, exc_info=errors)
339
+ raise ExceptionGroup(
340
+ message,
341
+ errors,
342
+ )
343
+
344
+ return entities
345
+
272
346
  async def update_raw_diff(
273
347
  self,
274
348
  kind: str,
@@ -293,21 +367,53 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
293
367
  with logger.contextualize(kind=kind):
294
368
  logger.info(f"Found {len(resource_mappings)} resources for {kind}")
295
369
 
296
- objects_diff = await self._calculate_raw(
297
- [(mapping, raw_desired_state) for mapping in resource_mappings]
370
+ entities_before, _ = zip(
371
+ await self._calculate_raw(
372
+ [
373
+ (mapping, raw_desired_state["before"])
374
+ for mapping in resource_mappings
375
+ ]
376
+ )
298
377
  )
299
378
 
300
- entities_before, entities_after = zip_and_sum(
301
- (
302
- (entities_change["before"], entities_change["after"])
303
- for entities_change in objects_diff
304
- )
379
+ entities_after, after_errors = await self._calculate_raw(
380
+ [(mapping, raw_desired_state["after"]) for mapping in resource_mappings]
305
381
  )
306
382
 
307
- await self.entities_state_applier.apply_diff(
308
- {"before": entities_before, "after": entities_after}, user_agent_type
383
+ entities_before_flatten: list[Entity] = sum(
384
+ (entities_diff.passed for entities_diff in entities_before), []
309
385
  )
310
386
 
387
+ entities_after_flatten: list[Entity] = sum(
388
+ (entities_diff.passed for entities_diff in entities_after), []
389
+ )
390
+
391
+ if after_errors:
392
+ message = (
393
+ f"Failed to calculate diff for entities with {len(after_errors)} errors. "
394
+ f"Skipping delete phase due to incomplete state"
395
+ )
396
+ logger.error(message, exc_info=after_errors)
397
+ entities_before_flatten = []
398
+
399
+ await self.entities_state_applier.apply_diff(
400
+ {"before": entities_before_flatten, "after": entities_after_flatten},
401
+ user_agent_type,
402
+ )
403
+ async def sort_and_upsert_failed_entities(self,user_agent_type: UserAgentType)->None:
404
+ try:
405
+ if not event.entity_topological_sorter.should_execute():
406
+ return None
407
+ logger.info(f"Executings topological sort of {event.entity_topological_sorter.get_entities_count()} entities failed to upsert.",failed_toupsert_entities_count=event.entity_topological_sorter.get_entities_count())
408
+
409
+ for entity in event.entity_topological_sorter.get_entities():
410
+ await self.entities_state_applier.context.port_client.upsert_entity(entity,event.port_app_config.get_port_request_options(),user_agent_type,should_raise=False)
411
+
412
+ except OceanAbortException as ocean_abort:
413
+ logger.info(f"Failed topological sort of failed to upsert entites - trying to upsert unordered {event.entity_topological_sorter.get_entities_count()} entities.",failed_topological_sort_entities_count=event.entity_topological_sorter.get_entities_count() )
414
+ if isinstance(ocean_abort.__cause__,CycleError):
415
+ for entity in event.entity_topological_sorter.get_entities(False):
416
+ await self.entities_state_applier.context.port_client.upsert_entity(entity,event.port_app_config.get_port_request_options(),user_agent_type,should_raise=False)
311
417
  async def sync_raw_all(
312
418
  self,
313
419
  _: dict[Any, Any] | None = None,
@@ -331,9 +437,27 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
331
437
  EventType.RESYNC,
332
438
  trigger_type=trigger_type,
333
439
  ):
334
- app_config = await self.port_app_config_handler.get_port_app_config()
440
+ # If a resync is triggered due to a mappings change, we want to make sure that we have the updated version
441
+ # rather than the old cache
442
+ app_config = await self.port_app_config_handler.get_port_app_config(
443
+ use_cache=False
444
+ )
445
+ logger.info(f"Resync will use the following mappings: {app_config.dict()}")
335
446
 
336
- entities_at_port = await ocean.port_client.search_entities(user_agent_type)
447
+ try:
448
+ did_fetched_current_state = True
449
+ entities_at_port = await ocean.port_client.search_entities(
450
+ user_agent_type
451
+ )
452
+ except httpx.HTTPError as e:
453
+ logger.warning(
454
+ "Failed to fetch the current state of entities at Port. "
455
+ "Skipping delete phase due to unknown initial state. "
456
+ f"Error: {e}\n"
457
+ f"Response status code: {e.response.status_code if isinstance(e, httpx.HTTPStatusError) else None}\n"
458
+ f"Response content: {e.response.text if isinstance(e, httpx.HTTPStatusError) else None}\n"
459
+ )
460
+ did_fetched_current_state = False
337
461
 
338
462
  creation_results: list[tuple[list[Entity], list[Exception]]] = []
339
463
 
@@ -349,9 +473,19 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
349
473
  event.on_abort(lambda: task.cancel())
350
474
 
351
475
  creation_results.append(await task)
476
+
477
+ await self.sort_and_upsert_failed_entities(user_agent_type)
352
478
  except asyncio.CancelledError as e:
353
- logger.warning("Resync aborted successfully")
479
+ logger.warning("Resync aborted successfully, skipping delete phase. This leads to an incomplete state")
480
+ raise
354
481
  else:
482
+ if not did_fetched_current_state:
483
+ logger.warning(
484
+ "Due to an error before the resync, the previous state of entities at Port is unknown."
485
+ " Skipping delete phase due to unknown initial state."
486
+ )
487
+ return
488
+
355
489
  logger.info("Starting resync diff calculation")
356
490
  flat_created_entities, errors = zip_and_sum(creation_results) or [
357
491
  [],
@@ -376,3 +510,4 @@ class SyncRawMixin(HandlerMixin, EventsMixin):
376
510
  {"before": entities_at_port, "after": flat_created_entities},
377
511
  user_agent_type,
378
512
  )
513
+ logger.info("Resync finished successfully")
@@ -9,7 +9,7 @@ from port_ocean.core.ocean_types import (
9
9
  RESYNC_EVENT_LISTENER,
10
10
  RESYNC_RESULT,
11
11
  )
12
- from port_ocean.core.utils import validate_result
12
+ from port_ocean.core.utils.utils import validate_result
13
13
  from port_ocean.exceptions.core import (
14
14
  RawObjectValidationException,
15
15
  OceanAbortException,
port_ocean/core/models.py CHANGED
@@ -1,9 +1,36 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
1
3
  from typing import Any
2
4
 
3
5
  from pydantic import BaseModel
4
6
  from pydantic.fields import Field
5
7
 
6
8
 
9
+ class Runtime(Enum):
10
+ Saas = "Saas"
11
+ OnPrem = "OnPrem"
12
+
13
+ @property
14
+ def is_saas_runtime(self) -> bool:
15
+ return self in [Runtime.Saas]
16
+
17
+ def is_installation_type_compatible(self, installation_type: str) -> bool:
18
+ """
19
+ Check if the installation type is compatible with the runtime
20
+
21
+ if the runtime is Saas, the installation type should start with Saas
22
+ else the installation type should be OnPrem
23
+ """
24
+ return (
25
+ self.value == Runtime.Saas.value
26
+ and installation_type.startswith(self.value)
27
+ ) or installation_type == self.value
28
+
29
+
30
+ class PortAPIErrorMessage(Enum):
31
+ NOT_FOUND = "not_found"
32
+
33
+
7
34
  class Entity(BaseModel):
8
35
  identifier: Any
9
36
  blueprint: Any
@@ -12,6 +39,10 @@ class Entity(BaseModel):
12
39
  properties: dict[str, Any] = {}
13
40
  relations: dict[str, Any] = {}
14
41
 
42
+ @property
43
+ def is_using_search_identifier(self) -> bool:
44
+ return isinstance(self.identifier, dict)
45
+
15
46
 
16
47
  class BlueprintRelation(BaseModel):
17
48
  many: bool
@@ -34,3 +65,16 @@ class Migration(BaseModel):
34
65
  sourceBlueprint: str
35
66
  mapping: dict[str, Any]
36
67
  status: str
68
+
69
+
70
+ @dataclass
71
+ class EntityPortDiff:
72
+ """Represents the differences between entities for porting.
73
+
74
+ This class holds the lists of deleted, modified, and created entities as part
75
+ of the porting process.
76
+ """
77
+
78
+ deleted: list[Entity] = field(default_factory=list)
79
+ modified: list[Entity] = field(default_factory=list)
80
+ created: list[Entity] = field(default_factory=list)
@@ -1,11 +1,28 @@
1
- from typing import TypedDict, Any, AsyncIterator, Callable, Awaitable
2
-
1
+ from typing import (
2
+ TypedDict,
3
+ Any,
4
+ AsyncIterator,
5
+ Callable,
6
+ Awaitable,
7
+ NamedTuple,
8
+ )
9
+
10
+ from dataclasses import field
3
11
  from port_ocean.core.models import Entity
4
12
 
13
+ RAW_ITEM = dict[Any, Any]
14
+ RAW_RESULT = list[RAW_ITEM]
15
+ ASYNC_GENERATOR_RESYNC_TYPE = AsyncIterator[RAW_RESULT]
16
+ RESYNC_RESULT = list[RAW_ITEM | ASYNC_GENERATOR_RESYNC_TYPE]
17
+
18
+ LISTENER_RESULT = Awaitable[RAW_RESULT] | ASYNC_GENERATOR_RESYNC_TYPE
19
+ RESYNC_EVENT_LISTENER = Callable[[str], LISTENER_RESULT]
20
+ START_EVENT_LISTENER = Callable[[], Awaitable[None]]
21
+
5
22
 
6
23
  class RawEntityDiff(TypedDict):
7
- before: list[dict[Any, Any]]
8
- after: list[dict[Any, Any]]
24
+ before: list[RAW_ITEM]
25
+ after: list[RAW_ITEM]
9
26
 
10
27
 
11
28
  class EntityDiff(TypedDict):
@@ -13,14 +30,15 @@ class EntityDiff(TypedDict):
13
30
  after: list[Entity]
14
31
 
15
32
 
16
- RAW_ITEM = dict[Any, Any]
17
- RAW_RESULT = list[RAW_ITEM]
18
- ASYNC_GENERATOR_RESYNC_TYPE = AsyncIterator[RAW_RESULT]
19
- RESYNC_RESULT = list[RAW_ITEM | ASYNC_GENERATOR_RESYNC_TYPE]
33
+ class EntitySelectorDiff(NamedTuple):
34
+ passed: list[Entity]
35
+ failed: list[Entity]
20
36
 
21
- LISTENER_RESULT = Awaitable[RAW_RESULT] | ASYNC_GENERATOR_RESYNC_TYPE
22
- RESYNC_EVENT_LISTENER = Callable[[str], LISTENER_RESULT]
23
- START_EVENT_LISTENER = Callable[[], Awaitable[None]]
37
+
38
+ class CalculationResult(NamedTuple):
39
+ entity_selector_diff: EntitySelectorDiff
40
+ errors: list[Exception]
41
+ misonfigured_entity_keys: dict[str, str] = field(default_factory=dict)
24
42
 
25
43
 
26
44
  class IntegrationEventsCallbacks(TypedDict):
@@ -0,0 +1,90 @@
1
+ from typing import Any, Generator
2
+ from port_ocean.context import event
3
+ from port_ocean.core.models import Entity
4
+
5
+ from loguru import logger
6
+
7
+ from graphlib import TopologicalSorter, CycleError
8
+ from typing import Set
9
+
10
+ from port_ocean.exceptions.core import OceanAbortException
11
+
12
+ Node = tuple[str, str]
13
+
14
+
15
+ class EntityTopologicalSorter:
16
+ def __init__(self) -> None:
17
+ self.entities: list[Entity] = []
18
+
19
+ def register_entity(
20
+ self,
21
+ entity: Entity,
22
+ ) -> None:
23
+ logger.debug(
24
+ f"Will retry upserting entity - {entity.identifier} at the end of resync"
25
+ )
26
+ self.entities.append(entity)
27
+
28
+ def should_execute(self) -> int:
29
+ return not event.event.port_app_config.create_missing_related_entities
30
+
31
+ def get_entities_count(self) -> int:
32
+ return len(self.entities)
33
+
34
+ def get_entities(self, sorted: bool = True) -> Generator[Entity, Any, None]:
35
+ if not sorted:
36
+ for entity in self.entities:
37
+ yield entity
38
+ return
39
+
40
+ sorted_and_mapped = EntityTopologicalSorter.order_by_entities_dependencies(
41
+ self.entities
42
+ )
43
+ for entity in sorted_and_mapped:
44
+ yield entity
45
+
46
+ @staticmethod
47
+ def node(entity: Entity) -> Node:
48
+ return entity.identifier, entity.blueprint
49
+
50
+ @staticmethod
51
+ def order_by_entities_dependencies(entities: list[Entity]) -> list[Entity]:
52
+ nodes: dict[Node, Set[Node]] = {}
53
+ entities_map = {}
54
+ for entity in entities:
55
+ nodes[EntityTopologicalSorter.node(entity)] = set()
56
+ entities_map[EntityTopologicalSorter.node(entity)] = entity
57
+
58
+ for entity in entities:
59
+ relation_target_ids: list[str] = sum(
60
+ [
61
+ identifiers if isinstance(identifiers, list) else [identifiers]
62
+ for identifiers in entity.relations.values()
63
+ if identifiers is not None
64
+ ],
65
+ [],
66
+ )
67
+ related_entities = [
68
+ related
69
+ for related in entities
70
+ if related.identifier in relation_target_ids
71
+ ]
72
+
73
+ for related_entity in related_entities:
74
+ if (
75
+ entity.blueprint is not related_entity.blueprint
76
+ or entity.identifier is not related_entity.identifier
77
+ ):
78
+ nodes[EntityTopologicalSorter.node(entity)].add(
79
+ EntityTopologicalSorter.node(related_entity)
80
+ )
81
+
82
+ sort_op = TopologicalSorter(nodes)
83
+ try:
84
+ return [entities_map[item] for item in sort_op.static_order()]
85
+ except CycleError as ex:
86
+ raise OceanAbortException(
87
+ "Cannot order entities due to cyclic dependencies. \n"
88
+ "If you do want to have cyclic dependencies, please make sure to set the keys"
89
+ " 'createMissingRelatedEntities' and 'deleteDependentEntities' in the integration config in Port."
90
+ ) from ex