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.
Files changed (51) hide show
  1. integrations/_infra/Dockerfile.Deb +1 -0
  2. integrations/_infra/Dockerfile.local +1 -0
  3. port_ocean/clients/port/authentication.py +19 -0
  4. port_ocean/clients/port/client.py +3 -0
  5. port_ocean/clients/port/mixins/actions.py +93 -0
  6. port_ocean/clients/port/mixins/blueprints.py +0 -12
  7. port_ocean/clients/port/mixins/entities.py +79 -44
  8. port_ocean/clients/port/mixins/integrations.py +7 -2
  9. port_ocean/config/settings.py +35 -3
  10. port_ocean/context/ocean.py +7 -5
  11. port_ocean/core/defaults/initialize.py +12 -5
  12. port_ocean/core/event_listener/__init__.py +7 -0
  13. port_ocean/core/event_listener/actions_only.py +42 -0
  14. port_ocean/core/event_listener/base.py +4 -1
  15. port_ocean/core/event_listener/factory.py +18 -9
  16. port_ocean/core/event_listener/http.py +4 -3
  17. port_ocean/core/event_listener/kafka.py +3 -2
  18. port_ocean/core/event_listener/once.py +5 -2
  19. port_ocean/core/event_listener/polling.py +4 -3
  20. port_ocean/core/event_listener/webhooks_only.py +3 -2
  21. port_ocean/core/handlers/actions/__init__.py +7 -0
  22. port_ocean/core/handlers/actions/abstract_executor.py +150 -0
  23. port_ocean/core/handlers/actions/execution_manager.py +434 -0
  24. port_ocean/core/handlers/entity_processor/jq_entity_processor.py +479 -17
  25. port_ocean/core/handlers/entity_processor/jq_input_evaluator.py +137 -0
  26. port_ocean/core/handlers/port_app_config/models.py +4 -2
  27. port_ocean/core/handlers/webhook/abstract_webhook_processor.py +16 -0
  28. port_ocean/core/handlers/webhook/processor_manager.py +30 -12
  29. port_ocean/core/integrations/mixins/sync_raw.py +4 -4
  30. port_ocean/core/integrations/mixins/utils.py +250 -29
  31. port_ocean/core/models.py +35 -2
  32. port_ocean/core/utils/utils.py +16 -5
  33. port_ocean/exceptions/execution_manager.py +22 -0
  34. port_ocean/helpers/retry.py +4 -40
  35. port_ocean/log/logger_setup.py +2 -2
  36. port_ocean/ocean.py +30 -4
  37. port_ocean/tests/clients/port/mixins/test_entities.py +71 -5
  38. port_ocean/tests/core/event_listener/test_kafka.py +14 -7
  39. port_ocean/tests/core/handlers/actions/test_execution_manager.py +837 -0
  40. port_ocean/tests/core/handlers/entity_processor/test_jq_entity_processor.py +932 -1
  41. port_ocean/tests/core/handlers/entity_processor/test_jq_input_evaluator.py +932 -0
  42. port_ocean/tests/core/handlers/webhook/test_processor_manager.py +3 -1
  43. port_ocean/tests/core/utils/test_get_port_diff.py +164 -0
  44. port_ocean/tests/helpers/test_retry.py +241 -1
  45. port_ocean/tests/utils/test_cache.py +240 -0
  46. port_ocean/utils/cache.py +45 -9
  47. {port_ocean-0.28.5.dist-info → port_ocean-0.29.0.dist-info}/METADATA +2 -1
  48. {port_ocean-0.28.5.dist-info → port_ocean-0.29.0.dist-info}/RECORD +51 -41
  49. {port_ocean-0.28.5.dist-info → port_ocean-0.29.0.dist-info}/LICENSE.md +0 -0
  50. {port_ocean-0.28.5.dist-info → port_ocean-0.29.0.dist-info}/WHEEL +0 -0
  51. {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