port-ocean 0.18.6__py3-none-any.whl → 0.18.8__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (27) hide show
  1. port_ocean/context/ocean.py +32 -0
  2. port_ocean/core/handlers/entities_state_applier/port/applier.py +18 -2
  3. port_ocean/core/handlers/port_app_config/models.py +3 -0
  4. port_ocean/core/handlers/queue/__init__.py +4 -0
  5. port_ocean/core/handlers/queue/abstract_queue.py +28 -0
  6. port_ocean/core/handlers/queue/local_queue.py +25 -0
  7. port_ocean/core/handlers/webhook/__init__.py +0 -0
  8. port_ocean/core/handlers/webhook/abstract_webhook_processor.py +101 -0
  9. port_ocean/core/handlers/webhook/processor_manager.py +237 -0
  10. port_ocean/core/handlers/webhook/webhook_event.py +77 -0
  11. port_ocean/core/integrations/mixins/sync.py +2 -1
  12. port_ocean/core/integrations/mixins/sync_raw.py +1 -1
  13. port_ocean/exceptions/webhook_processor.py +4 -0
  14. port_ocean/ocean.py +7 -1
  15. port_ocean/tests/core/handlers/entities_state_applier/test_applier.py +86 -0
  16. port_ocean/tests/core/handlers/port_app_config/test_base.py +8 -8
  17. port_ocean/tests/core/handlers/queue/test_local_queue.py +90 -0
  18. port_ocean/tests/core/handlers/webhook/test_abstract_webhook_processor.py +115 -0
  19. port_ocean/tests/core/handlers/webhook/test_processor_manager.py +391 -0
  20. port_ocean/tests/core/handlers/webhook/test_webhook_event.py +65 -0
  21. port_ocean/tests/helpers/ocean_app.py +2 -0
  22. port_ocean/utils/signal.py +6 -2
  23. {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/METADATA +1 -1
  24. {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/RECORD +27 -14
  25. {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/LICENSE.md +0 -0
  26. {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/WHEEL +0 -0
  27. {port_ocean-0.18.6.dist-info → port_ocean-0.18.8.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,86 @@
1
+ from unittest.mock import Mock, patch
2
+ import pytest
3
+ from port_ocean.core.handlers.entities_state_applier.port.applier import (
4
+ HttpEntitiesStateApplier,
5
+ )
6
+ from port_ocean.core.models import Entity
7
+ from port_ocean.core.ocean_types import EntityDiff
8
+ from port_ocean.clients.port.types import UserAgentType
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_delete_diff_no_deleted_entities() -> None:
13
+ applier = HttpEntitiesStateApplier(Mock())
14
+ entities = EntityDiff(
15
+ before=[Entity(identifier="1", blueprint="test")],
16
+ after=[Entity(identifier="1", blueprint="test")],
17
+ )
18
+
19
+ with patch.object(applier, "_safe_delete") as mock_safe_delete:
20
+ await applier.delete_diff(entities, UserAgentType.exporter)
21
+
22
+ mock_safe_delete.assert_not_called()
23
+
24
+
25
+ @pytest.mark.asyncio
26
+ async def test_delete_diff_below_threshold() -> None:
27
+ applier = HttpEntitiesStateApplier(Mock())
28
+ entities = EntityDiff(
29
+ before=[
30
+ Entity(identifier="1", blueprint="test"),
31
+ Entity(identifier="2", blueprint="test"),
32
+ Entity(identifier="3", blueprint="test"),
33
+ ],
34
+ after=[
35
+ Entity(identifier="1", blueprint="test"),
36
+ Entity(identifier="2", blueprint="test"),
37
+ ],
38
+ )
39
+
40
+ with patch.object(applier, "_safe_delete") as mock_safe_delete:
41
+ await applier.delete_diff(
42
+ entities, UserAgentType.exporter, entity_deletion_threshold=0.9
43
+ )
44
+
45
+ mock_safe_delete.assert_called_once()
46
+ assert len(mock_safe_delete.call_args[0][0]) == 1
47
+ assert mock_safe_delete.call_args[0][0][0].identifier == "3"
48
+
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_delete_diff_above_default_threshold() -> None:
52
+ applier = HttpEntitiesStateApplier(Mock())
53
+ entities = EntityDiff(
54
+ before=[
55
+ Entity(identifier="1", blueprint="test"),
56
+ Entity(identifier="2", blueprint="test"),
57
+ Entity(identifier="3", blueprint="test"),
58
+ ],
59
+ after=[],
60
+ )
61
+
62
+ with patch.object(applier, "_safe_delete") as mock_safe_delete:
63
+ await applier.delete_diff(
64
+ entities, UserAgentType.exporter, entity_deletion_threshold=0.9
65
+ )
66
+
67
+ mock_safe_delete.assert_not_called()
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_delete_diff_custom_threshold_above_threshold_not_deleted() -> None:
72
+ applier = HttpEntitiesStateApplier(Mock())
73
+ entities = EntityDiff(
74
+ before=[
75
+ Entity(identifier="1", blueprint="test"),
76
+ Entity(identifier="2", blueprint="test"),
77
+ ],
78
+ after=[],
79
+ )
80
+
81
+ with patch.object(applier, "_safe_delete") as mock_safe_delete:
82
+ await applier.delete_diff(
83
+ entities, UserAgentType.exporter, entity_deletion_threshold=0.5
84
+ )
85
+
86
+ mock_safe_delete.assert_not_called()
@@ -10,7 +10,7 @@ from port_ocean.context.event import EventType, event_context
10
10
  from port_ocean.exceptions.api import EmptyPortAppConfigError
11
11
 
12
12
 
13
- class TestPortAppConfig(BasePortAppConfig):
13
+ class MockPortAppConfig(BasePortAppConfig):
14
14
  mock_get_port_app_config: Any
15
15
 
16
16
  async def _get_port_app_config(self) -> Dict[str, Any]:
@@ -25,15 +25,15 @@ def mock_context() -> PortOceanContext:
25
25
 
26
26
 
27
27
  @pytest.fixture
28
- def port_app_config_handler(mock_context: PortOceanContext) -> TestPortAppConfig:
29
- handler = TestPortAppConfig(mock_context)
28
+ def port_app_config_handler(mock_context: PortOceanContext) -> MockPortAppConfig:
29
+ handler = MockPortAppConfig(mock_context)
30
30
  handler.mock_get_port_app_config = MagicMock()
31
31
  return handler
32
32
 
33
33
 
34
34
  @pytest.mark.asyncio
35
35
  async def test_get_port_app_config_success(
36
- port_app_config_handler: TestPortAppConfig,
36
+ port_app_config_handler: MockPortAppConfig,
37
37
  ) -> None:
38
38
  # Arrange
39
39
  valid_config = {
@@ -83,7 +83,7 @@ async def test_get_port_app_config_success(
83
83
 
84
84
  @pytest.mark.asyncio
85
85
  async def test_get_port_app_config_uses_cache(
86
- port_app_config_handler: TestPortAppConfig,
86
+ port_app_config_handler: MockPortAppConfig,
87
87
  ) -> None:
88
88
  # Arrange
89
89
  valid_config = {
@@ -122,7 +122,7 @@ async def test_get_port_app_config_uses_cache(
122
122
 
123
123
  @pytest.mark.asyncio
124
124
  async def test_get_port_app_config_bypass_cache(
125
- port_app_config_handler: TestPortAppConfig,
125
+ port_app_config_handler: MockPortAppConfig,
126
126
  ) -> None:
127
127
  # Arrange
128
128
  valid_config = {
@@ -163,7 +163,7 @@ async def test_get_port_app_config_bypass_cache(
163
163
 
164
164
  @pytest.mark.asyncio
165
165
  async def test_get_port_app_config_validation_error(
166
- port_app_config_handler: TestPortAppConfig, monkeypatch: pytest.MonkeyPatch
166
+ port_app_config_handler: MockPortAppConfig, monkeypatch: pytest.MonkeyPatch
167
167
  ) -> None:
168
168
  # Arrange
169
169
  invalid_config = {"invalid_field": "invalid_value"}
@@ -184,7 +184,7 @@ async def test_get_port_app_config_validation_error(
184
184
 
185
185
  @pytest.mark.asyncio
186
186
  async def test_get_port_app_config_fetch_error(
187
- port_app_config_handler: TestPortAppConfig,
187
+ port_app_config_handler: MockPortAppConfig,
188
188
  ) -> None:
189
189
  # Arrange
190
190
  port_app_config_handler.mock_get_port_app_config.side_effect = (
@@ -0,0 +1,90 @@
1
+ import asyncio
2
+ import pytest
3
+ from dataclasses import dataclass
4
+
5
+ from port_ocean.core.handlers.queue.local_queue import LocalQueue
6
+
7
+
8
+ @dataclass
9
+ class MockMessage:
10
+ """Example message type for testing"""
11
+
12
+ id: str
13
+ data: str
14
+ processed: bool = False
15
+
16
+
17
+ class TestLocalQueue:
18
+ """
19
+ Test suite for LocalQueue implementation
20
+ This can serve as an example for testing other Queue implementations
21
+ """
22
+
23
+ @pytest.fixture
24
+ def queue(self) -> LocalQueue[MockMessage]:
25
+ return LocalQueue[MockMessage]()
26
+
27
+ async def test_basic_queue_operations(self, queue: LocalQueue[MockMessage]) -> None:
28
+ """Test basic put/get operations"""
29
+ message = MockMessage(id="1", data="test")
30
+
31
+ # Put item in queue
32
+ await queue.put(message)
33
+
34
+ # Get item from queue
35
+ received = await queue.get()
36
+
37
+ assert received.id == message.id
38
+ assert received.data == message.data
39
+
40
+ # Mark as processed
41
+ await queue.commit()
42
+
43
+ async def test_fifo_order(self, queue: LocalQueue[MockMessage]) -> None:
44
+ """Demonstrate and test FIFO (First In, First Out) behavior"""
45
+ messages = [
46
+ MockMessage(id="1", data="first"),
47
+ MockMessage(id="2", data="second"),
48
+ MockMessage(id="3", data="third"),
49
+ ]
50
+
51
+ # Put items in queue
52
+ for msg in messages:
53
+ await queue.put(msg)
54
+
55
+ # Verify order
56
+ for expected in messages:
57
+ received = await queue.get()
58
+ assert received.id == expected.id
59
+ await queue.commit()
60
+
61
+ async def test_wait_for_completion(self, queue: LocalQueue[MockMessage]) -> None:
62
+ """Example of waiting for all messages to be processed"""
63
+ processed_count = 0
64
+
65
+ async def slow_processor() -> None:
66
+ nonlocal processed_count
67
+ while True:
68
+ try:
69
+ await asyncio.wait_for(queue.get(), timeout=0.1)
70
+ # Simulate processing time
71
+ await asyncio.sleep(0.1)
72
+ processed_count += 1
73
+ await queue.commit()
74
+ except asyncio.TimeoutError:
75
+ break
76
+
77
+ # Add messages
78
+ message_count = 5
79
+ for i in range(message_count):
80
+ await queue.put(MockMessage(id=str(i), data=f"test_{i}"))
81
+
82
+ # Start processor
83
+ processor = asyncio.create_task(slow_processor())
84
+
85
+ # Wait for completion
86
+ await queue.teardown()
87
+
88
+ await processor
89
+
90
+ assert processed_count == message_count
@@ -0,0 +1,115 @@
1
+ from fastapi import APIRouter
2
+ import pytest
3
+
4
+ from port_ocean.core.handlers.webhook.abstract_webhook_processor import (
5
+ AbstractWebhookProcessor,
6
+ )
7
+ from port_ocean.exceptions.webhook_processor import RetryableError
8
+ from port_ocean.core.handlers.webhook.webhook_event import (
9
+ EventHeaders,
10
+ EventPayload,
11
+ WebhookEvent,
12
+ )
13
+ from port_ocean.core.handlers.webhook.processor_manager import WebhookProcessorManager
14
+ from port_ocean.utils.signal import SignalHandler
15
+
16
+
17
+ class MockWebhookHandler(AbstractWebhookProcessor):
18
+ """Concrete implementation for testing."""
19
+
20
+ def __init__(
21
+ self,
22
+ event: WebhookEvent,
23
+ should_fail: bool = False,
24
+ fail_count: int = 0,
25
+ max_retries: int = 3,
26
+ ) -> None:
27
+ super().__init__(event)
28
+ self.authenticated = False
29
+ self.validated = False
30
+ self.handled = False
31
+ self.should_fail = should_fail
32
+ self.fail_count = fail_count
33
+ self.current_fails = 0
34
+ self.error_handler_called = False
35
+ self.cancelled = False
36
+ self.max_retries = max_retries
37
+
38
+ async def authenticate(self, payload: EventPayload, headers: EventHeaders) -> bool:
39
+ self.authenticated = True
40
+ return True
41
+
42
+ async def validate_payload(self, payload: EventPayload) -> bool:
43
+ self.validated = True
44
+ return True
45
+
46
+ async def handle_event(self, payload: EventPayload) -> None:
47
+ if self.should_fail and self.current_fails < self.fail_count:
48
+ self.current_fails += 1
49
+ raise RetryableError("Temporary failure")
50
+ self.handled = True
51
+
52
+ async def cancel(self) -> None:
53
+ self.cancelled = True
54
+
55
+ async def on_error(self, error: Exception) -> None:
56
+ self.error_handler_called = True
57
+ await super().on_error(error)
58
+
59
+
60
+ @pytest.mark.skip("Skipping until fixed")
61
+ class TestAbstractWebhookHandler:
62
+ @pytest.fixture
63
+ def webhook_event(self) -> WebhookEvent:
64
+ return WebhookEvent(
65
+ trace_id="test-trace",
66
+ payload={"test": "data"},
67
+ headers={"content-type": "application/json"},
68
+ )
69
+
70
+ @pytest.fixture
71
+ def processor_manager(self) -> WebhookProcessorManager:
72
+ return WebhookProcessorManager(APIRouter(), SignalHandler())
73
+
74
+ @pytest.fixture
75
+ def processor(self, webhook_event: WebhookEvent) -> MockWebhookHandler:
76
+ return MockWebhookHandler(webhook_event)
77
+
78
+ async def test_successful_processing(
79
+ self, processor: MockWebhookHandler, processor_manager: WebhookProcessorManager
80
+ ) -> None:
81
+ """Test successful webhook processing flow."""
82
+ await processor_manager._process_webhook_request(processor)
83
+
84
+ assert processor.authenticated
85
+ assert processor.validated
86
+ assert processor.handled
87
+ assert not processor.error_handler_called
88
+
89
+ async def test_retry_mechanism(
90
+ self, webhook_event: WebhookEvent, processor_manager: WebhookProcessorManager
91
+ ) -> None:
92
+ """Test retry mechanism with temporary failures."""
93
+ processor = MockWebhookHandler(webhook_event, should_fail=True, fail_count=2)
94
+
95
+ await processor_manager._process_webhook_request(processor)
96
+
97
+ assert processor.handled
98
+ assert processor.current_fails == 2
99
+ assert processor.retry_count == 2
100
+ assert processor.error_handler_called
101
+
102
+ async def test_max_retries_exceeded(
103
+ self, webhook_event: WebhookEvent, processor_manager: WebhookProcessorManager
104
+ ) -> None:
105
+ """Test behavior when max retries are exceeded."""
106
+ processor = MockWebhookHandler(
107
+ webhook_event, should_fail=True, fail_count=2, max_retries=1
108
+ )
109
+
110
+ with pytest.raises(RetryableError):
111
+ await processor_manager._process_webhook_request(processor)
112
+
113
+ assert processor.retry_count == processor.max_retries
114
+ assert processor.error_handler_called
115
+ assert not processor.handled