port-ocean 0.28.5__py3-none-any.whl → 0.29.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.
- integrations/_infra/Dockerfile.Deb +1 -0
- integrations/_infra/Dockerfile.local +1 -0
- port_ocean/clients/port/authentication.py +19 -0
- port_ocean/clients/port/client.py +3 -0
- port_ocean/clients/port/mixins/actions.py +93 -0
- port_ocean/clients/port/mixins/blueprints.py +0 -12
- port_ocean/clients/port/mixins/entities.py +79 -44
- port_ocean/clients/port/mixins/integrations.py +7 -2
- port_ocean/config/settings.py +35 -3
- port_ocean/context/ocean.py +7 -5
- port_ocean/core/defaults/initialize.py +12 -5
- port_ocean/core/event_listener/__init__.py +7 -0
- port_ocean/core/event_listener/actions_only.py +42 -0
- port_ocean/core/event_listener/base.py +4 -1
- port_ocean/core/event_listener/factory.py +18 -9
- port_ocean/core/event_listener/http.py +4 -3
- port_ocean/core/event_listener/kafka.py +3 -2
- port_ocean/core/event_listener/once.py +5 -2
- port_ocean/core/event_listener/polling.py +4 -3
- port_ocean/core/event_listener/webhooks_only.py +3 -2
- port_ocean/core/handlers/actions/__init__.py +7 -0
- port_ocean/core/handlers/actions/abstract_executor.py +150 -0
- port_ocean/core/handlers/actions/execution_manager.py +434 -0
- port_ocean/core/handlers/entity_processor/jq_entity_processor.py +479 -17
- port_ocean/core/handlers/entity_processor/jq_input_evaluator.py +137 -0
- port_ocean/core/handlers/port_app_config/models.py +4 -2
- port_ocean/core/handlers/webhook/abstract_webhook_processor.py +16 -0
- port_ocean/core/handlers/webhook/processor_manager.py +30 -12
- port_ocean/core/integrations/mixins/sync_raw.py +4 -4
- port_ocean/core/integrations/mixins/utils.py +250 -29
- port_ocean/core/models.py +35 -2
- port_ocean/core/utils/utils.py +16 -5
- port_ocean/exceptions/execution_manager.py +22 -0
- port_ocean/helpers/retry.py +4 -40
- port_ocean/log/logger_setup.py +2 -2
- port_ocean/ocean.py +30 -4
- port_ocean/tests/clients/port/mixins/test_entities.py +71 -5
- port_ocean/tests/core/event_listener/test_kafka.py +14 -7
- port_ocean/tests/core/handlers/actions/test_execution_manager.py +837 -0
- port_ocean/tests/core/handlers/entity_processor/test_jq_entity_processor.py +932 -1
- port_ocean/tests/core/handlers/entity_processor/test_jq_input_evaluator.py +932 -0
- port_ocean/tests/core/handlers/webhook/test_processor_manager.py +3 -1
- port_ocean/tests/core/utils/test_get_port_diff.py +164 -0
- port_ocean/tests/helpers/test_retry.py +241 -1
- port_ocean/tests/utils/test_cache.py +240 -0
- port_ocean/utils/cache.py +45 -9
- {port_ocean-0.28.5.dist-info → port_ocean-0.29.0.dist-info}/METADATA +2 -1
- {port_ocean-0.28.5.dist-info → port_ocean-0.29.0.dist-info}/RECORD +51 -41
- {port_ocean-0.28.5.dist-info → port_ocean-0.29.0.dist-info}/LICENSE.md +0 -0
- {port_ocean-0.28.5.dist-info → port_ocean-0.29.0.dist-info}/WHEEL +0 -0
- {port_ocean-0.28.5.dist-info → port_ocean-0.29.0.dist-info}/entry_points.txt +0 -0
|
@@ -348,6 +348,7 @@ async def test_extractMatchingProcessors_processorMatch(
|
|
|
348
348
|
assert len(processors) == 1
|
|
349
349
|
config, processor = processors[0]
|
|
350
350
|
assert isinstance(processor, MockProcessor)
|
|
351
|
+
assert config is not None
|
|
351
352
|
assert config.kind == "repository"
|
|
352
353
|
assert processor.event != webhook_event
|
|
353
354
|
assert processor.event.payload == webhook_event.payload
|
|
@@ -414,6 +415,7 @@ async def test_extractMatchingProcessors_onlyOneMatches(
|
|
|
414
415
|
assert len(processors) == 1
|
|
415
416
|
config, processor = processors[0]
|
|
416
417
|
assert isinstance(processor, MockProcessor)
|
|
418
|
+
assert config is not None
|
|
417
419
|
assert config.kind == "repository"
|
|
418
420
|
assert processor.event != webhook_event
|
|
419
421
|
assert processor.event.payload == webhook_event.payload
|
|
@@ -885,7 +887,7 @@ async def test_integrationTest_postRequestSent_noMatchingHandlers_entityNotUpser
|
|
|
885
887
|
|
|
886
888
|
async def patched_extract_matching_processors(
|
|
887
889
|
self: LiveEventsProcessorManager, event: WebhookEvent, path: str
|
|
888
|
-
) -> list[tuple[ResourceConfig, AbstractWebhookProcessor]]:
|
|
890
|
+
) -> list[tuple[ResourceConfig | None, AbstractWebhookProcessor]]:
|
|
889
891
|
try:
|
|
890
892
|
return await original_process_data(self, event, path)
|
|
891
893
|
except Exception as e:
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from port_ocean.core.models import Entity
|
|
4
|
+
from port_ocean.core.utils.utils import get_port_diff
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def create_test_entity(
|
|
8
|
+
identifier: str,
|
|
9
|
+
blueprint: str,
|
|
10
|
+
properties: dict[str, Any],
|
|
11
|
+
relations: dict[str, Any],
|
|
12
|
+
title: Any,
|
|
13
|
+
team: str | None | list[Any] = [],
|
|
14
|
+
) -> Entity:
|
|
15
|
+
return Entity(
|
|
16
|
+
identifier=identifier,
|
|
17
|
+
blueprint=blueprint,
|
|
18
|
+
properties=properties,
|
|
19
|
+
relations=relations,
|
|
20
|
+
title=title,
|
|
21
|
+
team=team,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
entity1 = create_test_entity(
|
|
26
|
+
"id1",
|
|
27
|
+
"bp1",
|
|
28
|
+
{"totalIssues": 123, "url": "https://test.atlassian.net/browse/test-29081"},
|
|
29
|
+
{"reporter": "id1", "project": "project_id"},
|
|
30
|
+
"",
|
|
31
|
+
"",
|
|
32
|
+
)
|
|
33
|
+
entity1_modified_properties = create_test_entity(
|
|
34
|
+
"id1",
|
|
35
|
+
"bp1",
|
|
36
|
+
{"totalIssues": 5, "url": "https://test.atlassian.net/browse/test-29081"},
|
|
37
|
+
{"reporter": "id1", "project": "project_id"},
|
|
38
|
+
"",
|
|
39
|
+
"",
|
|
40
|
+
)
|
|
41
|
+
entity2 = create_test_entity(
|
|
42
|
+
"id2",
|
|
43
|
+
"bp2",
|
|
44
|
+
{"totalIssues": 234, "url": "https://test.atlassian.net/browse/test-23451"},
|
|
45
|
+
{"reporter": "id2", "project": "project_id2"},
|
|
46
|
+
"",
|
|
47
|
+
"",
|
|
48
|
+
)
|
|
49
|
+
entity3 = create_test_entity(
|
|
50
|
+
"id3",
|
|
51
|
+
"bp3",
|
|
52
|
+
{"totalIssues": 20, "url": "https://test.atlassian.net/browse/test-542"},
|
|
53
|
+
{"reporter": "id3", "project": "project_id3"},
|
|
54
|
+
"",
|
|
55
|
+
"",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_get_port_diff_with_dictionary_identifier() -> None:
|
|
60
|
+
"""
|
|
61
|
+
Test that get_port_diff handles dictionary identifiers by converting them to strings.
|
|
62
|
+
An entity with a dictionary identifier in 'before' and not in 'after' should be marked as deleted.
|
|
63
|
+
"""
|
|
64
|
+
entity_with_dict_id = create_test_entity(
|
|
65
|
+
identifier={"rules": "some_id", "combinator": "some combinator"}, # type: ignore
|
|
66
|
+
blueprint="bp1",
|
|
67
|
+
properties={},
|
|
68
|
+
relations={},
|
|
69
|
+
title="test",
|
|
70
|
+
)
|
|
71
|
+
before = [entity_with_dict_id]
|
|
72
|
+
after: list[Entity] = []
|
|
73
|
+
|
|
74
|
+
diff = get_port_diff(before, after)
|
|
75
|
+
|
|
76
|
+
assert not diff.created
|
|
77
|
+
assert not diff.modified
|
|
78
|
+
assert len(diff.deleted) == 1
|
|
79
|
+
assert entity_with_dict_id in diff.deleted
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_get_port_diff_no_changes() -> None:
|
|
83
|
+
"""
|
|
84
|
+
Test get_port_diff with no changes between before and after.
|
|
85
|
+
Entities present in both should be in the 'modified' list.
|
|
86
|
+
"""
|
|
87
|
+
before = [entity1, entity2]
|
|
88
|
+
after = [entity1, entity2]
|
|
89
|
+
|
|
90
|
+
diff = get_port_diff(before, after)
|
|
91
|
+
|
|
92
|
+
assert not diff.created
|
|
93
|
+
assert not diff.deleted
|
|
94
|
+
assert len(diff.modified) == 2
|
|
95
|
+
assert entity1 in diff.modified
|
|
96
|
+
assert entity2 in diff.modified
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_get_port_diff_created_entities() -> None:
|
|
100
|
+
"""
|
|
101
|
+
Test get_port_diff with only new entities.
|
|
102
|
+
"""
|
|
103
|
+
before: list[Entity] = []
|
|
104
|
+
after = [entity1, entity2]
|
|
105
|
+
|
|
106
|
+
diff = get_port_diff(before, after)
|
|
107
|
+
|
|
108
|
+
assert not diff.modified
|
|
109
|
+
assert not diff.deleted
|
|
110
|
+
assert len(diff.created) == 2
|
|
111
|
+
assert entity1 in diff.created
|
|
112
|
+
assert entity2 in diff.created
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_get_port_diff_deleted_entities() -> None:
|
|
116
|
+
"""
|
|
117
|
+
Test get_port_diff with only deleted entities.
|
|
118
|
+
"""
|
|
119
|
+
before = [entity1, entity2]
|
|
120
|
+
after: list[Entity] = []
|
|
121
|
+
|
|
122
|
+
diff = get_port_diff(before, after)
|
|
123
|
+
|
|
124
|
+
assert not diff.created
|
|
125
|
+
assert not diff.modified
|
|
126
|
+
assert len(diff.deleted) == 2
|
|
127
|
+
assert entity1 in diff.deleted
|
|
128
|
+
assert entity2 in diff.deleted
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_get_port_diff_modified_entities() -> None:
|
|
132
|
+
"""
|
|
133
|
+
Test get_port_diff with modified entities.
|
|
134
|
+
Entities with same identifier and blueprint are considered modified.
|
|
135
|
+
"""
|
|
136
|
+
before = [entity1, entity2]
|
|
137
|
+
after = [entity1_modified_properties, entity2]
|
|
138
|
+
|
|
139
|
+
diff = get_port_diff(before, after)
|
|
140
|
+
|
|
141
|
+
assert not diff.created
|
|
142
|
+
assert not diff.deleted
|
|
143
|
+
assert len(diff.modified) == 2
|
|
144
|
+
assert entity1_modified_properties in diff.modified
|
|
145
|
+
assert entity2 in diff.modified
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_get_port_diff_mixed_changes() -> None:
|
|
149
|
+
"""
|
|
150
|
+
Test get_port_diff with a mix of created, modified, and deleted entities.
|
|
151
|
+
"""
|
|
152
|
+
before = [entity1, entity2]
|
|
153
|
+
after = [entity1_modified_properties, entity3]
|
|
154
|
+
|
|
155
|
+
diff = get_port_diff(before, after)
|
|
156
|
+
|
|
157
|
+
assert len(diff.created) == 1
|
|
158
|
+
assert entity3 in diff.created
|
|
159
|
+
|
|
160
|
+
assert len(diff.modified) == 1
|
|
161
|
+
assert entity1_modified_properties in diff.modified
|
|
162
|
+
|
|
163
|
+
assert len(diff.deleted) == 1
|
|
164
|
+
assert entity2 in diff.deleted
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pytest
|
|
2
|
-
from unittest.mock import Mock
|
|
2
|
+
from unittest.mock import Mock, patch
|
|
3
3
|
from http import HTTPStatus
|
|
4
4
|
import httpx
|
|
5
5
|
|
|
@@ -307,3 +307,243 @@ class TestRetryConfigIntegration:
|
|
|
307
307
|
"Retry-After",
|
|
308
308
|
]
|
|
309
309
|
assert HTTPStatus.FORBIDDEN in transport._retry_config.retry_status_codes
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class TestResponseSizeLogging:
|
|
313
|
+
"""Tests for response size logging functionality."""
|
|
314
|
+
|
|
315
|
+
def setup_method(self) -> None:
|
|
316
|
+
"""Reset global callback state before each test."""
|
|
317
|
+
retry_module._RETRY_CONFIG_CALLBACK = None
|
|
318
|
+
retry_module._ON_RETRY_CALLBACK = None
|
|
319
|
+
|
|
320
|
+
def test_should_log_response_size_with_logger(self) -> None:
|
|
321
|
+
"""Test _should_log_response_size returns True when logger is present and not getport.io."""
|
|
322
|
+
mock_transport = Mock()
|
|
323
|
+
mock_logger = Mock()
|
|
324
|
+
transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
|
|
325
|
+
|
|
326
|
+
mock_request = Mock()
|
|
327
|
+
mock_request.url.host = "api.example.com"
|
|
328
|
+
|
|
329
|
+
assert transport._should_log_response_size(mock_request) is True
|
|
330
|
+
|
|
331
|
+
def test_should_log_response_size_without_logger(self) -> None:
|
|
332
|
+
"""Test _should_log_response_size returns False when no logger."""
|
|
333
|
+
mock_transport = Mock()
|
|
334
|
+
transport = RetryTransport(wrapped_transport=mock_transport)
|
|
335
|
+
|
|
336
|
+
mock_request = Mock()
|
|
337
|
+
mock_request.url.host = "api.example.com"
|
|
338
|
+
|
|
339
|
+
assert transport._should_log_response_size(mock_request) is False
|
|
340
|
+
|
|
341
|
+
def test_should_log_response_size_getport_io(self) -> None:
|
|
342
|
+
"""Test _should_log_response_size returns False for getport.io domains."""
|
|
343
|
+
mock_transport = Mock()
|
|
344
|
+
mock_logger = Mock()
|
|
345
|
+
transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
|
|
346
|
+
|
|
347
|
+
mock_request = Mock()
|
|
348
|
+
mock_request.url.host = "api.getport.io"
|
|
349
|
+
|
|
350
|
+
assert transport._should_log_response_size(mock_request) is False
|
|
351
|
+
|
|
352
|
+
def test_get_content_length_from_headers(self) -> None:
|
|
353
|
+
"""Test _get_content_length extracts content length from headers."""
|
|
354
|
+
mock_transport = Mock()
|
|
355
|
+
transport = RetryTransport(wrapped_transport=mock_transport)
|
|
356
|
+
|
|
357
|
+
mock_response = Mock()
|
|
358
|
+
mock_response.headers = {"Content-Length": "1024"}
|
|
359
|
+
|
|
360
|
+
assert transport._get_content_length(mock_response) == 1024
|
|
361
|
+
|
|
362
|
+
def test_get_content_length_case_insensitive(self) -> None:
|
|
363
|
+
"""Test _get_content_length works with case insensitive headers."""
|
|
364
|
+
mock_transport = Mock()
|
|
365
|
+
transport = RetryTransport(wrapped_transport=mock_transport)
|
|
366
|
+
|
|
367
|
+
mock_response = Mock()
|
|
368
|
+
mock_response.headers = {"content-length": "2048"}
|
|
369
|
+
|
|
370
|
+
assert transport._get_content_length(mock_response) == 2048
|
|
371
|
+
|
|
372
|
+
def test_get_content_length_no_header(self) -> None:
|
|
373
|
+
"""Test _get_content_length returns None when no content-length header."""
|
|
374
|
+
mock_transport = Mock()
|
|
375
|
+
transport = RetryTransport(wrapped_transport=mock_transport)
|
|
376
|
+
|
|
377
|
+
mock_response = Mock()
|
|
378
|
+
mock_response.headers = {}
|
|
379
|
+
|
|
380
|
+
assert transport._get_content_length(mock_response) is None
|
|
381
|
+
|
|
382
|
+
def test_get_content_length_invalid_value(self) -> None:
|
|
383
|
+
"""Test _get_content_length handles invalid header values."""
|
|
384
|
+
mock_transport = Mock()
|
|
385
|
+
transport = RetryTransport(wrapped_transport=mock_transport)
|
|
386
|
+
|
|
387
|
+
mock_response = Mock()
|
|
388
|
+
mock_response.headers = {"Content-Length": "invalid"}
|
|
389
|
+
|
|
390
|
+
# Should raise ValueError when converting to int
|
|
391
|
+
with pytest.raises(ValueError):
|
|
392
|
+
transport._get_content_length(mock_response)
|
|
393
|
+
|
|
394
|
+
@patch("port_ocean.helpers.retry.cast")
|
|
395
|
+
def test_log_response_size_with_content_length(self, mock_cast: Mock) -> None:
|
|
396
|
+
"""Test _log_response_size logs when Content-Length header is present."""
|
|
397
|
+
mock_transport = Mock()
|
|
398
|
+
mock_logger = Mock()
|
|
399
|
+
mock_cast.return_value = mock_logger
|
|
400
|
+
transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
|
|
401
|
+
|
|
402
|
+
mock_request = Mock()
|
|
403
|
+
mock_request.method = "GET"
|
|
404
|
+
mock_url = Mock()
|
|
405
|
+
mock_url.host = "api.example.com"
|
|
406
|
+
mock_url.configure_mock(__str__=lambda self: "https://api.example.com/data")
|
|
407
|
+
mock_request.url = mock_url
|
|
408
|
+
|
|
409
|
+
mock_response = Mock()
|
|
410
|
+
mock_response.headers = {"Content-Length": "1024"}
|
|
411
|
+
|
|
412
|
+
transport._log_response_size(mock_request, mock_response)
|
|
413
|
+
|
|
414
|
+
mock_logger.info.assert_called_once_with(
|
|
415
|
+
"Response for GET https://api.example.com/data - Size: 1024 bytes"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
@patch("port_ocean.helpers.retry.cast")
|
|
419
|
+
def test_log_response_size_without_content_length(self, mock_cast: Mock) -> None:
|
|
420
|
+
"""Test _log_response_size does nothing when no Content-Length header."""
|
|
421
|
+
mock_transport = Mock()
|
|
422
|
+
mock_logger = Mock()
|
|
423
|
+
mock_cast.return_value = mock_logger
|
|
424
|
+
transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
|
|
425
|
+
|
|
426
|
+
mock_request = Mock()
|
|
427
|
+
mock_request.method = "POST"
|
|
428
|
+
mock_url = Mock()
|
|
429
|
+
mock_url.host = "api.example.com"
|
|
430
|
+
mock_url.configure_mock(__str__=lambda self: "https://api.example.com/create")
|
|
431
|
+
mock_request.url = mock_url
|
|
432
|
+
|
|
433
|
+
mock_response = Mock()
|
|
434
|
+
mock_response.headers = {}
|
|
435
|
+
|
|
436
|
+
transport._log_response_size(mock_request, mock_response)
|
|
437
|
+
|
|
438
|
+
mock_response.read.assert_not_called()
|
|
439
|
+
mock_logger.info.assert_not_called()
|
|
440
|
+
|
|
441
|
+
# Read error path removed since _log_response_size no longer reads body
|
|
442
|
+
|
|
443
|
+
@patch("port_ocean.helpers.retry.cast")
|
|
444
|
+
def test_log_response_size_skips_when_should_not_log(self, mock_cast: Mock) -> None:
|
|
445
|
+
"""Test _log_response_size skips logging when _should_log_response_size returns False."""
|
|
446
|
+
mock_transport = Mock()
|
|
447
|
+
mock_logger = Mock()
|
|
448
|
+
mock_cast.return_value = mock_logger
|
|
449
|
+
transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
|
|
450
|
+
|
|
451
|
+
mock_request = Mock()
|
|
452
|
+
mock_request.url.host = "api.getport.io" # This should skip logging
|
|
453
|
+
|
|
454
|
+
mock_response = Mock()
|
|
455
|
+
mock_response.headers = {"Content-Length": "1024"}
|
|
456
|
+
|
|
457
|
+
transport._log_response_size(mock_request, mock_response)
|
|
458
|
+
|
|
459
|
+
mock_logger.info.assert_not_called()
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class TestResponseSizeLoggingIntegration:
|
|
463
|
+
"""Integration tests to verify response consumption works after size logging."""
|
|
464
|
+
|
|
465
|
+
def setup_method(self) -> None:
|
|
466
|
+
"""Reset global callback state before each test."""
|
|
467
|
+
retry_module._RETRY_CONFIG_CALLBACK = None
|
|
468
|
+
retry_module._ON_RETRY_CALLBACK = None
|
|
469
|
+
|
|
470
|
+
@patch("port_ocean.helpers.retry.cast")
|
|
471
|
+
def test_log_response_size_preserves_json_consumption(
|
|
472
|
+
self, mock_cast: Mock
|
|
473
|
+
) -> None:
|
|
474
|
+
"""When no Content-Length, no logging/reading occurs; response usable."""
|
|
475
|
+
mock_transport = Mock()
|
|
476
|
+
mock_logger = Mock()
|
|
477
|
+
mock_cast.return_value = mock_logger
|
|
478
|
+
transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
|
|
479
|
+
|
|
480
|
+
mock_request = Mock()
|
|
481
|
+
mock_request.method = "GET"
|
|
482
|
+
mock_request.url.host = "api.example.com"
|
|
483
|
+
|
|
484
|
+
mock_response = Mock()
|
|
485
|
+
mock_response.headers = {}
|
|
486
|
+
mock_response.json.return_value = {"message": "test", "data": [1, 2, 3]}
|
|
487
|
+
|
|
488
|
+
transport._log_response_size(mock_request, mock_response)
|
|
489
|
+
|
|
490
|
+
mock_logger.info.assert_not_called()
|
|
491
|
+
result = mock_response.json()
|
|
492
|
+
assert result == {"message": "test", "data": [1, 2, 3]}
|
|
493
|
+
mock_response.read.assert_not_called()
|
|
494
|
+
|
|
495
|
+
@patch("port_ocean.helpers.retry.cast")
|
|
496
|
+
def test_log_response_size_with_content_length_preserves_json(
|
|
497
|
+
self, mock_cast: Mock
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Test that _log_response_size with Content-Length header preserves JSON consumption."""
|
|
500
|
+
mock_transport = Mock()
|
|
501
|
+
mock_logger = Mock()
|
|
502
|
+
mock_cast.return_value = mock_logger
|
|
503
|
+
transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
|
|
504
|
+
|
|
505
|
+
mock_request = Mock()
|
|
506
|
+
mock_request.method = "POST"
|
|
507
|
+
mock_request.url.host = "api.example.com"
|
|
508
|
+
|
|
509
|
+
# Create a mock response with Content-Length header
|
|
510
|
+
mock_response = Mock()
|
|
511
|
+
mock_response.headers = {"Content-Length": "1024"}
|
|
512
|
+
mock_response.json.return_value = {"status": "success", "id": 123}
|
|
513
|
+
|
|
514
|
+
# Call the logging function
|
|
515
|
+
transport._log_response_size(mock_request, mock_response)
|
|
516
|
+
|
|
517
|
+
# Verify logging occurred
|
|
518
|
+
mock_logger.info.assert_called_once()
|
|
519
|
+
|
|
520
|
+
# Verify that response.json() can still be called
|
|
521
|
+
result = mock_response.json()
|
|
522
|
+
assert result == {"status": "success", "id": 123}
|
|
523
|
+
|
|
524
|
+
# Verify that read was NOT called since we had Content-Length
|
|
525
|
+
mock_response.read.assert_not_called()
|
|
526
|
+
|
|
527
|
+
@patch("port_ocean.helpers.retry.cast")
|
|
528
|
+
def test_log_response_size_preserves_text_consumption(
|
|
529
|
+
self, mock_cast: Mock
|
|
530
|
+
) -> None:
|
|
531
|
+
"""When no Content-Length, no logging/reading; response.text still accessible."""
|
|
532
|
+
mock_transport = Mock()
|
|
533
|
+
mock_logger = Mock()
|
|
534
|
+
mock_cast.return_value = mock_logger
|
|
535
|
+
transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
|
|
536
|
+
|
|
537
|
+
mock_request = Mock()
|
|
538
|
+
mock_request.method = "GET"
|
|
539
|
+
mock_request.url.host = "api.example.com"
|
|
540
|
+
|
|
541
|
+
mock_response = Mock()
|
|
542
|
+
mock_response.headers = {}
|
|
543
|
+
mock_response.text = "Hello, World! This is a test response."
|
|
544
|
+
|
|
545
|
+
transport._log_response_size(mock_request, mock_response)
|
|
546
|
+
|
|
547
|
+
mock_logger.info.assert_not_called()
|
|
548
|
+
assert mock_response.text == "Hello, World! This is a test response."
|
|
549
|
+
mock_response.read.assert_not_called()
|
|
@@ -272,3 +272,243 @@ async def test_cache_failures_dont_affect_execution(
|
|
|
272
272
|
# Verify that both read and write errors were raised
|
|
273
273
|
assert isinstance(mock_cache_provider.get.side_effect, FailedToReadCacheError)
|
|
274
274
|
assert isinstance(mock_cache_provider.set.side_effect, FailedToWriteCacheError)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@pytest.mark.asyncio
|
|
278
|
+
async def test_cache_iterator_result_on_instance_method(
|
|
279
|
+
mock_ocean: Any, monkeypatch: Any
|
|
280
|
+
) -> None:
|
|
281
|
+
monkeypatch.setattr(cache, "ocean", mock_ocean)
|
|
282
|
+
|
|
283
|
+
class Sample:
|
|
284
|
+
def __init__(self) -> None:
|
|
285
|
+
self.calls = 0
|
|
286
|
+
|
|
287
|
+
@cache.cache_iterator_result()
|
|
288
|
+
async def inst_method(self, x: int) -> AsyncGenerator[List[int], None]:
|
|
289
|
+
self.calls += 1
|
|
290
|
+
for i in range(x):
|
|
291
|
+
await asyncio.sleep(0.01)
|
|
292
|
+
yield [i]
|
|
293
|
+
|
|
294
|
+
s = Sample()
|
|
295
|
+
|
|
296
|
+
# First call should MISS and increment calls
|
|
297
|
+
result1 = await collect_iterator_results(s.inst_method(3))
|
|
298
|
+
assert result1 == [0, 1, 2]
|
|
299
|
+
assert s.calls == 1
|
|
300
|
+
|
|
301
|
+
# Second call with same args should HIT cache
|
|
302
|
+
result2 = await collect_iterator_results(s.inst_method(3))
|
|
303
|
+
assert result2 == [0, 1, 2]
|
|
304
|
+
assert s.calls == 1 # no extra call
|
|
305
|
+
|
|
306
|
+
# Different args should MISS again
|
|
307
|
+
result3 = await collect_iterator_results(s.inst_method(4))
|
|
308
|
+
assert result3 == [0, 1, 2, 3]
|
|
309
|
+
assert s.calls == 2
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@pytest.mark.asyncio
|
|
313
|
+
async def test_cache_iterator_result_on_class_method(
|
|
314
|
+
mock_ocean: Any, monkeypatch: Any
|
|
315
|
+
) -> None:
|
|
316
|
+
monkeypatch.setattr(cache, "ocean", mock_ocean)
|
|
317
|
+
|
|
318
|
+
class Sample:
|
|
319
|
+
calls = 0
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
@cache.cache_iterator_result()
|
|
323
|
+
async def cls_method(cls, x: int) -> AsyncGenerator[List[int], None]:
|
|
324
|
+
cls.calls += 1
|
|
325
|
+
for i in range(x):
|
|
326
|
+
await asyncio.sleep(0.01)
|
|
327
|
+
yield [i]
|
|
328
|
+
|
|
329
|
+
# First call should MISS
|
|
330
|
+
result1 = await collect_iterator_results(Sample.cls_method(3))
|
|
331
|
+
assert result1 == [0, 1, 2]
|
|
332
|
+
assert Sample.calls == 1
|
|
333
|
+
|
|
334
|
+
# Second call with same args should HIT cache
|
|
335
|
+
result2 = await collect_iterator_results(Sample.cls_method(3))
|
|
336
|
+
assert result2 == [0, 1, 2]
|
|
337
|
+
assert Sample.calls == 1
|
|
338
|
+
|
|
339
|
+
# Different args should MISS
|
|
340
|
+
result3 = await collect_iterator_results(Sample.cls_method(2))
|
|
341
|
+
assert result3 == [0, 1]
|
|
342
|
+
assert Sample.calls == 2
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@pytest.mark.asyncio
|
|
346
|
+
async def test_cache_iterator_result_on_static_method(
|
|
347
|
+
mock_ocean: Any, monkeypatch: Any
|
|
348
|
+
) -> None:
|
|
349
|
+
monkeypatch.setattr(cache, "ocean", mock_ocean)
|
|
350
|
+
|
|
351
|
+
class Sample:
|
|
352
|
+
calls = 0
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
@cache.cache_iterator_result()
|
|
356
|
+
async def static_method(x: int) -> AsyncGenerator[List[int], None]:
|
|
357
|
+
Sample.calls += 1
|
|
358
|
+
for i in range(x):
|
|
359
|
+
await asyncio.sleep(0.01)
|
|
360
|
+
yield [i]
|
|
361
|
+
|
|
362
|
+
# First call should MISS
|
|
363
|
+
result1 = await collect_iterator_results(Sample.static_method(3))
|
|
364
|
+
assert result1 == [0, 1, 2]
|
|
365
|
+
assert Sample.calls == 1
|
|
366
|
+
|
|
367
|
+
# Second call with same args should HIT
|
|
368
|
+
result2 = await collect_iterator_results(Sample.static_method(3))
|
|
369
|
+
assert result2 == [0, 1, 2]
|
|
370
|
+
assert Sample.calls == 1
|
|
371
|
+
|
|
372
|
+
# Different args should MISS
|
|
373
|
+
result3 = await collect_iterator_results(Sample.static_method(4))
|
|
374
|
+
assert result3 == [0, 1, 2, 3]
|
|
375
|
+
assert Sample.calls == 2
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@pytest.mark.asyncio
|
|
379
|
+
async def test_regular_iterator_with_self_param_not_filtered(
|
|
380
|
+
mock_ocean: Any, monkeypatch: Any
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Test that regular iterator functions with 'self' parameter are not filtered (by design)."""
|
|
383
|
+
monkeypatch.setattr(cache, "ocean", mock_ocean)
|
|
384
|
+
|
|
385
|
+
async def regular_function_with_self(
|
|
386
|
+
self: int, y: int
|
|
387
|
+
) -> AsyncGenerator[List[int], None]:
|
|
388
|
+
for i in range(self + y):
|
|
389
|
+
await asyncio.sleep(0.01)
|
|
390
|
+
yield [i]
|
|
391
|
+
|
|
392
|
+
# Test that 'self' parameter IS filtered (by design, for consistency)
|
|
393
|
+
key1 = cache.hash_func(regular_function_with_self, 5, y=3)
|
|
394
|
+
key2 = cache.hash_func(regular_function_with_self, 4, y=3)
|
|
395
|
+
|
|
396
|
+
# Keys should not be the same because 'self' is not filtered (by design)
|
|
397
|
+
assert key1 != key2
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@pytest.mark.asyncio
|
|
401
|
+
async def test_cache_coroutine_result_on_instance_method(
|
|
402
|
+
mock_ocean: Any, monkeypatch: Any
|
|
403
|
+
) -> None:
|
|
404
|
+
monkeypatch.setattr(cache, "ocean", mock_ocean)
|
|
405
|
+
|
|
406
|
+
class Sample:
|
|
407
|
+
def __init__(self) -> None:
|
|
408
|
+
self.calls = 0
|
|
409
|
+
|
|
410
|
+
@cache.cache_coroutine_result()
|
|
411
|
+
async def inst_method(self, x: int) -> int:
|
|
412
|
+
self.calls += 1
|
|
413
|
+
await asyncio.sleep(0.01)
|
|
414
|
+
return x * 2
|
|
415
|
+
|
|
416
|
+
s = Sample()
|
|
417
|
+
|
|
418
|
+
# First call should MISS and increment calls
|
|
419
|
+
result1 = await s.inst_method(3)
|
|
420
|
+
assert result1 == 6
|
|
421
|
+
assert s.calls == 1
|
|
422
|
+
|
|
423
|
+
# Second call with same args should HIT cache
|
|
424
|
+
result2 = await s.inst_method(3)
|
|
425
|
+
assert result2 == 6
|
|
426
|
+
assert s.calls == 1 # still 1 call
|
|
427
|
+
|
|
428
|
+
# Different args should MISS again
|
|
429
|
+
result3 = await s.inst_method(4)
|
|
430
|
+
assert result3 == 8
|
|
431
|
+
assert s.calls == 2
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@pytest.mark.asyncio
|
|
435
|
+
async def test_cache_coroutine_result_on_class_method(
|
|
436
|
+
mock_ocean: Any, monkeypatch: Any
|
|
437
|
+
) -> None:
|
|
438
|
+
monkeypatch.setattr(cache, "ocean", mock_ocean)
|
|
439
|
+
|
|
440
|
+
class Sample:
|
|
441
|
+
calls = 0
|
|
442
|
+
|
|
443
|
+
@classmethod
|
|
444
|
+
@cache.cache_coroutine_result()
|
|
445
|
+
async def cls_method(cls, x: int) -> int:
|
|
446
|
+
cls.calls += 1
|
|
447
|
+
await asyncio.sleep(0.01)
|
|
448
|
+
return x + 5
|
|
449
|
+
|
|
450
|
+
# First call should MISS
|
|
451
|
+
result1 = await Sample.cls_method(3)
|
|
452
|
+
assert result1 == 8
|
|
453
|
+
assert Sample.calls == 1
|
|
454
|
+
|
|
455
|
+
# Second call with same args should HIT
|
|
456
|
+
result2 = await Sample.cls_method(3)
|
|
457
|
+
assert result2 == 8
|
|
458
|
+
assert Sample.calls == 1
|
|
459
|
+
|
|
460
|
+
# Different args should MISS
|
|
461
|
+
result3 = await Sample.cls_method(2)
|
|
462
|
+
assert result3 == 7
|
|
463
|
+
assert Sample.calls == 2
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@pytest.mark.asyncio
|
|
467
|
+
async def test_cache_coroutine_result_on_static_method(
|
|
468
|
+
mock_ocean: Any, monkeypatch: Any
|
|
469
|
+
) -> None:
|
|
470
|
+
monkeypatch.setattr(cache, "ocean", mock_ocean)
|
|
471
|
+
|
|
472
|
+
class Sample:
|
|
473
|
+
calls = 0
|
|
474
|
+
|
|
475
|
+
@staticmethod
|
|
476
|
+
@cache.cache_coroutine_result()
|
|
477
|
+
async def static_method(x: int) -> int:
|
|
478
|
+
Sample.calls += 1
|
|
479
|
+
await asyncio.sleep(0.01)
|
|
480
|
+
return x * x
|
|
481
|
+
|
|
482
|
+
# First call should MISS
|
|
483
|
+
result1 = await Sample.static_method(3)
|
|
484
|
+
assert result1 == 9
|
|
485
|
+
assert Sample.calls == 1
|
|
486
|
+
|
|
487
|
+
# Second call with same args should HIT
|
|
488
|
+
result2 = await Sample.static_method(3)
|
|
489
|
+
assert result2 == 9
|
|
490
|
+
assert Sample.calls == 1
|
|
491
|
+
|
|
492
|
+
# Different args should MISS
|
|
493
|
+
result3 = await Sample.static_method(4)
|
|
494
|
+
assert result3 == 16
|
|
495
|
+
assert Sample.calls == 2
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@pytest.mark.asyncio
|
|
499
|
+
async def test_regular_coroutine_with_self_param_not_filtered(
|
|
500
|
+
mock_ocean: Any, monkeypatch: Any
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Test that regular coroutines with 'self' parameter are not filtered (by design)."""
|
|
503
|
+
monkeypatch.setattr(cache, "ocean", mock_ocean)
|
|
504
|
+
|
|
505
|
+
@cache.cache_coroutine_result()
|
|
506
|
+
async def regular_function_with_self(self: int, y: int) -> int:
|
|
507
|
+
await asyncio.sleep(0.01)
|
|
508
|
+
return self + y
|
|
509
|
+
|
|
510
|
+
key1 = cache.hash_func(regular_function_with_self, 5, y=3)
|
|
511
|
+
key2 = cache.hash_func(regular_function_with_self, 4, y=3)
|
|
512
|
+
|
|
513
|
+
# Keys should not be the same because 'self' is not filtered
|
|
514
|
+
assert key1 != key2
|