port-ocean 0.23.5__py3-none-any.whl → 0.24.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.

@@ -1,7 +1,7 @@
1
1
  import asyncio
2
2
  from typing import Any, Literal
3
3
  from urllib.parse import quote_plus
4
-
4
+ import json
5
5
 
6
6
  import httpx
7
7
  from loguru import logger
@@ -12,11 +12,19 @@ from port_ocean.clients.port.utils import (
12
12
  handle_port_status_code,
13
13
  PORT_HTTP_MAX_CONNECTIONS_LIMIT,
14
14
  )
15
- from port_ocean.core.models import Entity, PortAPIErrorMessage
15
+ from port_ocean.core.models import (
16
+ BulkUpsertResponse,
17
+ Entity,
18
+ PortAPIErrorMessage,
19
+ )
16
20
  from starlette import status
17
21
 
18
22
  from port_ocean.helpers.metric.metric import MetricPhase, MetricType
19
23
 
24
+ ENTITIES_BULK_SAMPLES_SIZE = 10
25
+ ENTITIES_BULK_ESTIMATED_SIZE_MULTIPLIER = 1.5
26
+ ENTITIES_BULK_MINIMUM_BATCH_SIZE = 1
27
+
20
28
 
21
29
  class EntityClientMixin:
22
30
  def __init__(self, auth: PortAuthentication, client: httpx.AsyncClient):
@@ -29,6 +37,42 @@ class EntityClientMixin:
29
37
  round(0.5 * PORT_HTTP_MAX_CONNECTIONS_LIMIT)
30
38
  ) # 50% of the max connections limit in order to avoid overloading port
31
39
 
40
+ def calculate_entities_batch_size(self, entities: list[Entity]) -> int:
41
+ """
42
+ Calculate the optimal batch size based on entity size and configured limits.
43
+
44
+ Args:
45
+ entities: List of entities to calculate batch size for
46
+
47
+ Returns:
48
+ int: The optimal batch size to use
49
+ """
50
+ if not entities:
51
+ return ENTITIES_BULK_MINIMUM_BATCH_SIZE
52
+
53
+ # Calculate average entity size from a sample
54
+ SAMPLE_SIZE = min(ENTITIES_BULK_SAMPLES_SIZE, len(entities))
55
+ sample_entities = entities[:SAMPLE_SIZE]
56
+ average_entity_size = (
57
+ sum(
58
+ len(json.dumps(entity.dict(exclude_unset=True, by_alias=True)).encode())
59
+ for entity in sample_entities
60
+ )
61
+ / SAMPLE_SIZE
62
+ )
63
+
64
+ # Use a conservative estimate to ensure we stay under the limit
65
+ estimated_entity_size = int(
66
+ average_entity_size * ENTITIES_BULK_ESTIMATED_SIZE_MULTIPLIER
67
+ )
68
+ max_entities_per_batch = min(
69
+ ocean.config.upsert_entities_batch_max_length,
70
+ ocean.config.upsert_entities_batch_max_size_in_bytes
71
+ // estimated_entity_size,
72
+ )
73
+
74
+ return max(ENTITIES_BULK_MINIMUM_BATCH_SIZE, max_entities_per_batch)
75
+
32
76
  async def upsert_entity(
33
77
  self,
34
78
  entity: Entity,
@@ -124,38 +168,145 @@ class EntityClientMixin:
124
168
  return None
125
169
  return self._reduce_entity(result_entity)
126
170
 
127
- @staticmethod
128
- def _reduce_entity(entity: Entity) -> Entity:
171
+ async def upsert_entities_bulk(
172
+ self,
173
+ blueprint: str,
174
+ entities: list[Entity],
175
+ request_options: RequestOptions,
176
+ user_agent_type: UserAgentType | None = None,
177
+ should_raise: bool = True,
178
+ ) -> list[tuple[bool | None, Entity]] | httpx.HTTPStatusError:
129
179
  """
130
- Reduces an entity to only keep identifier, blueprint and processed relations.
131
- This helps save memory by removing unnecessary data.
180
+ This function upserts a list of entities into Port.
132
181
 
133
- Args:
134
- entity: The entity to reduce
182
+ Usage:
183
+ ```python
184
+ upsertedEntities = await self.context.port_client.upsert_entities_batch(
185
+ entities,
186
+ event.port_app_config.get_port_request_options(),
187
+ user_agent_type,
188
+ should_raise=False,
189
+ )
190
+ ```
191
+ :param blueprint: The blueprint of the entities to be upserted
192
+ :param entities: A list of Entities to be upserted
193
+ :param request_options: A dictionary specifying how to upsert the entity
194
+ :param user_agent_type: a UserAgentType specifying who is preforming the action
195
+ :param should_raise: A boolean specifying whether the error should be raised or handled silently
196
+ :return: A list of tuples where each tuple contains:
197
+ - First value: True if entity was created successfully, False if there was an error, None if there was an error and the entity use search identifier
198
+ - Second value: The original entity (if failed) or the reduced entity with updated identifier (if successful)
199
+ :return: httpx.HTTPStatusError if there was an HTTP error and should_raise is False
200
+ """
201
+ validation_only = request_options["validation_only"]
202
+ async with self.semaphore:
203
+ logger.debug(
204
+ f"{'Validating' if validation_only else 'Upserting'} {len(entities)} of blueprint: {blueprint}"
205
+ )
206
+ headers = await self.auth.headers(user_agent_type)
207
+ response = await self.client.post(
208
+ f"{self.auth.api_url}/blueprints/{blueprint}/entities/bulk",
209
+ json={
210
+ "entities": [
211
+ entity.dict(exclude_unset=True, by_alias=True)
212
+ for entity in entities
213
+ ]
214
+ },
215
+ headers=headers,
216
+ params={
217
+ "upsert": "true",
218
+ "merge": str(request_options["merge"]).lower(),
219
+ "create_missing_related_entities": str(
220
+ request_options["create_missing_related_entities"]
221
+ ).lower(),
222
+ "validation_only": str(validation_only).lower(),
223
+ },
224
+ extensions={"retryable": True},
225
+ )
226
+ if response.is_error:
227
+ logger.error(
228
+ f"Error {'Validating' if validation_only else 'Upserting'} "
229
+ f"{len(entities)} entities of "
230
+ f"blueprint: {blueprint}"
231
+ )
232
+ handle_port_status_code(response, should_raise)
233
+ return httpx.HTTPStatusError(
234
+ f"HTTP {response.status_code}",
235
+ request=response.request,
236
+ response=response,
237
+ )
238
+ handle_port_status_code(response, should_raise)
239
+ result = response.json()
135
240
 
136
- Returns:
137
- Entity: A new entity with only the essential data
241
+ return self._parse_upsert_entities_batch_response(entities, result)
242
+
243
+ def _parse_upsert_entities_batch_response(
244
+ self,
245
+ entities: list[Entity],
246
+ result: BulkUpsertResponse,
247
+ ) -> list[tuple[bool | None, Entity]]:
138
248
  """
139
- reduced_entity = Entity(
140
- identifier=entity.identifier, blueprint=entity.blueprint
141
- )
249
+ Parse the response from a bulk upsert operation and map it to the original entities.
142
250
 
143
- # Turning dict typed relations (raw search relations) is required
144
- # for us to be able to successfully calculate the participation related entities
145
- # and ignore the ones that don't as they weren't upserted
146
- reduced_entity.relations = {
147
- key: None if isinstance(relation, dict) else relation
148
- for key, relation in entity.relations.items()
251
+ :param entities: The original entities
252
+ :param result: The response from the bulk upsert operation
253
+ :return: A list of tuples containing the success status and the entity
254
+ """
255
+ index_to_entity = {i: entity for i, entity in enumerate(entities)}
256
+ successful_entities = {
257
+ entity_result["index"]: entity_result
258
+ for entity_result in result.get("entities", [])
149
259
  }
150
- return reduced_entity
260
+ error_entities = {error["index"]: error for error in result.get("errors", [])}
261
+
262
+ batch_results: list[tuple[bool | None, Entity]] = []
263
+ for entity_index, original_entity in index_to_entity.items():
264
+ reduced_entity = self._reduce_entity(original_entity)
265
+ if entity_index in successful_entities:
266
+ ocean.metrics.inc_metric(
267
+ name=MetricType.OBJECT_COUNT_NAME,
268
+ labels=[
269
+ ocean.metrics.current_resource_kind(),
270
+ MetricPhase.LOAD,
271
+ MetricPhase.LoadResult.LOADED,
272
+ ],
273
+ value=1,
274
+ )
275
+ success_entity = successful_entities[entity_index]
276
+ # Create a copy of the original entity with the new identifier
277
+ updated_entity = reduced_entity.copy()
278
+ updated_entity.identifier = success_entity["identifier"]
279
+ batch_results.append((True, updated_entity))
280
+ elif entity_index in error_entities:
281
+ ocean.metrics.inc_metric(
282
+ name=MetricType.OBJECT_COUNT_NAME,
283
+ labels=[
284
+ ocean.metrics.current_resource_kind(),
285
+ MetricPhase.LOAD,
286
+ MetricPhase.LoadResult.FAILED,
287
+ ],
288
+ value=1,
289
+ )
290
+ error = error_entities[entity_index]
291
+ if (
292
+ error.get("identifier") == "unknown"
293
+ ): # when using the search identifier we might not have an actual identifier
294
+ batch_results.append((None, reduced_entity))
295
+ else:
296
+ batch_results.append((False, reduced_entity))
297
+ else:
298
+ batch_results.append((False, reduced_entity))
299
+
300
+ return batch_results
151
301
 
152
- async def batch_upsert_entities(
302
+ async def _upsert_entities_batch_individually(
153
303
  self,
154
304
  entities: list[Entity],
155
305
  request_options: RequestOptions,
156
306
  user_agent_type: UserAgentType | None = None,
157
307
  should_raise: bool = True,
158
308
  ) -> list[tuple[bool, Entity]]:
309
+ entities_results: list[tuple[bool, Entity]] = []
159
310
  modified_entities_results = await asyncio.gather(
160
311
  *(
161
312
  self.upsert_entity(
@@ -169,17 +320,102 @@ class EntityClientMixin:
169
320
  return_exceptions=True,
170
321
  )
171
322
 
172
- entities_results: list[tuple[bool, Entity]] = []
173
- for original_entity, result in zip(entities, modified_entities_results):
174
- if isinstance(result, Exception) and should_raise:
175
- raise result
176
- elif isinstance(result, Entity):
177
- entities_results.append((True, result))
178
- elif result is False:
323
+ for original_entity, single_result in zip(entities, modified_entities_results):
324
+ if isinstance(single_result, Exception) and should_raise:
325
+ raise single_result
326
+ elif isinstance(single_result, Entity):
327
+ entities_results.append((True, single_result))
328
+ elif single_result is False:
179
329
  entities_results.append((False, original_entity))
180
330
 
181
331
  return entities_results
182
332
 
333
+ async def upsert_entities_in_batches(
334
+ self,
335
+ entities: list[Entity],
336
+ request_options: RequestOptions,
337
+ user_agent_type: UserAgentType | None = None,
338
+ should_raise: bool = True,
339
+ ) -> list[tuple[bool, Entity]]:
340
+ """
341
+ This function upserts a list of entities into Port in batches.
342
+ The batch size is calculated based on both the number of entities and their size.
343
+ Batches are processed in parallel using asyncio.gather, with concurrency controlled by the semaphore.
344
+
345
+ :param entities: A list of Entities to be upserted
346
+ :param request_options: A dictionary specifying how to upsert the entity
347
+ :param user_agent_type: a UserAgentType specifying who is preforming the action
348
+ :param should_raise: A boolean specifying whether the error should be raised or handled silently
349
+ :return: A list of tuples where each tuple contains:
350
+ - First value: True if entity was created successfully, False if there was an error
351
+ - Second value: The reduced entity with updated identifier (if successful) or the original entity (if failed)
352
+ """
353
+ entities_results: list[tuple[bool, Entity]] = []
354
+ blueprint = entities[0].blueprint
355
+
356
+ if ocean.config.bulk_upserts_enabled:
357
+ bulk_size = self.calculate_entities_batch_size(entities)
358
+ bulks = [
359
+ entities[i : i + bulk_size] for i in range(0, len(entities), bulk_size)
360
+ ]
361
+
362
+ bulk_results = await asyncio.gather(
363
+ *(
364
+ self.upsert_entities_bulk(
365
+ blueprint,
366
+ bulk,
367
+ request_options,
368
+ user_agent_type,
369
+ should_raise=should_raise,
370
+ )
371
+ for bulk in bulks
372
+ ),
373
+ return_exceptions=True,
374
+ )
375
+
376
+ for bulk, bulk_result in zip(bulks, bulk_results):
377
+ if isinstance(bulk_result, httpx.HTTPStatusError) or isinstance(
378
+ bulk_result, Exception
379
+ ):
380
+ if should_raise:
381
+ raise bulk_result
382
+ # If should_raise is False, retry batch in sequential order as a fallback only for 413 errors
383
+ if (
384
+ isinstance(bulk_result, httpx.HTTPStatusError)
385
+ and bulk_result.response.status_code == 413
386
+ ):
387
+ individual_upsert_results = (
388
+ await self._upsert_entities_batch_individually(
389
+ bulk, request_options, user_agent_type, should_raise
390
+ )
391
+ )
392
+ entities_results.extend(individual_upsert_results)
393
+ else:
394
+ # For other errors, mark all entities in the batch as failed
395
+ for entity in bulk:
396
+ failed_result: tuple[bool, Entity] = (
397
+ False,
398
+ self._reduce_entity(entity),
399
+ )
400
+ entities_results.append(failed_result)
401
+ elif isinstance(bulk_result, list):
402
+ for status, entity in bulk_result:
403
+ if (
404
+ status is not None
405
+ ): # when using the search identifier we might not have an actual identifier
406
+ bulk_result_tuple: tuple[bool, Entity] = (
407
+ bool(status),
408
+ entity,
409
+ )
410
+ entities_results.append(bulk_result_tuple)
411
+ else:
412
+ individual_upsert_results = await self._upsert_entities_batch_individually(
413
+ entities, request_options, user_agent_type, should_raise
414
+ )
415
+ entities_results.extend(individual_upsert_results)
416
+
417
+ return entities_results
418
+
183
419
  async def delete_entity(
184
420
  self,
185
421
  entity: Entity,
@@ -307,3 +543,28 @@ class EntityClientMixin:
307
543
  "rules": [{"combinator": "or", "rules": search_rules}],
308
544
  },
309
545
  )
546
+
547
+ @staticmethod
548
+ def _reduce_entity(entity: Entity) -> Entity:
549
+ """
550
+ Reduces an entity to only keep identifier, blueprint and processed relations.
551
+ This helps save memory by removing unnecessary data.
552
+
553
+ Args:
554
+ entity: The entity to reduce
555
+
556
+ Returns:
557
+ Entity: A new entity with only the essential data
558
+ """
559
+ reduced_entity = Entity(
560
+ identifier=entity.identifier, blueprint=entity.blueprint
561
+ )
562
+
563
+ # Turning dict typed relations (raw search relations) is required
564
+ # for us to be able to successfully calculate the participation related entities
565
+ # and ignore the ones that don't as they weren't upserted
566
+ reduced_entity.relations = {
567
+ key: None if isinstance(relation, dict) else relation
568
+ for key, relation in entity.relations.items()
569
+ }
570
+ return reduced_entity
@@ -101,6 +101,10 @@ class IntegrationConfiguration(BaseOceanSettings, extra=Extra.allow):
101
101
  caching_storage_mode: Optional[CachingStorageMode] = Field(default=None)
102
102
  process_execution_mode: Optional[ProcessExecutionMode] = Field(default=None)
103
103
 
104
+ upsert_entities_batch_max_length: int = 20
105
+ upsert_entities_batch_max_size_in_bytes: int = 1024 * 1024
106
+ bulk_upserts_enabled: bool = False
107
+
104
108
  @validator("metrics", pre=True)
105
109
  def validate_metrics(cls, v: Any) -> MetricsSettings | dict[str, Any] | None:
106
110
  if v is None:
@@ -127,7 +127,7 @@ class HttpEntitiesStateApplier(BaseEntitiesStateApplier):
127
127
  modified_entities: list[Entity] = []
128
128
  upserted_entities: list[tuple[bool, Entity]] = []
129
129
 
130
- upserted_entities = await self.context.port_client.batch_upsert_entities(
130
+ upserted_entities = await self.context.port_client.upsert_entities_in_batches(
131
131
  entities,
132
132
  event.port_app_config.get_port_request_options(),
133
133
  user_agent_type,
port_ocean/core/models.py CHANGED
@@ -1,6 +1,6 @@
1
1
  from dataclasses import dataclass, field
2
2
  from enum import Enum, StrEnum
3
- from typing import Any
3
+ from typing import Any, TypedDict
4
4
 
5
5
  from pydantic import BaseModel
6
6
  from pydantic.fields import Field
@@ -67,6 +67,25 @@ class Entity(BaseModel):
67
67
  )
68
68
 
69
69
 
70
+ class EntityBulkResult(TypedDict):
71
+ identifier: str
72
+ index: int
73
+ created: bool
74
+
75
+
76
+ class EntityBulkError(TypedDict):
77
+ identifier: str
78
+ index: int
79
+ statusCode: int
80
+ error: str
81
+ message: str
82
+
83
+
84
+ class BulkUpsertResponse(TypedDict):
85
+ entities: list[EntityBulkResult]
86
+ errors: list[EntityBulkError]
87
+
88
+
70
89
  class BlueprintRelation(BaseModel):
71
90
  many: bool
72
91
  required: bool
@@ -1,5 +1,5 @@
1
- from typing import Any
2
- from unittest.mock import MagicMock, patch
1
+ from typing import Any, List, Generator
2
+ from unittest.mock import MagicMock, patch, AsyncMock
3
3
 
4
4
  import pytest
5
5
 
@@ -7,50 +7,168 @@ from port_ocean.clients.port.mixins.entities import EntityClientMixin
7
7
  from port_ocean.core.models import Entity
8
8
  from httpx import ReadTimeout
9
9
 
10
+ # Mock the ocean context at module level
11
+ pytestmark = pytest.mark.usefixtures("mock_ocean")
12
+
10
13
 
11
14
  errored_entity_identifier: str = "a"
12
- expected_result_entities = [
15
+ expected_result_entities: List[Entity] = [
13
16
  Entity(identifier="b", blueprint="b"),
14
17
  Entity(identifier="c", blueprint="c"),
15
18
  ]
16
- all_entities = [
19
+ expected_result_entities_with_exception: List[Entity] = []
20
+ all_entities: List[Entity] = [
17
21
  Entity(identifier=errored_entity_identifier, blueprint="a")
18
22
  ] + expected_result_entities
19
23
 
20
24
 
21
- async def mock_upsert_entity(entity: Entity, *args: Any, **kwargs: Any) -> Entity:
22
- if entity.identifier == errored_entity_identifier:
23
- raise ReadTimeout("")
24
- else:
25
- return entity
25
+ @pytest.fixture(autouse=True)
26
+ def mock_ocean() -> Generator[MagicMock, None, None]:
27
+ with patch("port_ocean.clients.port.mixins.entities.ocean") as mock_ocean:
28
+ mock_ocean.config.upsert_entities_batch_max_length = 20
29
+ mock_ocean.config.upsert_entities_batch_max_size_in_bytes = 1024 * 1024 # 1MB
30
+ yield mock_ocean
31
+
32
+
33
+ async def mock_upsert_entities_bulk(
34
+ blueprint: str, entities: list[Entity], *args: Any, **kwargs: Any
35
+ ) -> list[tuple[bool | None, Entity]]:
36
+ results: list[tuple[bool | None, Entity]] = []
37
+ for entity in entities:
38
+ if entity.identifier == errored_entity_identifier:
39
+ results.append((False, entity))
40
+ else:
41
+ results.append((True, entity))
42
+ return results
43
+
44
+
45
+ async def mock_exception_upsert_entities_bulk(
46
+ blueprint: str, entities: list[Entity], *args: Any, **kwargs: Any
47
+ ) -> list[tuple[bool | None, Entity]]:
48
+ for entity in entities:
49
+ if entity.identifier == errored_entity_identifier:
50
+ raise ReadTimeout("")
51
+ return [(True, entity) for entity in entities]
26
52
 
27
53
 
28
54
  @pytest.fixture
29
55
  async def entity_client(monkeypatch: Any) -> EntityClientMixin:
30
- # Arrange
31
56
  entity_client = EntityClientMixin(auth=MagicMock(), client=MagicMock())
32
- monkeypatch.setattr(entity_client, "upsert_entity", mock_upsert_entity)
57
+ mock = AsyncMock()
58
+ mock.side_effect = mock_upsert_entities_bulk
59
+ monkeypatch.setattr(entity_client, "upsert_entities_bulk", mock)
33
60
 
34
61
  return entity_client
35
62
 
36
63
 
64
+ def test_calculate_entities_batch_size_empty_list(
65
+ entity_client: EntityClientMixin,
66
+ ) -> None:
67
+ """Test that empty list returns batch size of 1"""
68
+ assert entity_client.calculate_entities_batch_size([]) == 1
69
+
70
+
71
+ def test_calculate_entities_batch_size_small_entities(
72
+ entity_client: EntityClientMixin,
73
+ ) -> None:
74
+ """Test that small entities return max batch size"""
75
+ small_entities = [
76
+ Entity(identifier=f"small_{i}", blueprint="test", properties={"small": "value"})
77
+ for i in range(30)
78
+ ]
79
+ # Small entities should allow max batch size
80
+ assert entity_client.calculate_entities_batch_size(small_entities) == 20
81
+
82
+
83
+ def test_calculate_entities_batch_size_large_entities(
84
+ entity_client: EntityClientMixin,
85
+ ) -> None:
86
+ """Test that large entities reduce batch size"""
87
+ large_entities = [
88
+ Entity(
89
+ identifier=f"large_{i}",
90
+ blueprint="test",
91
+ properties={"large": "x" * (100 * 1024)}, # 100KB per entity
92
+ )
93
+ for i in range(30)
94
+ ]
95
+ # With 1MB limit and 100KB per entity (plus overhead), should get ~9 entities per batch
96
+ batch_size = entity_client.calculate_entities_batch_size(large_entities)
97
+ assert 5 <= batch_size <= 15
98
+
99
+
100
+ def test_calculate_entities_batch_size_mixed_entities(
101
+ entity_client: EntityClientMixin,
102
+ ) -> None:
103
+ """Test that mixed size entities calculate correct batch size"""
104
+ mixed_entities = [
105
+ Entity(identifier=f"small_{i}", blueprint="test", properties={"small": "value"})
106
+ for i in range(10)
107
+ ] + [
108
+ Entity(
109
+ identifier=f"large_{i}",
110
+ blueprint="test",
111
+ properties={"large": "x" * (50 * 1024)}, # 50KB per entity
112
+ )
113
+ for i in range(10)
114
+ ]
115
+ # With 1MB limit and mixed sizes, should get a reasonable batch size
116
+ batch_size = entity_client.calculate_entities_batch_size(mixed_entities)
117
+ assert 15 <= batch_size <= 20
118
+
119
+
120
+ def test_calculate_entities_batch_size_single_large_entity(
121
+ entity_client: EntityClientMixin,
122
+ ) -> None:
123
+ """Test that single very large entity returns batch size of 1"""
124
+ large_entity = Entity(
125
+ identifier="huge",
126
+ blueprint="test",
127
+ properties={"huge": "x" * (2 * 1024 * 1024)}, # 2MB entity
128
+ )
129
+ # Even though entity is larger than limit, we return 1 to ensure at least one entity is processed
130
+ assert entity_client.calculate_entities_batch_size([large_entity]) == 1
131
+
132
+
37
133
  async def test_batch_upsert_entities_read_timeout_should_raise_false(
38
134
  entity_client: EntityClientMixin,
39
135
  ) -> None:
40
136
  with patch("port_ocean.context.event.event", MagicMock()):
41
- result_entities = await entity_client.batch_upsert_entities(
137
+ result_entities = await entity_client.upsert_entities_in_batches(
42
138
  entities=all_entities, request_options=MagicMock(), should_raise=False
43
139
  )
44
- entities_only = [entity for _, entity in result_entities]
140
+ # Only get entities that were successfully upserted (status is True)
141
+ entities_only = [entity for status, entity in result_entities if status is True]
45
142
 
46
143
  assert entities_only == expected_result_entities
47
144
 
48
145
 
146
+ async def test_batch_upsert_entities_read_timeout_should_raise_false_with_exception(
147
+ entity_client: EntityClientMixin,
148
+ monkeypatch: Any,
149
+ ) -> None:
150
+ with patch("port_ocean.context.event.event", MagicMock()):
151
+ # Override the mock for this test to use the exception-throwing version
152
+ mock = AsyncMock(side_effect=mock_exception_upsert_entities_bulk)
153
+ monkeypatch.setattr(entity_client, "upsert_entities_bulk", mock)
154
+ result_entities = await entity_client.upsert_entities_in_batches(
155
+ entities=all_entities, request_options=MagicMock(), should_raise=False
156
+ )
157
+ # Only get entities that were successfully upserted (status is True)
158
+ entities_only = [entity for status, entity in result_entities if status is True]
159
+
160
+ assert entities_only == expected_result_entities_with_exception
161
+
162
+
49
163
  async def test_batch_upsert_entities_read_timeout_should_raise_true(
50
164
  entity_client: EntityClientMixin,
165
+ monkeypatch: Any,
51
166
  ) -> None:
52
167
  with patch("port_ocean.context.event.event", MagicMock()):
168
+ # Override the mock for this test to use the exception-throwing version
169
+ mock = AsyncMock(side_effect=mock_exception_upsert_entities_bulk)
170
+ monkeypatch.setattr(entity_client, "upsert_entities_bulk", mock)
53
171
  with pytest.raises(ReadTimeout):
54
- await entity_client.batch_upsert_entities(
172
+ await entity_client.upsert_entities_in_batches(
55
173
  entities=all_entities, request_options=MagicMock(), should_raise=True
56
174
  )
@@ -35,24 +35,59 @@ def mock_http_client() -> MagicMock:
35
35
  mock_upserted_entities = []
36
36
 
37
37
  async def post(url: str, *args: Any, **kwargs: Any) -> Response:
38
- entity = kwargs.get("json", {})
39
- if entity.get("properties", {}).get("mock_is_to_fail", {}):
38
+ if "/bulk" in url:
39
+ success_entities = []
40
+ failed_entities = []
41
+ entities_body = kwargs.get("json", {})
42
+ entities = entities_body.get("entities", [])
43
+ for index, entity in enumerate(entities):
44
+ if entity.get("properties", {}).get("mock_is_to_fail", False):
45
+ failed_entities.append(
46
+ {
47
+ "identifier": entity.get("identifier"),
48
+ "index": index,
49
+ "statusCode": 404,
50
+ "error": "not_found",
51
+ "message": "Entity not found",
52
+ }
53
+ )
54
+ else:
55
+ mock_upserted_entities.append(
56
+ f"{entity.get('identifier')}-{entity.get('blueprint')}"
57
+ )
58
+ success_entities.append(
59
+ (
60
+ {
61
+ "identifier": entity.get("identifier"),
62
+ "index": index,
63
+ "created": True,
64
+ }
65
+ )
66
+ )
67
+
40
68
  return Response(
41
- 404, headers=MagicMock(), json={"ok": False, "error": "not_found"}
69
+ 207,
70
+ json={"entities": success_entities, "errors": failed_entities},
42
71
  )
72
+ else:
73
+ entity = kwargs.get("json", {})
74
+ if entity.get("properties", {}).get("mock_is_to_fail", False):
75
+ return Response(
76
+ 404, headers=MagicMock(), json={"ok": False, "error": "not_found"}
77
+ )
43
78
 
44
- mock_upserted_entities.append(
45
- f"{entity.get('identifier')}-{entity.get('blueprint')}"
46
- )
47
- return Response(
48
- 200,
49
- json={
50
- "entity": {
51
- "identifier": entity.get("identifier"),
52
- "blueprint": entity.get("blueprint"),
53
- }
54
- },
55
- )
79
+ mock_upserted_entities.append(
80
+ f"{entity.get('identifier')}-{entity.get('blueprint')}"
81
+ )
82
+ return Response(
83
+ 200,
84
+ json={
85
+ "entity": {
86
+ "identifier": entity.get("identifier"),
87
+ "blueprint": entity.get("blueprint"),
88
+ }
89
+ },
90
+ )
56
91
 
57
92
  mock_http_client.post = AsyncMock(side_effect=post)
58
93
  return mock_http_client
@@ -1,4 +1,4 @@
1
- from unittest.mock import Mock, patch
1
+ from unittest.mock import Mock, patch, AsyncMock
2
2
  import pytest
3
3
  from port_ocean.core.handlers.entities_state_applier.port.applier import (
4
4
  HttpEntitiesStateApplier,
@@ -125,33 +125,29 @@ async def test_applier_with_mock_context(
125
125
  mock_context: PortOceanContext,
126
126
  mock_port_app_config: PortAppConfig,
127
127
  ) -> None:
128
- # Create an applier using the mock_context fixture
129
128
  applier = HttpEntitiesStateApplier(mock_context)
130
-
131
- # Create test entities
132
129
  entity = Entity(identifier="test_entity", blueprint="test_blueprint")
133
130
 
134
131
  async with event_context(EventType.RESYNC, trigger_type="machine") as event:
135
132
  event.port_app_config = mock_port_app_config
133
+ event.entity_topological_sorter = Mock()
134
+
135
+ mock_blueprint = Mock()
136
+ mock_blueprint.identifier = "test_blueprint"
137
+ mock_blueprint.relations = {}
138
+ mock_get_blueprint = AsyncMock(return_value=mock_blueprint)
139
+ setattr(mock_ocean.port_client, "get_blueprint", mock_get_blueprint)
136
140
 
137
- # Test the upsert method with mocked client
138
- with patch.object(mock_ocean.port_client.client, "post") as mock_post:
139
- mock_post.return_value = Mock(
140
- status_code=200,
141
- json=lambda: {
142
- "entity": {
143
- "identifier": "test_entity",
144
- "blueprint": "test_blueprint",
145
- }
146
- },
147
- )
141
+ mock_ocean.config.upsert_entities_batch_max_length = 100
142
+ mock_ocean.config.upsert_entities_batch_max_size_in_bytes = 1000
148
143
 
149
- result = await applier.upsert([entity], UserAgentType.exporter)
144
+ mock_upsert = AsyncMock(return_value=[(True, entity)])
145
+ setattr(mock_ocean.port_client, "upsert_entities_bulk", mock_upsert)
150
146
 
151
- # Assert that the post method was called
152
- mock_post.assert_called_once()
153
- assert len(result) == 1
154
- assert result[0].identifier == "test_entity"
147
+ result = await applier.upsert([entity], UserAgentType.exporter)
148
+ mock_upsert.assert_called_once()
149
+ assert len(result) == 1
150
+ assert result[0].identifier == "test_entity"
155
151
 
156
152
 
157
153
  @pytest.mark.asyncio
@@ -160,32 +156,24 @@ async def test_applier_one_not_upserted(
160
156
  mock_context: PortOceanContext,
161
157
  mock_port_app_config: PortAppConfig,
162
158
  ) -> None:
163
- # Create an applier using the mock_context fixture
164
159
  applier = HttpEntitiesStateApplier(mock_context)
165
-
166
- # Create test entities
167
160
  entity = Entity(identifier="test_entity", blueprint="test_blueprint")
168
161
 
169
162
  async with event_context(EventType.RESYNC, trigger_type="machine") as event:
170
- # Mock the register_entity method
171
163
  event.entity_topological_sorter.register_entity = Mock() # type: ignore
172
164
  event.port_app_config = mock_port_app_config
173
165
 
174
- # Test the upsert method with mocked client
175
- with patch.object(mock_ocean.port_client.client, "post") as mock_post:
176
- mock_post.return_value = Mock(
177
- status_code=404,
178
- json=lambda: {"ok": False, "error": "not_found"},
179
- )
166
+ mock_ocean.config.upsert_entities_batch_max_length = 100
167
+ mock_ocean.config.upsert_entities_batch_max_size_in_bytes = 1000
168
+
169
+ mock_upsert = AsyncMock(return_value=[(False, entity)])
170
+ setattr(mock_ocean.port_client, "upsert_entities_bulk", mock_upsert)
180
171
 
181
- result = await applier.upsert([entity], UserAgentType.exporter)
172
+ result = await applier.upsert([entity], UserAgentType.exporter)
182
173
 
183
- # Assert that the post method was called
184
- mock_post.assert_called_once()
185
- assert len(result) == 0
186
- event.entity_topological_sorter.register_entity.assert_called_once_with(
187
- entity
188
- )
174
+ mock_upsert.assert_called_once()
175
+ assert len(result) == 0
176
+ event.entity_topological_sorter.register_entity.assert_called_once_with(entity)
189
177
 
190
178
 
191
179
  @pytest.mark.asyncio
@@ -194,32 +182,23 @@ async def test_applier_error_upserting(
194
182
  mock_context: PortOceanContext,
195
183
  mock_port_app_config: PortAppConfig,
196
184
  ) -> None:
197
- # Create an applier using the mock_context fixture
198
185
  applier = HttpEntitiesStateApplier(mock_context)
199
-
200
- # Create test entities
201
186
  entity = Entity(identifier="test_entity", blueprint="test_blueprint")
202
187
 
203
188
  async with event_context(EventType.RESYNC, trigger_type="machine") as event:
204
- # Mock the register_entity method
205
189
  event.entity_topological_sorter.register_entity = Mock() # type: ignore
206
190
  event.port_app_config = mock_port_app_config
207
191
 
208
- # Test the upsert method with mocked client
209
- with patch.object(mock_ocean.port_client.client, "post") as mock_post:
210
- mock_post.return_value = Mock(
211
- status_code=404,
212
- json=lambda: {"ok": False, "error": "not_found"},
213
- )
192
+ mock_ocean.config.upsert_entities_batch_max_length = 100
193
+ mock_ocean.config.upsert_entities_batch_max_size_in_bytes = 1000
214
194
 
215
- result = await applier.upsert([entity], UserAgentType.exporter)
195
+ mock_upsert = AsyncMock(return_value=[(False, entity)])
196
+ setattr(mock_ocean.port_client, "upsert_entities_bulk", mock_upsert)
216
197
 
217
- # Assert that the post method was called
218
- mock_post.assert_called_once()
219
- assert len(result) == 0
220
- event.entity_topological_sorter.register_entity.assert_called_once_with(
221
- entity
222
- )
198
+ result = await applier.upsert([entity], UserAgentType.exporter)
199
+ mock_upsert.assert_called_once()
200
+ assert len(result) == 0
201
+ event.entity_topological_sorter.register_entity.assert_called_once_with(entity)
223
202
 
224
203
 
225
204
  @pytest.mark.asyncio
@@ -228,31 +207,24 @@ async def test_using_create_entity_helper(
228
207
  mock_context: PortOceanContext,
229
208
  mock_port_app_config: PortAppConfig,
230
209
  ) -> None:
231
- # Create the applier with the mock context
232
210
  applier = HttpEntitiesStateApplier(mock_context)
233
-
234
- # Create test entities using the helper function
235
211
  entity1 = create_entity("entity1", "service", {"related_to": "entity2"}, False)
236
212
 
237
- # Test that entities were created correctly
238
213
  assert entity1.identifier == "entity1"
239
214
  assert entity1.blueprint == "service"
240
215
  assert entity1.relations == {"related_to": "entity2"}
241
216
  assert entity1.properties == {"mock_is_to_fail": False}
242
217
 
243
- # Test the applier with these entities
244
218
  async with event_context(EventType.RESYNC, trigger_type="machine") as event:
245
219
  event.port_app_config = mock_port_app_config
246
220
 
247
- with patch.object(mock_ocean.port_client.client, "post") as mock_post:
248
- mock_post.return_value = Mock(
249
- status_code=200,
250
- json=lambda: {
251
- "entity": {"identifier": "entity1", "blueprint": "service"}
252
- },
253
- )
221
+ mock_ocean.config.upsert_entities_batch_max_length = 100
222
+ mock_ocean.config.upsert_entities_batch_max_size_in_bytes = 1000
223
+
224
+ mock_upsert = AsyncMock(return_value=[(True, entity1)])
225
+ setattr(mock_ocean.port_client, "upsert_entities_bulk", mock_upsert)
254
226
 
255
- result = await applier.upsert([entity1], UserAgentType.exporter)
227
+ result = await applier.upsert([entity1], UserAgentType.exporter)
256
228
 
257
- mock_post.assert_called_once()
258
- assert len(result) == 1
229
+ mock_upsert.assert_called_once()
230
+ assert len(result) == 1
@@ -96,6 +96,10 @@ async def test_sync_raw_mixin_self_dependency(
96
96
  mock_sync_raw_mixin: SyncRawMixin,
97
97
  mock_ocean: Ocean,
98
98
  ) -> None:
99
+ mock_ocean.config.upsert_entities_batch_max_length = 20
100
+ mock_ocean.config.upsert_entities_batch_max_size_in_bytes = 1024 * 1024
101
+ mock_ocean.config.bulk_upserts_enabled = True
102
+
99
103
  entities_params = [
100
104
  ("entity_1", "service", {"service": "entity_1"}, True),
101
105
  ("entity_2", "service", {"service": "entity_2"}, False),
@@ -148,9 +152,13 @@ async def test_sync_raw_mixin_self_dependency(
148
152
 
149
153
  assert mock_order_by_entities_dependencies.call_count == 1
150
154
  assert [
151
- call[0][0][0]
155
+ call[0][0][0].identifier
152
156
  for call in mock_order_by_entities_dependencies.call_args_list
153
- ] == [entity for entity in entities if entity.identifier == "entity_1"]
157
+ ] == [
158
+ entity.identifier
159
+ for entity in entities
160
+ if entity.identifier == "entity_1"
161
+ ]
154
162
 
155
163
  # Add assertions for actual metrics
156
164
  metrics = mock_ocean.metrics.generate_metrics()
@@ -209,6 +217,10 @@ async def test_sync_raw_mixin_self_dependency(
209
217
  async def test_sync_raw_mixin_circular_dependency(
210
218
  mock_sync_raw_mixin: SyncRawMixin, mock_ocean: Ocean
211
219
  ) -> None:
220
+ mock_ocean.config.upsert_entities_batch_max_length = 20
221
+ mock_ocean.config.upsert_entities_batch_max_size_in_bytes = 1024 * 1024
222
+ mock_ocean.config.bulk_upserts_enabled = True
223
+
212
224
  entities_params = [
213
225
  ("entity_1", "service", {"service": "entity_2"}, True),
214
226
  ("entity_2", "service", {"service": "entity_1"}, True),
@@ -284,8 +296,8 @@ async def test_sync_raw_mixin_circular_dependency(
284
296
  assert isinstance(raiesed_error_handle_failed[0].__cause__, CycleError)
285
297
  assert (
286
298
  len(mock_ocean.port_client.client.post.call_args_list) # type: ignore
287
- / len(entities)
288
- == 2
299
+ - len(entities)
300
+ == 1
289
301
  )
290
302
 
291
303
  # Add assertions for actual metrics
@@ -345,7 +357,10 @@ async def test_sync_raw_mixin_circular_dependency(
345
357
  async def test_sync_raw_mixin_dependency(
346
358
  mock_sync_raw_mixin: SyncRawMixin, mock_ocean: Ocean
347
359
  ) -> None:
348
- # Create entities with more realistic data
360
+ mock_ocean.config.upsert_entities_batch_max_length = 20
361
+ mock_ocean.config.upsert_entities_batch_max_size_in_bytes = 1024 * 1024
362
+ mock_ocean.config.bulk_upserts_enabled = True
363
+
349
364
  entities_params = [
350
365
  ("entity_1", "service", {"service": "entity_3"}, True),
351
366
  ("entity_2", "service", {"service": "entity_4"}, True),
@@ -418,17 +433,20 @@ async def test_sync_raw_mixin_dependency(
418
433
  ), "Expected one failed entity callback due to retry logic"
419
434
  assert event.entity_topological_sorter.get_entities.call_count == 1
420
435
  assert len(raiesed_error_handle_failed) == 0
421
- assert mock_ocean.port_client.client.post.call_count == 10 # type: ignore
436
+ assert mock_ocean.port_client.client.post.call_count == 6 # type: ignore
422
437
  assert mock_order_by_entities_dependencies.call_count == 1
423
438
 
424
- first = mock_ocean.port_client.client.post.call_args_list[0:5] # type: ignore
425
- second = mock_ocean.port_client.client.post.call_args_list[5:10] # type: ignore
439
+ result_bulk = mock_ocean.port_client.client.post.call_args_list[0] # type: ignore
440
+ result_non_bulk = mock_ocean.port_client.client.post.call_args_list[1:6] # type: ignore
426
441
 
427
442
  assert "-".join(
428
- [call[1].get("json").get("identifier") for call in first]
443
+ [
444
+ entity.get("identifier")
445
+ for entity in result_bulk[1].get("json").get("entities")
446
+ ]
429
447
  ) == "-".join([entity.identifier for entity in entities])
430
448
  assert "-".join(
431
- [call[1].get("json").get("identifier") for call in second]
449
+ [call[1].get("json").get("identifier") for call in result_non_bulk]
432
450
  ) in (
433
451
  "entity_3-entity_4-entity_1-entity_2-entity_5",
434
452
  "entity_3-entity_4-entity_1-entity_5-entity_2",
@@ -892,6 +910,8 @@ async def test_on_resync_start_hooks_are_called(
892
910
  resync_start_called = True
893
911
 
894
912
  mock_sync_raw_mixin.on_resync_start(on_resync_start)
913
+ mock_sync_raw_mixin._get_resource_raw_results = AsyncMock(return_value=([], [])) # type: ignore
914
+
895
915
  mock_ocean.metrics.report_sync_metrics = AsyncMock(return_value=None) # type: ignore
896
916
  mock_ocean.metrics.report_kind_sync_metrics = AsyncMock(return_value=None) # type: ignore
897
917
  mock_ocean.metrics.send_metrics_to_webhook = AsyncMock(return_value=None) # type: ignore
@@ -922,6 +942,7 @@ async def test_on_resync_complete_hooks_are_called_on_success(
922
942
 
923
943
  mock_sync_raw_mixin.on_resync_complete(on_resync_complete)
924
944
  mock_ocean.port_client.search_entities.return_value = [] # type: ignore
945
+ mock_sync_raw_mixin._get_resource_raw_results = AsyncMock(return_value=([], [])) # type: ignore
925
946
  mock_ocean.metrics.report_sync_metrics = AsyncMock(return_value=None) # type: ignore
926
947
  mock_ocean.metrics.report_kind_sync_metrics = AsyncMock(return_value=None) # type: ignore
927
948
  mock_ocean.metrics.send_metrics_to_webhook = AsyncMock(return_value=None) # type: ignore
@@ -994,6 +1015,7 @@ async def test_multiple_on_resync_start_on_resync_complete_hooks_called_in_order
994
1015
  mock_sync_raw_mixin.on_resync_complete(on_resync_complete1)
995
1016
  mock_sync_raw_mixin.on_resync_complete(on_resync_complete2)
996
1017
  mock_ocean.port_client.search_entities.return_value = [] # type: ignore
1018
+ mock_sync_raw_mixin._get_resource_raw_results = AsyncMock(return_value=([], [])) # type: ignore
997
1019
 
998
1020
  mock_ocean.metrics.report_sync_metrics = AsyncMock(return_value=None) # type: ignore
999
1021
  mock_ocean.metrics.report_kind_sync_metrics = AsyncMock(return_value=None) # type: ignore
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: port-ocean
3
- Version: 0.23.5
3
+ Version: 0.24.0
4
4
  Summary: Port Ocean is a CLI tool for managing your Port projects.
5
5
  Home-page: https://app.getport.io
6
6
  Keywords: ocean,port-ocean,port
@@ -59,7 +59,7 @@ port_ocean/clients/port/authentication.py,sha256=r7r8Ag9WuwXy-CmgeOoj-PHbmJAQxhb
59
59
  port_ocean/clients/port/client.py,sha256=dv0mxIOde6J-wFi1FXXZkoNPVHrZzY7RSMhNkDD9xgA,3566
60
60
  port_ocean/clients/port/mixins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
61
  port_ocean/clients/port/mixins/blueprints.py,sha256=aMCG4zePsMSMjMLiGrU37h5z5_ElfMzTcTvqvOI5wXY,4683
62
- port_ocean/clients/port/mixins/entities.py,sha256=-1Gs74z_8eviWItHIpveQhKdA7gnjbqZ3STS4jgGONs,11668
62
+ port_ocean/clients/port/mixins/entities.py,sha256=OMcgJMKruxLBYrHVvzhQmkW6_4QRYi48myjiBd-BTaA,23317
63
63
  port_ocean/clients/port/mixins/integrations.py,sha256=s6paomK9bYWW-Tu3y2OIaEGSxsXCHyhapVi4JIhhO64,11162
64
64
  port_ocean/clients/port/mixins/migrations.py,sha256=vdL_A_NNUogvzujyaRLIoZEu5vmKDY2BxTjoGP94YzI,1467
65
65
  port_ocean/clients/port/mixins/organization.py,sha256=A2cP5V49KnjoAXxjmnm_XGth4ftPSU0qURNfnyUyS_Y,1041
@@ -69,7 +69,7 @@ port_ocean/clients/port/utils.py,sha256=osFyAjw7Y5Qf2uVSqC7_RTCQfijiL1zS74JJM0go
69
69
  port_ocean/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
70
70
  port_ocean/config/base.py,sha256=x1gFbzujrxn7EJudRT81C6eN9WsYAb3vOHwcpcpX8Tc,6370
71
71
  port_ocean/config/dynamic.py,sha256=T0AWE41tjp9fL1sgrTRwNAGlPw6xiakFp-KXWvHtu_4,2035
72
- port_ocean/config/settings.py,sha256=kVXF5_Jr93qW4xDlYXbfehDlQjpv4REjiSAQWePKfYs,6438
72
+ port_ocean/config/settings.py,sha256=ls8yae6j7UP_-eIx_eLAwPxSraSsaDJn9osNqz1QUUo,6588
73
73
  port_ocean/consumers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
74
74
  port_ocean/consumers/kafka_consumer.py,sha256=N8KocjBi9aR0BOPG8hgKovg-ns_ggpEjrSxqSqF_BSo,4710
75
75
  port_ocean/context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -95,7 +95,7 @@ port_ocean/core/handlers/base.py,sha256=cTarblazu8yh8xz2FpB-dzDKuXxtoi143XJgPbV_
95
95
  port_ocean/core/handlers/entities_state_applier/__init__.py,sha256=kgLZDCeCEzi4r-0nzW9k78haOZNf6PX7mJOUr34A4c8,173
96
96
  port_ocean/core/handlers/entities_state_applier/base.py,sha256=5wHL0icfFAYRPqk8iV_wN49GdJ3aRUtO8tumSxBi4Wo,2268
97
97
  port_ocean/core/handlers/entities_state_applier/port/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
98
- port_ocean/core/handlers/entities_state_applier/port/applier.py,sha256=CcrKlFG0eAvUqWit_2SjDrYfTDmMlr4PPSiPBIw7qhM,6367
98
+ port_ocean/core/handlers/entities_state_applier/port/applier.py,sha256=QzN8OWgp-i4bRwslnLo5qjS7rt93Stceq9C4FD11Vy4,6372
99
99
  port_ocean/core/handlers/entities_state_applier/port/get_related_entities.py,sha256=1zncwCbE-Gej0xaWKlzZgoXxOBe9bgs_YxlZ8QW3NdI,1751
100
100
  port_ocean/core/handlers/entities_state_applier/port/order_by_entities_dependencies.py,sha256=lyv6xKzhYfd6TioUgR3AVRSJqj7JpAaj1LxxU2xAqeo,1720
101
101
  port_ocean/core/handlers/entity_processor/__init__.py,sha256=FvFCunFg44wNQoqlybem9MthOs7p1Wawac87uSXz9U8,156
@@ -123,7 +123,7 @@ port_ocean/core/integrations/mixins/live_events.py,sha256=8HklZmlyffYY_LeDe8xbt3
123
123
  port_ocean/core/integrations/mixins/sync.py,sha256=Vm_898pLKBwfVewtwouDWsXoxcOLicnAy6pzyqqk6U8,4053
124
124
  port_ocean/core/integrations/mixins/sync_raw.py,sha256=4zNsf0QdvsLANhoUTUnf0bNbHIyV7-MqQOYIr0ES85o,34121
125
125
  port_ocean/core/integrations/mixins/utils.py,sha256=g1XbC12dswefQ-NpcLSCqFtd_WRp2bTL98jyZ5rRbGk,3444
126
- port_ocean/core/models.py,sha256=MKfq69zGbFRzo0I2HRDUvSbz_pjrtcFVsD5B4Qwa3fw,2538
126
+ port_ocean/core/models.py,sha256=NYsOBtAqRgmRTb2XYGDW31IxTHSXGRQLOF64apwUZ2Q,2872
127
127
  port_ocean/core/ocean_types.py,sha256=4VipWFOHEh_d9LmWewQccwx1p2dtrRYW0YURVgNsAjo,1398
128
128
  port_ocean/core/utils/entity_topological_sorter.py,sha256=MDUjM6OuDy4Xj68o-7InNN0w1jqjxeDfeY8U02vySNI,3081
129
129
  port_ocean/core/utils/utils.py,sha256=XJ6ZZBR5hols19TcX4Bh49ygSNhPt3MLncLR-g41GTA,6858
@@ -158,15 +158,15 @@ port_ocean/tests/cache/test_memory_cache.py,sha256=xlwIOBU0RVLYYJU83l_aoZDzZ6QID
158
158
  port_ocean/tests/clients/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
159
159
  port_ocean/tests/clients/oauth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
160
160
  port_ocean/tests/clients/oauth/test_oauth_client.py,sha256=2XVMQUalDpiD539Z7_dk5BK_ngXQzsTmb2lNBsfEm9c,3266
161
- port_ocean/tests/clients/port/mixins/test_entities.py,sha256=Zq_wKTymxJ0R8lHKztvEV6lN__3FZk8uTSIVpKCE6NA,1815
161
+ port_ocean/tests/clients/port/mixins/test_entities.py,sha256=DeWbAQcaxT3LQQf_j9HA5nG7YgsQDvXmgK2aghlG9ug,6619
162
162
  port_ocean/tests/clients/port/mixins/test_organization_mixin.py,sha256=zzKYz3h8dl4Z5A2QG_924m0y9U6XTth1XYOfwNrd_24,914
163
163
  port_ocean/tests/conftest.py,sha256=JXASSS0IY0nnR6bxBflhzxS25kf4iNaABmThyZ0mZt8,101
164
- port_ocean/tests/core/conftest.py,sha256=tTOxB8HlCmXgSsXhtPI6xYgdpbpemxfBhA_gBDBuACQ,6115
164
+ port_ocean/tests/core/conftest.py,sha256=7K_M1--wQ08VmiQRB0vo1nst2X00cwsuBS5UfERsnG8,7589
165
165
  port_ocean/tests/core/defaults/test_common.py,sha256=sR7RqB3ZYV6Xn6NIg-c8k5K6JcGsYZ2SCe_PYX5vLYM,5560
166
- port_ocean/tests/core/handlers/entities_state_applier/test_applier.py,sha256=WNg1fWZsXu0MDnz9-ahRiPb_OPofWx7E8wxBx0cyZKs,8946
166
+ port_ocean/tests/core/handlers/entities_state_applier/test_applier.py,sha256=eJYXc7AwrV0XRS6HpixwzghjB3pspT5Gxr9twvJE7fk,8290
167
167
  port_ocean/tests/core/handlers/entity_processor/test_jq_entity_processor.py,sha256=8WpMn559Mf0TFWmloRpZrVgr6yWwyA0C4n2lVHCtyq4,13596
168
168
  port_ocean/tests/core/handlers/mixins/test_live_events.py,sha256=iAwVpr3n3PIkXQLw7hxd-iB_SR_vyfletVXJLOmyz28,12480
169
- port_ocean/tests/core/handlers/mixins/test_sync_raw.py,sha256=iK-lfu6vk-DBScYm-nLvRzbj0ImbKUN4LfNLXOb8Z3Q,43413
169
+ port_ocean/tests/core/handlers/mixins/test_sync_raw.py,sha256=E_r_L6Jr6DTPyUP5fn3yQ-MAC3YbpO2AHf690NyLW4g,44447
170
170
  port_ocean/tests/core/handlers/port_app_config/test_api.py,sha256=eJZ6SuFBLz71y4ca3DNqKag6d6HUjNJS0aqQPwiLMTI,1999
171
171
  port_ocean/tests/core/handlers/port_app_config/test_base.py,sha256=hSh556bJM9zuELwhwnyKSfd9z06WqWXIfe-6hCl5iKI,9799
172
172
  port_ocean/tests/core/handlers/queue/test_local_queue.py,sha256=9Ly0HzZXbs6Rbl_bstsIdInC3h2bgABU3roP9S_PnJM,2582
@@ -200,8 +200,8 @@ port_ocean/utils/repeat.py,sha256=U2OeCkHPWXmRTVoPV-VcJRlQhcYqPWI5NfmPlb1JIbc,32
200
200
  port_ocean/utils/signal.py,sha256=mMVq-1Ab5YpNiqN4PkiyTGlV_G0wkUDMMjTZp5z3pb0,1514
201
201
  port_ocean/utils/time.py,sha256=pufAOH5ZQI7gXvOvJoQXZXZJV-Dqktoj9Qp9eiRwmJ4,1939
202
202
  port_ocean/version.py,sha256=UsuJdvdQlazzKGD3Hd5-U7N69STh8Dq9ggJzQFnu9fU,177
203
- port_ocean-0.23.5.dist-info/LICENSE.md,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
204
- port_ocean-0.23.5.dist-info/METADATA,sha256=c7JCQ5hwJwGhb1mGKvRp1Ls35CHw9KksCyrlj6dEc0o,6764
205
- port_ocean-0.23.5.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
206
- port_ocean-0.23.5.dist-info/entry_points.txt,sha256=F_DNUmGZU2Kme-8NsWM5LLE8piGMafYZygRYhOVtcjA,54
207
- port_ocean-0.23.5.dist-info/RECORD,,
203
+ port_ocean-0.24.0.dist-info/LICENSE.md,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
204
+ port_ocean-0.24.0.dist-info/METADATA,sha256=I_Ne_n_R7YjRUyEUR74UJ0u7b5NToTapBpVT0TzFbqU,6764
205
+ port_ocean-0.24.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
206
+ port_ocean-0.24.0.dist-info/entry_points.txt,sha256=F_DNUmGZU2Kme-8NsWM5LLE8piGMafYZygRYhOVtcjA,54
207
+ port_ocean-0.24.0.dist-info/RECORD,,