port-ocean 0.28.15__py3-none-any.whl → 0.28.17__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,24 +1,24 @@
1
1
  import asyncio
2
+ import json
2
3
  from typing import Any, Literal
3
4
  from urllib.parse import quote_plus
4
- import json
5
5
 
6
6
  import httpx
7
7
  from loguru import logger
8
- from port_ocean.context.ocean import ocean
8
+ from starlette import status
9
+
9
10
  from port_ocean.clients.port.authentication import PortAuthentication
10
11
  from port_ocean.clients.port.types import RequestOptions, UserAgentType
11
12
  from port_ocean.clients.port.utils import (
12
- handle_port_status_code,
13
13
  PORT_HTTP_MAX_CONNECTIONS_LIMIT,
14
+ handle_port_status_code,
14
15
  )
16
+ from port_ocean.context.ocean import ocean
15
17
  from port_ocean.core.models import (
16
18
  BulkUpsertResponse,
17
19
  Entity,
18
20
  PortAPIErrorMessage,
19
21
  )
20
- from starlette import status
21
-
22
22
  from port_ocean.helpers.metric.metric import MetricPhase, MetricType
23
23
 
24
24
  ENTITIES_BULK_SAMPLES_SIZE = 10
@@ -484,54 +484,89 @@ class EntityClientMixin:
484
484
  parameters_to_include: list[str] | None = None,
485
485
  ) -> list[Entity]:
486
486
  if query is None:
487
- datasource_prefix = f"port-ocean/{self.auth.integration_type}/"
488
- datasource_suffix = (
489
- f"/{self.auth.integration_identifier}/{user_agent_type.value}"
490
- )
491
- logger.info(
492
- f"Searching entities with datasource prefix: {datasource_prefix} and suffix: {datasource_suffix}"
493
- )
487
+ return await self._search_entities_by_datasource_paginated(user_agent_type)
488
+
489
+ return await self._search_entities_by_query(
490
+ user_agent_type=user_agent_type,
491
+ query=query,
492
+ parameters_to_include=parameters_to_include,
493
+ )
494
+
495
+ async def _search_entities_by_datasource_paginated(
496
+ self, user_agent_type: UserAgentType
497
+ ) -> list[Entity]:
498
+ datasource_prefix = f"port-ocean/{self.auth.integration_type}/"
499
+ datasource_suffix = (
500
+ f"/{self.auth.integration_identifier}/{user_agent_type.value}"
501
+ )
502
+ logger.info(
503
+ f"Searching entities with datasource prefix: {datasource_prefix} and suffix: {datasource_suffix}"
504
+ )
505
+
506
+ next_from: str | None = None
507
+ aggregated_entities: list[Entity] = []
508
+ while True:
509
+ request_body: dict[str, Any] = {
510
+ "datasource_prefix": datasource_prefix,
511
+ "datasource_suffix": datasource_suffix,
512
+ }
513
+
514
+ if next_from:
515
+ request_body["from"] = next_from
494
516
 
495
517
  response = await self.client.post(
496
518
  f"{self.auth.api_url}/blueprints/entities/datasource-entities",
497
- json={
498
- "datasource_prefix": datasource_prefix,
499
- "datasource_suffix": datasource_suffix,
500
- },
519
+ json=request_body,
501
520
  headers=await self.auth.headers(user_agent_type),
502
521
  extensions={"retryable": True},
503
522
  )
504
- else:
505
- default_query = {
506
- "combinator": "and",
507
- "rules": [
508
- {
509
- "property": "$datasource",
510
- "operator": "contains",
511
- "value": f"port-ocean/{self.auth.integration_type}/",
512
- },
513
- {
514
- "property": "$datasource",
515
- "operator": "contains",
516
- "value": f"/{self.auth.integration_identifier}/{user_agent_type.value}",
517
- },
518
- ],
519
- }
523
+ handle_port_status_code(response)
524
+ response_json = response.json()
525
+ aggregated_entities.extend(
526
+ Entity.parse_obj(result) for result in response_json.get("entities", [])
527
+ )
528
+ next_from = response_json.get("next")
529
+ if not next_from:
530
+ break
520
531
 
521
- if query.get("rules"):
522
- query["rules"].extend(default_query["rules"])
532
+ return aggregated_entities
523
533
 
524
- logger.info(f"Searching entities with custom query: {query}")
525
- response = await self.client.post(
526
- f"{self.auth.api_url}/entities/search",
527
- json=query,
528
- headers=await self.auth.headers(user_agent_type),
529
- params={
530
- "exclude_calculated_properties": "true",
531
- "include": parameters_to_include or ["blueprint", "identifier"],
534
+ async def _search_entities_by_query(
535
+ self,
536
+ user_agent_type: UserAgentType,
537
+ query: dict[Any, Any],
538
+ parameters_to_include: list[str] | None,
539
+ ) -> list[Entity]:
540
+ default_query = {
541
+ "combinator": "and",
542
+ "rules": [
543
+ {
544
+ "property": "$datasource",
545
+ "operator": "contains",
546
+ "value": f"port-ocean/{self.auth.integration_type}/",
532
547
  },
533
- extensions={"retryable": True},
534
- )
548
+ {
549
+ "property": "$datasource",
550
+ "operator": "contains",
551
+ "value": f"/{self.auth.integration_identifier}/{user_agent_type.value}",
552
+ },
553
+ ],
554
+ }
555
+
556
+ if query.get("rules"):
557
+ query["rules"].extend(default_query["rules"])
558
+
559
+ logger.info(f"Searching entities with custom query: {query}")
560
+ response = await self.client.post(
561
+ f"{self.auth.api_url}/entities/search",
562
+ json=query,
563
+ headers=await self.auth.headers(user_agent_type),
564
+ params={
565
+ "exclude_calculated_properties": "true",
566
+ "include": parameters_to_include or ["blueprint", "identifier"],
567
+ },
568
+ extensions={"retryable": True},
569
+ )
535
570
 
536
571
  handle_port_status_code(response)
537
572
  return [Entity.parse_obj(result) for result in response.json()["entities"]]
@@ -359,8 +359,11 @@ class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
359
359
  else:
360
360
  # If no Content-Length header, try to get actual content size
361
361
  try:
362
- actual_size = len(await response.aread())
362
+ content = await response.aread()
363
+ actual_size = len(content)
363
364
  size_info = actual_size
365
+ # Restore the cached body so downstream code can still use .json()/.text/.content
366
+ response._content = content # httpx convention
364
367
  except Exception as e:
365
368
  cast(logging.Logger, self._logger).error(
366
369
  f"Error getting response size: {e}"
@@ -383,8 +386,11 @@ class RetryTransport(httpx.AsyncBaseTransport, httpx.BaseTransport):
383
386
  else:
384
387
  # If no Content-Length header, try to get actual content size
385
388
  try:
386
- actual_size = len(response.read())
389
+ content = response.read()
390
+ actual_size = len(content)
387
391
  size_info = actual_size
392
+ # Restore the cached body so downstream code can still use .json()/.text/.content
393
+ response._content = content # httpx convention
388
394
  except Exception as e:
389
395
  cast(logging.Logger, self._logger).error(
390
396
  f"Error getting response size: {e}"
@@ -179,7 +179,7 @@ async def test_search_entities_uses_datasource_route_when_query_is_none(
179
179
  ) -> None:
180
180
  """Test that search_entities uses datasource route when query is None"""
181
181
  mock_response = MagicMock()
182
- mock_response.json.return_value = {"entities": []}
182
+ mock_response.json.return_value = {"entities": [], "next": None}
183
183
  mock_response.is_error = False
184
184
  mock_response.status_code = 200
185
185
  mock_response.headers = {}
@@ -208,8 +208,74 @@ async def test_search_entities_uses_datasource_route_when_query_is_none(
208
208
  == "https://api.getport.io/v1/blueprints/entities/datasource-entities"
209
209
  )
210
210
 
211
- expected_json = {
212
- "datasource_prefix": "port-ocean/test-integration/",
213
- "datasource_suffix": "/test-identifier/sync",
211
+ sent_json = call_args[1]["json"]
212
+ assert sent_json["datasource_prefix"] == "port-ocean/test-integration/"
213
+ assert sent_json["datasource_suffix"] == "/test-identifier/sync"
214
+
215
+
216
+ async def test_search_entities_uses_datasource_route_when_query_is_none_two_pages(
217
+ entity_client: EntityClientMixin,
218
+ ) -> None:
219
+ """Test that search_entities uses datasource route when query is None"""
220
+ # First response with pagination token
221
+ mock_response_first = MagicMock()
222
+ mock_response_first.json.return_value = {
223
+ "entities": [
224
+ Entity(identifier="entity_1", blueprint="entity_1"),
225
+ Entity(identifier="entity_2", blueprint="entity_2"),
226
+ ],
227
+ "next": "next_page_token",
228
+ }
229
+ mock_response_first.is_error = False
230
+ mock_response_first.status_code = 200
231
+ mock_response_first.headers = {}
232
+
233
+ # Second response without pagination token (end of pagination)
234
+ mock_response_second = MagicMock()
235
+ mock_response_second.json.return_value = {
236
+ "entities": [Entity(identifier="entity_3", blueprint="entity_3")],
237
+ "next": None,
214
238
  }
215
- assert call_args[1]["json"] == expected_json
239
+ mock_response_second.is_error = False
240
+ mock_response_second.status_code = 200
241
+ mock_response_second.headers = {}
242
+
243
+ # Mock the client to return different responses for each call
244
+ entity_client.client.post = AsyncMock(side_effect=[mock_response_first, mock_response_second]) # type: ignore
245
+ entity_client.auth.headers = AsyncMock(return_value={"Authorization": "Bearer test"}) # type: ignore
246
+
247
+ entity_client.auth.integration_type = "test-integration"
248
+ entity_client.auth.integration_identifier = "test-identifier"
249
+ entity_client.auth.api_url = "https://api.getport.io/v1"
250
+
251
+ mock_user_agent_type = MagicMock()
252
+ mock_user_agent_type.value = "sync"
253
+
254
+ entities = await entity_client.search_entities(
255
+ user_agent_type=mock_user_agent_type,
256
+ query=None,
257
+ )
258
+
259
+ # Should call the datasource-entities endpoint exactly twice for pagination
260
+ assert entity_client.client.post.await_count == 2
261
+ assert len(entities) == 3
262
+
263
+ # Check first call
264
+ first_call_args = entity_client.client.post.call_args_list[0]
265
+ assert (
266
+ first_call_args[0][0]
267
+ == "https://api.getport.io/v1/blueprints/entities/datasource-entities"
268
+ )
269
+ first_sent_json = first_call_args[1]["json"]
270
+ assert first_sent_json["datasource_prefix"] == "port-ocean/test-integration/"
271
+ assert first_sent_json["datasource_suffix"] == "/test-identifier/sync"
272
+
273
+ # Check second call
274
+ second_call_args = entity_client.client.post.call_args_list[1]
275
+ assert (
276
+ second_call_args[0][0]
277
+ == "https://api.getport.io/v1/blueprints/entities/datasource-entities"
278
+ )
279
+ second_sent_json = second_call_args[1]["json"]
280
+ assert second_sent_json["datasource_prefix"] == "port-ocean/test-integration/"
281
+ assert second_sent_json["datasource_suffix"] == "/test-identifier/sync"
@@ -1,5 +1,5 @@
1
1
  import pytest
2
- from unittest.mock import Mock
2
+ from unittest.mock import Mock, AsyncMock, patch
3
3
  from http import HTTPStatus
4
4
  import httpx
5
5
 
@@ -307,3 +307,625 @@ 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 reads content 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
+ mock_response.read.return_value = b"test content"
436
+
437
+ transport._log_response_size(mock_request, mock_response)
438
+
439
+ mock_response.read.assert_called_once()
440
+ mock_logger.info.assert_called_once_with(
441
+ "Response for POST https://api.example.com/create - Size: 12 bytes"
442
+ )
443
+
444
+ @patch("port_ocean.helpers.retry.cast")
445
+ def test_log_response_size_read_error(self, mock_cast: Mock) -> None:
446
+ """Test _log_response_size handles read errors gracefully."""
447
+ mock_transport = Mock()
448
+ mock_logger = Mock()
449
+ mock_cast.return_value = mock_logger
450
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
451
+
452
+ mock_request = Mock()
453
+ mock_request.method = "GET"
454
+ mock_request.url.host = "api.example.com"
455
+
456
+ mock_response = Mock()
457
+ mock_response.headers = {}
458
+ mock_response.read.side_effect = Exception("Read error")
459
+
460
+ transport._log_response_size(mock_request, mock_response)
461
+
462
+ mock_logger.error.assert_called_once_with(
463
+ "Error getting response size: Read error"
464
+ )
465
+ mock_logger.info.assert_not_called()
466
+
467
+ @patch("port_ocean.helpers.retry.cast")
468
+ def test_log_response_size_skips_when_should_not_log(self, mock_cast: Mock) -> None:
469
+ """Test _log_response_size skips logging when _should_log_response_size returns False."""
470
+ mock_transport = Mock()
471
+ mock_logger = Mock()
472
+ mock_cast.return_value = mock_logger
473
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
474
+
475
+ mock_request = Mock()
476
+ mock_request.url.host = "api.getport.io" # This should skip logging
477
+
478
+ mock_response = Mock()
479
+ mock_response.headers = {"Content-Length": "1024"}
480
+
481
+ transport._log_response_size(mock_request, mock_response)
482
+
483
+ mock_logger.info.assert_not_called()
484
+
485
+ @pytest.mark.asyncio
486
+ @patch("port_ocean.helpers.retry.cast")
487
+ async def test_log_response_size_async_with_content_length(
488
+ self, mock_cast: Mock
489
+ ) -> None:
490
+ """Test _log_response_size_async logs when Content-Length header is present."""
491
+ mock_transport = Mock()
492
+ mock_logger = Mock()
493
+ mock_cast.return_value = mock_logger
494
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
495
+
496
+ mock_request = Mock()
497
+ mock_request.method = "GET"
498
+ mock_url = Mock()
499
+ mock_url.host = "api.example.com"
500
+ mock_url.configure_mock(__str__=lambda self: "https://api.example.com/data")
501
+ mock_request.url = mock_url
502
+
503
+ mock_response = Mock()
504
+ mock_response.headers = {"Content-Length": "1024"}
505
+
506
+ await transport._log_response_size_async(mock_request, mock_response)
507
+
508
+ mock_logger.info.assert_called_once_with(
509
+ "Response for GET https://api.example.com/data - Size: 1024 bytes"
510
+ )
511
+
512
+ @pytest.mark.asyncio
513
+ @patch("port_ocean.helpers.retry.cast")
514
+ async def test_log_response_size_async_without_content_length(
515
+ self, mock_cast: Mock
516
+ ) -> None:
517
+ """Test _log_response_size_async reads content when no Content-Length header."""
518
+ mock_transport = Mock()
519
+ mock_logger = Mock()
520
+ mock_cast.return_value = mock_logger
521
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
522
+
523
+ mock_request = Mock()
524
+ mock_request.method = "POST"
525
+ mock_url = Mock()
526
+ mock_url.host = "api.example.com"
527
+ mock_url.configure_mock(__str__=lambda self: "https://api.example.com/create")
528
+ mock_request.url = mock_url
529
+
530
+ mock_response = Mock()
531
+ mock_response.headers = {}
532
+ mock_response.aread = AsyncMock(return_value=b"test content")
533
+
534
+ await transport._log_response_size_async(mock_request, mock_response)
535
+
536
+ mock_response.aread.assert_called_once()
537
+ mock_logger.info.assert_called_once_with(
538
+ "Response for POST https://api.example.com/create - Size: 12 bytes"
539
+ )
540
+
541
+ @pytest.mark.asyncio
542
+ @patch("port_ocean.helpers.retry.cast")
543
+ async def test_log_response_size_async_read_error(self, mock_cast: Mock) -> None:
544
+ """Test _log_response_size_async handles read errors gracefully."""
545
+ mock_transport = Mock()
546
+ mock_logger = Mock()
547
+ mock_cast.return_value = mock_logger
548
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
549
+
550
+ mock_request = Mock()
551
+ mock_request.method = "GET"
552
+ mock_request.url.host = "api.example.com"
553
+
554
+ mock_response = Mock()
555
+ mock_response.headers = {}
556
+ mock_response.aread = AsyncMock(side_effect=Exception("Async read error"))
557
+
558
+ await transport._log_response_size_async(mock_request, mock_response)
559
+
560
+ mock_logger.error.assert_called_once_with(
561
+ "Error getting response size: Async read error"
562
+ )
563
+ mock_logger.info.assert_not_called()
564
+
565
+ @pytest.mark.asyncio
566
+ @patch("port_ocean.helpers.retry.cast")
567
+ async def test_log_response_size_async_skips_when_should_not_log(
568
+ self, mock_cast: Mock
569
+ ) -> None:
570
+ """Test _log_response_size_async skips logging when _should_log_response_size returns False."""
571
+ mock_transport = Mock()
572
+ mock_logger = Mock()
573
+ mock_cast.return_value = mock_logger
574
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
575
+
576
+ mock_request = Mock()
577
+ mock_request.url.host = "api.getport.io" # This should skip logging
578
+
579
+ mock_response = Mock()
580
+ mock_response.headers = {"Content-Length": "1024"}
581
+
582
+ await transport._log_response_size_async(mock_request, mock_response)
583
+
584
+ mock_logger.info.assert_not_called()
585
+
586
+ @pytest.mark.asyncio
587
+ @patch("port_ocean.helpers.retry.cast")
588
+ async def test_log_response_size_async_restores_content(
589
+ self, mock_cast: Mock
590
+ ) -> None:
591
+ """Test _log_response_size_async restores response content after reading."""
592
+ mock_transport = Mock()
593
+ mock_logger = Mock()
594
+ mock_cast.return_value = mock_logger
595
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
596
+
597
+ mock_request = Mock()
598
+ mock_request.method = "GET"
599
+ mock_url = Mock()
600
+ mock_url.host = "api.example.com"
601
+ mock_url.configure_mock(__str__=lambda self: "https://api.example.com/data")
602
+ mock_request.url = mock_url
603
+
604
+ test_content = b"test response content"
605
+ mock_response = Mock()
606
+ mock_response.headers = {}
607
+ mock_response.aread = AsyncMock(return_value=test_content)
608
+
609
+ await transport._log_response_size_async(mock_request, mock_response)
610
+
611
+ # Verify that the content was restored to the response
612
+ assert mock_response._content == test_content
613
+ mock_logger.info.assert_called_once_with(
614
+ "Response for GET https://api.example.com/data - Size: 21 bytes"
615
+ )
616
+
617
+ @patch("port_ocean.helpers.retry.cast")
618
+ def test_log_response_size_restores_content(self, mock_cast: Mock) -> None:
619
+ """Test _log_response_size restores response content after reading."""
620
+ mock_transport = Mock()
621
+ mock_logger = Mock()
622
+ mock_cast.return_value = mock_logger
623
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
624
+
625
+ mock_request = Mock()
626
+ mock_request.method = "GET"
627
+ mock_url = Mock()
628
+ mock_url.host = "api.example.com"
629
+ mock_url.configure_mock(__str__=lambda self: "https://api.example.com/data")
630
+ mock_request.url = mock_url
631
+
632
+ test_content = b"test response content"
633
+ mock_response = Mock()
634
+ mock_response.headers = {}
635
+ mock_response.read.return_value = test_content
636
+
637
+ transport._log_response_size(mock_request, mock_response)
638
+
639
+ # Verify that the content was restored to the response
640
+ assert mock_response._content == test_content
641
+ mock_logger.info.assert_called_once_with(
642
+ "Response for GET https://api.example.com/data - Size: 21 bytes"
643
+ )
644
+
645
+
646
+ class TestResponseSizeLoggingIntegration:
647
+ """Integration tests to verify response consumption works after size logging."""
648
+
649
+ def setup_method(self) -> None:
650
+ """Reset global callback state before each test."""
651
+ retry_module._RETRY_CONFIG_CALLBACK = None
652
+ retry_module._ON_RETRY_CALLBACK = None
653
+
654
+ @patch("port_ocean.helpers.retry.cast")
655
+ def test_log_response_size_preserves_json_consumption(
656
+ self, mock_cast: Mock
657
+ ) -> None:
658
+ """Test that _log_response_size preserves response for .json() consumption."""
659
+ mock_transport = Mock()
660
+ mock_logger = Mock()
661
+ mock_cast.return_value = mock_logger
662
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
663
+
664
+ mock_request = Mock()
665
+ mock_request.method = "GET"
666
+ mock_request.url.host = "api.example.com"
667
+
668
+ # Create a mock response with JSON content
669
+ json_content = b'{"message": "test", "data": [1, 2, 3]}'
670
+ mock_response = Mock()
671
+ mock_response.headers = {} # No Content-Length header to force content reading
672
+ mock_response.read.return_value = json_content
673
+ mock_response.json.return_value = {"message": "test", "data": [1, 2, 3]}
674
+
675
+ # Call the logging function
676
+ transport._log_response_size(mock_request, mock_response)
677
+
678
+ # Verify logging occurred
679
+ mock_logger.info.assert_called_once()
680
+
681
+ # Verify that response.json() can still be called without StreamConsumed error
682
+ result = mock_response.json()
683
+ assert result == {"message": "test", "data": [1, 2, 3]}
684
+
685
+ # Verify that read was called and content was restored
686
+ mock_response.read.assert_called_once()
687
+ assert mock_response._content == json_content
688
+
689
+ @patch("port_ocean.helpers.retry.cast")
690
+ def test_log_response_size_with_content_length_preserves_json(
691
+ self, mock_cast: Mock
692
+ ) -> None:
693
+ """Test that _log_response_size with Content-Length header preserves JSON consumption."""
694
+ mock_transport = Mock()
695
+ mock_logger = Mock()
696
+ mock_cast.return_value = mock_logger
697
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
698
+
699
+ mock_request = Mock()
700
+ mock_request.method = "POST"
701
+ mock_request.url.host = "api.example.com"
702
+
703
+ # Create a mock response with Content-Length header
704
+ mock_response = Mock()
705
+ mock_response.headers = {"Content-Length": "1024"}
706
+ mock_response.json.return_value = {"status": "success", "id": 123}
707
+
708
+ # Call the logging function
709
+ transport._log_response_size(mock_request, mock_response)
710
+
711
+ # Verify logging occurred
712
+ mock_logger.info.assert_called_once()
713
+
714
+ # Verify that response.json() can still be called
715
+ result = mock_response.json()
716
+ assert result == {"status": "success", "id": 123}
717
+
718
+ # Verify that read was NOT called since we had Content-Length
719
+ mock_response.read.assert_not_called()
720
+
721
+ @pytest.mark.asyncio
722
+ @patch("port_ocean.helpers.retry.cast")
723
+ async def test_log_response_size_async_preserves_json_consumption(
724
+ self, mock_cast: Mock
725
+ ) -> None:
726
+ """Test that _log_response_size_async preserves response for .json() consumption."""
727
+ mock_transport = Mock()
728
+ mock_logger = Mock()
729
+ mock_cast.return_value = mock_logger
730
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
731
+
732
+ mock_request = Mock()
733
+ mock_request.method = "GET"
734
+ mock_request.url.host = "api.example.com"
735
+
736
+ # Create a mock response with JSON content
737
+ json_content = b'{"users": [{"name": "John", "age": 30}]}'
738
+ mock_response = Mock()
739
+ mock_response.headers = {} # No Content-Length header to force content reading
740
+ mock_response.aread = AsyncMock(return_value=json_content)
741
+ mock_response.json.return_value = {"users": [{"name": "John", "age": 30}]}
742
+
743
+ # Call the async logging function
744
+ await transport._log_response_size_async(mock_request, mock_response)
745
+
746
+ # Verify logging occurred
747
+ mock_logger.info.assert_called_once()
748
+
749
+ # Verify that response.json() can still be called without StreamConsumed error
750
+ result = mock_response.json()
751
+ assert result == {"users": [{"name": "John", "age": 30}]}
752
+
753
+ # Verify that aread was called and content was restored
754
+ mock_response.aread.assert_called_once()
755
+ assert mock_response._content == json_content
756
+
757
+ @pytest.mark.asyncio
758
+ @patch("port_ocean.helpers.retry.cast")
759
+ async def test_log_response_size_async_with_content_length_preserves_json(
760
+ self, mock_cast: Mock
761
+ ) -> None:
762
+ """Test that _log_response_size_async with Content-Length header preserves JSON consumption."""
763
+ mock_transport = Mock()
764
+ mock_logger = Mock()
765
+ mock_cast.return_value = mock_logger
766
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
767
+
768
+ mock_request = Mock()
769
+ mock_request.method = "PUT"
770
+ mock_request.url.host = "api.example.com"
771
+
772
+ # Create a mock response with Content-Length header
773
+ mock_response = Mock()
774
+ mock_response.headers = {"Content-Length": "2048"}
775
+ mock_response.json.return_value = {
776
+ "updated": True,
777
+ "timestamp": "2023-12-01T12:00:00Z",
778
+ }
779
+
780
+ # Call the async logging function
781
+ await transport._log_response_size_async(mock_request, mock_response)
782
+
783
+ # Verify logging occurred
784
+ mock_logger.info.assert_called_once()
785
+
786
+ # Verify that response.json() can still be called
787
+ result = mock_response.json()
788
+ assert result == {"updated": True, "timestamp": "2023-12-01T12:00:00Z"}
789
+
790
+ # Verify that aread was NOT called since we had Content-Length
791
+ mock_response.aread.assert_not_called()
792
+
793
+ @patch("port_ocean.helpers.retry.cast")
794
+ def test_log_response_size_preserves_text_consumption(
795
+ self, mock_cast: Mock
796
+ ) -> None:
797
+ """Test that _log_response_size preserves response for .text consumption."""
798
+ mock_transport = Mock()
799
+ mock_logger = Mock()
800
+ mock_cast.return_value = mock_logger
801
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
802
+
803
+ mock_request = Mock()
804
+ mock_request.method = "GET"
805
+ mock_request.url.host = "api.example.com"
806
+
807
+ # Create a mock response with text content
808
+ text_content = b"Hello, World! This is a test response."
809
+ mock_response = Mock()
810
+ mock_response.headers = {} # No Content-Length header to force content reading
811
+ mock_response.read.return_value = text_content
812
+ mock_response.text = "Hello, World! This is a test response."
813
+
814
+ # Call the logging function
815
+ transport._log_response_size(mock_request, mock_response)
816
+
817
+ # Verify logging occurred
818
+ mock_logger.info.assert_called_once()
819
+
820
+ # Verify that response.text can still be accessed
821
+ assert mock_response.text == "Hello, World! This is a test response."
822
+
823
+ # Verify that read was called and content was restored
824
+ mock_response.read.assert_called_once()
825
+ assert mock_response._content == text_content
826
+
827
+ @pytest.mark.asyncio
828
+ @patch("port_ocean.helpers.retry.cast")
829
+ async def test_log_response_size_async_preserves_content_consumption(
830
+ self, mock_cast: Mock
831
+ ) -> None:
832
+ """Test that _log_response_size_async preserves response for .content consumption."""
833
+ mock_transport = Mock()
834
+ mock_logger = Mock()
835
+ mock_cast.return_value = mock_logger
836
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
837
+
838
+ mock_request = Mock()
839
+ mock_request.method = "GET"
840
+ mock_request.url.host = "api.example.com"
841
+
842
+ # Create a mock response with binary content
843
+ binary_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
844
+ mock_response = Mock()
845
+ mock_response.headers = {} # No Content-Length header to force content reading
846
+ mock_response.aread = AsyncMock(return_value=binary_content)
847
+ mock_response.content = binary_content
848
+
849
+ # Call the async logging function
850
+ await transport._log_response_size_async(mock_request, mock_response)
851
+
852
+ # Verify logging occurred
853
+ mock_logger.info.assert_called_once()
854
+
855
+ # Verify that response.content can still be accessed
856
+ assert mock_response.content == binary_content
857
+
858
+ # Verify that aread was called and content was restored
859
+ mock_response.aread.assert_called_once()
860
+ assert mock_response._content == binary_content
861
+
862
+ @patch("port_ocean.helpers.retry.cast")
863
+ def test_log_response_size_error_handling_preserves_response(
864
+ self, mock_cast: Mock
865
+ ) -> None:
866
+ """Test that _log_response_size error handling doesn't break response consumption."""
867
+ mock_transport = Mock()
868
+ mock_logger = Mock()
869
+ mock_cast.return_value = mock_logger
870
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
871
+
872
+ mock_request = Mock()
873
+ mock_request.method = "GET"
874
+ mock_request.url.host = "api.example.com"
875
+
876
+ # Create a mock response that will fail on read
877
+ mock_response = Mock()
878
+ mock_response.headers = {} # No Content-Length header to force content reading
879
+ mock_response.read.side_effect = Exception("Network error")
880
+ mock_response.json.return_value = {"error": "handled gracefully"}
881
+
882
+ # Call the logging function
883
+ transport._log_response_size(mock_request, mock_response)
884
+
885
+ # Verify error was logged
886
+ mock_logger.error.assert_called_once_with(
887
+ "Error getting response size: Network error"
888
+ )
889
+
890
+ # Verify that response.json() can still be called despite the error
891
+ result = mock_response.json()
892
+ assert result == {"error": "handled gracefully"}
893
+
894
+ # Verify that read was attempted
895
+ mock_response.read.assert_called_once()
896
+
897
+ @pytest.mark.asyncio
898
+ @patch("port_ocean.helpers.retry.cast")
899
+ async def test_log_response_size_async_error_handling_preserves_response(
900
+ self, mock_cast: Mock
901
+ ) -> None:
902
+ """Test that _log_response_size_async error handling doesn't break response consumption."""
903
+ mock_transport = Mock()
904
+ mock_logger = Mock()
905
+ mock_cast.return_value = mock_logger
906
+ transport = RetryTransport(wrapped_transport=mock_transport, logger=mock_logger)
907
+
908
+ mock_request = Mock()
909
+ mock_request.method = "GET"
910
+ mock_request.url.host = "api.example.com"
911
+
912
+ # Create a mock response that will fail on aread
913
+ mock_response = Mock()
914
+ mock_response.headers = {} # No Content-Length header to force content reading
915
+ mock_response.aread = AsyncMock(side_effect=Exception("Async network error"))
916
+ mock_response.json.return_value = {"error": "handled gracefully"}
917
+
918
+ # Call the async logging function
919
+ await transport._log_response_size_async(mock_request, mock_response)
920
+
921
+ # Verify error was logged
922
+ mock_logger.error.assert_called_once_with(
923
+ "Error getting response size: Async network error"
924
+ )
925
+
926
+ # Verify that response.json() can still be called despite the error
927
+ result = mock_response.json()
928
+ assert result == {"error": "handled gracefully"}
929
+
930
+ # Verify that aread was attempted
931
+ mock_response.aread.assert_called_once()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: port-ocean
3
- Version: 0.28.15
3
+ Version: 0.28.17
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
@@ -60,7 +60,7 @@ port_ocean/clients/port/authentication.py,sha256=ZO1Vw1nm-NlVUPPtPS5O4GGDvRmyS3v
60
60
  port_ocean/clients/port/client.py,sha256=hBXgU0CDseN2F-vn20JqowfVkcd6oSVmYrjn6t4TI6c,3616
61
61
  port_ocean/clients/port/mixins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
62
  port_ocean/clients/port/mixins/blueprints.py,sha256=aMCG4zePsMSMjMLiGrU37h5z5_ElfMzTcTvqvOI5wXY,4683
63
- port_ocean/clients/port/mixins/entities.py,sha256=X2NqH00eK6TMJ3a3QEQRVQlKHzyj5l1FiPkIhonnbPg,24234
63
+ port_ocean/clients/port/mixins/entities.py,sha256=UiVssYYqJeHhrLahx1mW24B7oGVMZV2WVvUze_htuBk,25279
64
64
  port_ocean/clients/port/mixins/integrations.py,sha256=8EhGms1_iOaAPEexmHGF4PJaSSL4O09GtXr_Q8UyaJI,12049
65
65
  port_ocean/clients/port/mixins/migrations.py,sha256=vdL_A_NNUogvzujyaRLIoZEu5vmKDY2BxTjoGP94YzI,1467
66
66
  port_ocean/clients/port/mixins/organization.py,sha256=A2cP5V49KnjoAXxjmnm_XGth4ftPSU0qURNfnyUyS_Y,1041
@@ -144,7 +144,7 @@ port_ocean/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuF
144
144
  port_ocean/helpers/async_client.py,sha256=M8gKUjX8ZwRbmJ-U6KNq-p-nfGr0CwHdS0eN_pbZAJ0,2103
145
145
  port_ocean/helpers/metric/metric.py,sha256=6SMxov1WcZAV0NehMGMqWiLoOIpw-2fOpVbtPWhmW1c,14544
146
146
  port_ocean/helpers/metric/utils.py,sha256=1lAgrxnZLuR_wUNDyPOPzLrm32b8cDdioob2lvnPQ1A,1619
147
- port_ocean/helpers/retry.py,sha256=QM04mzaevIUlg8HnHjeY9UT_D4k26BHx3hVkCjV_jnY,21675
147
+ port_ocean/helpers/retry.py,sha256=fkKL_dSNKbGLpa9qi3Ceu2yCqcnzC0OV3dcEWo_kPHA,22067
148
148
  port_ocean/helpers/stream.py,sha256=_UwsThzXynxWzL8OlBT1pmb2evZBi9HaaqeAGNuTuOI,2338
149
149
  port_ocean/log/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
150
150
  port_ocean/log/handlers.py,sha256=LJ1WAfq7wYCrBpeLPihMKmWjdSahKKXNHFMRYkbk0Co,3630
@@ -162,7 +162,7 @@ port_ocean/tests/cache/test_memory_cache.py,sha256=xlwIOBU0RVLYYJU83l_aoZDzZ6QID
162
162
  port_ocean/tests/clients/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
163
163
  port_ocean/tests/clients/oauth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
164
164
  port_ocean/tests/clients/oauth/test_oauth_client.py,sha256=2XVMQUalDpiD539Z7_dk5BK_ngXQzsTmb2lNBsfEm9c,3266
165
- port_ocean/tests/clients/port/mixins/test_entities.py,sha256=_ZEQT7UMzg1gW8kH8oFjgRVcLwQb7dFac48Tw_vcCqk,8018
165
+ port_ocean/tests/clients/port/mixins/test_entities.py,sha256=jOMJ3ICUlhjjPTo4q6qUrEjTKvXRLUE6KjqjdFiDRBY,10766
166
166
  port_ocean/tests/clients/port/mixins/test_integrations.py,sha256=vRt_EMsLozQC1LJNXxlvnHj3-FlOBGgAYxg5T0IAqtA,7621
167
167
  port_ocean/tests/clients/port/mixins/test_organization_mixin.py,sha256=zzKYz3h8dl4Z5A2QG_924m0y9U6XTth1XYOfwNrd_24,914
168
168
  port_ocean/tests/config/test_config.py,sha256=Rk4N-ldVSOfn1p23NzdVdfqUpPrqG2cMut4Sv-sAOrw,1023
@@ -193,7 +193,7 @@ port_ocean/tests/helpers/integration.py,sha256=_RxS-RHpu11lrbhUXYPZp862HLWx8AoD7
193
193
  port_ocean/tests/helpers/ocean_app.py,sha256=N06vcNI1klqdcNFq-PXL5vm77u-hODsOSXnj9p8d1AI,2249
194
194
  port_ocean/tests/helpers/port_client.py,sha256=S0CXvZWUoHFWWQUOEgdkDammK9Fs3R06wx0flaMrTsg,647
195
195
  port_ocean/tests/helpers/smoke_test.py,sha256=_9aJJFRfuGJEg2D2YQJVJRmpreS6gEPHHQq8Q01x4aQ,2697
196
- port_ocean/tests/helpers/test_retry.py,sha256=c4kS5XrQNv8r-uqPLbWVGvudTy3b_dHxhcCjm7ZWeAQ,11033
196
+ port_ocean/tests/helpers/test_retry.py,sha256=4wuacabVZTGOICCd0dHlVklhLytsCGu2zjo8hnqEeEs,35912
197
197
  port_ocean/tests/log/test_handlers.py,sha256=x2P2Hd6Cb3sQafIE3TRGltbbHeiFHaiEjwRn9py_03g,2165
198
198
  port_ocean/tests/test_metric.py,sha256=gDdeJcqJDQ_o3VrYrW23iZyw2NuUsyATdrygSXhcDuQ,8096
199
199
  port_ocean/tests/test_ocean.py,sha256=bsXKGTVEjwLSbR7-qSmI4GZ-EzDo0eBE3TNSMsWzYxM,1502
@@ -211,8 +211,8 @@ port_ocean/utils/repeat.py,sha256=U2OeCkHPWXmRTVoPV-VcJRlQhcYqPWI5NfmPlb1JIbc,32
211
211
  port_ocean/utils/signal.py,sha256=J1sI-e_32VHP_VUa5bskLMFoJjJOAk5isrnewKDikUI,2125
212
212
  port_ocean/utils/time.py,sha256=pufAOH5ZQI7gXvOvJoQXZXZJV-Dqktoj9Qp9eiRwmJ4,1939
213
213
  port_ocean/version.py,sha256=UsuJdvdQlazzKGD3Hd5-U7N69STh8Dq9ggJzQFnu9fU,177
214
- port_ocean-0.28.15.dist-info/LICENSE.md,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
215
- port_ocean-0.28.15.dist-info/METADATA,sha256=aayam8J_hnYiAJuesIJZy_Y13XcnfYjtg21DM0-Znt8,7016
216
- port_ocean-0.28.15.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
217
- port_ocean-0.28.15.dist-info/entry_points.txt,sha256=F_DNUmGZU2Kme-8NsWM5LLE8piGMafYZygRYhOVtcjA,54
218
- port_ocean-0.28.15.dist-info/RECORD,,
214
+ port_ocean-0.28.17.dist-info/LICENSE.md,sha256=WNHhf_5RCaeuKWyq_K39vmp9F28LxKsB4SpomwSZ2L0,11357
215
+ port_ocean-0.28.17.dist-info/METADATA,sha256=rHk9ILkvieFzWeeX1dljjZ0wfjN9EPX9NDl5tLwOFDc,7016
216
+ port_ocean-0.28.17.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
217
+ port_ocean-0.28.17.dist-info/entry_points.txt,sha256=F_DNUmGZU2Kme-8NsWM5LLE8piGMafYZygRYhOVtcjA,54
218
+ port_ocean-0.28.17.dist-info/RECORD,,