From d2c4de8ff0e3b6b9685ab16d4507ca2b4c14dfc7 Mon Sep 17 00:00:00 2001 From: Zdenek Kraus Date: Fri, 10 Apr 2026 13:39:43 +0200 Subject: [PATCH 1/5] FIX(api-client) Handle ReportPortal API timestamp format change ReportPortal API changed from Unix milliseconds to ISO format strings. Added normalization layer in API client to support both formats for backward compatibility. Co-Authored-By: Claude Sonnet 4.5 fixes #7 Signed-off-by: Zdenek Kraus --- src/reportportal/rp_api_client.py | 77 ++++++++++++++++++--- tests/unit/test_rp_api_client.py | 110 ++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_rp_api_client.py diff --git a/src/reportportal/rp_api_client.py b/src/reportportal/rp_api_client.py index c383e7d..d86e429 100644 --- a/src/reportportal/rp_api_client.py +++ b/src/reportportal/rp_api_client.py @@ -7,6 +7,7 @@ import requests from typing import List, Dict, Optional, Any +from datetime import datetime from loguru import logger @@ -64,6 +65,59 @@ def build_headers(self) -> Dict[str, str]: 'accept': '*/*' } + @staticmethod + def normalize_timestamp(timestamp: Optional[Any]) -> Optional[int]: + """ + Normalize timestamp to Unix milliseconds (integer). + + ReportPortal API changed from returning Unix milliseconds to ISO format strings. + This method handles both formats for backward compatibility. + + Args: + timestamp: Either ISO format string or Unix milliseconds (int/float) + + Returns: + Unix timestamp in milliseconds as integer, or None if input is None + """ + if timestamp is None: + return None + + # Already in Unix milliseconds format + if isinstance(timestamp, (int, float)): + return int(timestamp) + + # ISO format string - convert to Unix milliseconds + if isinstance(timestamp, str): + try: + dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + return int(dt.timestamp() * 1000) + except (ValueError, AttributeError) as e: + logger.warning(f"Failed to parse timestamp '{timestamp}': {e}") + return None + + return None + + def normalize_timestamps_in_dict(self, data: Dict[str, Any], + timestamp_fields: List[str] = None) -> Dict[str, Any]: + """ + Normalize timestamp fields in a dictionary. + + Args: + data: Dictionary potentially containing timestamp fields + timestamp_fields: List of field names to normalize (default: startTime, endTime) + + Returns: + Dictionary with normalized timestamps + """ + if timestamp_fields is None: + timestamp_fields = ['startTime', 'endTime'] + + for field in timestamp_fields: + if field in data: + data[field] = self.normalize_timestamp(data[field]) + + return data + def build_url(self, resource: str, **params) -> str: """ Build ReportPortal API URL with query parameters. @@ -243,7 +297,7 @@ def get_launches(self, filters: Additional filters as dict (e.g., {'filter.eq.name': 'test'}) Returns: - List of launch dictionaries + List of launch dictionaries with normalized timestamps (Unix milliseconds) Raises: ReportPortalAPIError: On API errors @@ -261,6 +315,9 @@ def get_launches(self, data = self._get(endpoint) launches = data.get('content', []) + # Normalize timestamps in all launches + launches = [self.normalize_timestamps_in_dict(launch) for launch in launches] + logger.debug(f"Retrieved {len(launches)} launches") return launches @@ -272,14 +329,15 @@ def get_launch_by_id(self, launch_id: str) -> Dict: launch_id: Launch UUID Returns: - Launch dictionary + Launch dictionary with normalized timestamps (Unix milliseconds) Raises: ReportPortalAPIError: On API errors """ logger.debug(f"Fetching launch {launch_id}") endpoint = f'/api/v1/{self.project}/launch/{launch_id}' - return self._get(endpoint) + launch = self._get(endpoint) + return self.normalize_timestamps_in_dict(launch) # ========================================================================= # Test Item Operations @@ -298,7 +356,7 @@ def get_test_items(self, filters: Additional filters (e.g., {'filter.eq.status': 'FAILED'}) Returns: - List of test item dictionaries + List of test item dictionaries with normalized timestamps (Unix milliseconds) Raises: ReportPortalAPIError: On API errors @@ -319,6 +377,9 @@ def get_test_items(self, data = self._get(endpoint) items = data.get('content', []) + # Normalize timestamps in all test items + items = [self.normalize_timestamps_in_dict(item) for item in items] + logger.debug(f"Retrieved {len(items)} test items") return items @@ -330,14 +391,15 @@ def get_test_item_by_id(self, item_id: str) -> Dict: item_id: Test item UUID Returns: - Test item dictionary + Test item dictionary with normalized timestamps (Unix milliseconds) Raises: ReportPortalAPIError: On API errors """ logger.debug(f"Fetching test item {item_id}") endpoint = f'/api/v1/{self.project}/item/{item_id}' - return self._get(endpoint) + item = self._get(endpoint) + return self.normalize_timestamps_in_dict(item) # ========================================================================= # Analysis Operations @@ -391,9 +453,8 @@ def create_api_client(url: str, project: str, token: str) -> ReportPortalAPIClie url: ReportPortal URL project: ReportPortal project name token: ReportPortal API token - logger: Optional logger instance Returns: Configured ReportPortalAPIClient instance """ - return ReportPortalAPIClient(url, project, token, logger=logger) + return ReportPortalAPIClient(url, project, token) diff --git a/tests/unit/test_rp_api_client.py b/tests/unit/test_rp_api_client.py new file mode 100644 index 0000000..2a54140 --- /dev/null +++ b/tests/unit/test_rp_api_client.py @@ -0,0 +1,110 @@ +""" +Unit tests for ReportPortal API Client timestamp normalization. +""" + +import pytest +from reportportal.rp_api_client import ReportPortalAPIClient + + +class TestTimestampNormalization: + """Test timestamp normalization for API format changes.""" + + def test_normalize_timestamp_from_integer(self): + """Test normalizing Unix milliseconds (old format).""" + timestamp_ms = 1640000000000 + result = ReportPortalAPIClient.normalize_timestamp(timestamp_ms) + assert result == 1640000000000 + assert isinstance(result, int) + + def test_normalize_timestamp_from_float(self): + """Test normalizing Unix milliseconds as float.""" + timestamp_ms = 1640000000000.0 + result = ReportPortalAPIClient.normalize_timestamp(timestamp_ms) + assert result == 1640000000000 + assert isinstance(result, int) + + def test_normalize_timestamp_from_iso_string(self): + """Test normalizing ISO format string (new format).""" + # Without timezone, assumes local time + iso_timestamp = "2021-12-20T13:33:20" + result = ReportPortalAPIClient.normalize_timestamp(iso_timestamp) + # Verify it returns a valid integer timestamp + assert isinstance(result, int) + assert result > 0 + + def test_normalize_timestamp_from_iso_string_with_z(self): + """Test normalizing ISO format string with Z suffix.""" + # 2021-12-20 13:33:20 UTC = 1640007200000ms + iso_timestamp = "2021-12-20T13:33:20Z" + result = ReportPortalAPIClient.normalize_timestamp(iso_timestamp) + assert result == 1640007200000 + assert isinstance(result, int) + + def test_normalize_timestamp_from_iso_string_with_timezone(self): + """Test normalizing ISO format string with timezone.""" + # 2021-12-20 13:33:20 UTC = 1640007200000ms + iso_timestamp = "2021-12-20T13:33:20+00:00" + result = ReportPortalAPIClient.normalize_timestamp(iso_timestamp) + assert result == 1640007200000 + assert isinstance(result, int) + + def test_normalize_timestamp_none(self): + """Test normalizing None timestamp.""" + result = ReportPortalAPIClient.normalize_timestamp(None) + assert result is None + + def test_normalize_timestamp_invalid_string(self): + """Test normalizing invalid timestamp string.""" + result = ReportPortalAPIClient.normalize_timestamp("invalid") + assert result is None + + def test_normalize_timestamps_in_dict(self): + """Test normalizing timestamps in a dictionary.""" + client = ReportPortalAPIClient("http://example.com", "project", "token") + + data = { + 'id': 'launch-123', + 'name': 'Test Launch', + 'startTime': '2021-12-20T13:33:20+00:00', + 'endTime': 1640010000000, # Mixed formats + 'other_field': 'value' + } + + result = client.normalize_timestamps_in_dict(data) + + assert result['id'] == 'launch-123' + assert result['name'] == 'Test Launch' + assert result['startTime'] == 1640007200000 # Converted from ISO + assert result['endTime'] == 1640010000000 # Already in milliseconds + assert result['other_field'] == 'value' + + def test_normalize_timestamps_custom_fields(self): + """Test normalizing custom timestamp fields.""" + client = ReportPortalAPIClient("http://example.com", "project", "token") + + data = { + 'createdAt': '2021-12-20T13:33:20+00:00', + 'updatedAt': '2021-12-20T14:33:20+00:00', + } + + result = client.normalize_timestamps_in_dict(data, ['createdAt', 'updatedAt']) + + assert result['createdAt'] == 1640007200000 + assert result['updatedAt'] == 1640010800000 + + def test_normalize_timestamps_missing_fields(self): + """Test normalizing with missing timestamp fields.""" + client = ReportPortalAPIClient("http://example.com", "project", "token") + + data = { + 'id': 'launch-123', + 'name': 'Test Launch', + } + + # Should not raise error if fields don't exist + result = client.normalize_timestamps_in_dict(data) + + assert result['id'] == 'launch-123' + assert result['name'] == 'Test Launch' + assert 'startTime' not in result + assert 'endTime' not in result From bf9268bdc308a0c98abbe19c472aa70c73833884 Mon Sep 17 00:00:00 2001 From: Zdenek Kraus Date: Fri, 10 Apr 2026 14:36:09 +0200 Subject: [PATCH 2/5] fixup! FIX(api-client) Handle ReportPortal API timestamp format change Signed-off-by: Zdenek Kraus --- src/reportportal/rp_api_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reportportal/rp_api_client.py b/src/reportportal/rp_api_client.py index d86e429..87cfa40 100644 --- a/src/reportportal/rp_api_client.py +++ b/src/reportportal/rp_api_client.py @@ -98,7 +98,7 @@ def normalize_timestamp(timestamp: Optional[Any]) -> Optional[int]: return None def normalize_timestamps_in_dict(self, data: Dict[str, Any], - timestamp_fields: List[str] = None) -> Dict[str, Any]: + timestamp_fields: Optional[List[str]] = None) -> Dict[str, Any]: """ Normalize timestamp fields in a dictionary. From e9c0121fe1568c03fbcbcd23b7b7d0d8c9bf6ed6 Mon Sep 17 00:00:00 2001 From: Zdenek Kraus Date: Mon, 13 Apr 2026 12:52:29 +0200 Subject: [PATCH 3/5] fixup! fixup! FIX(api-client) Handle ReportPortal API timestamp format change Signed-off-by: Zdenek Kraus --- src/reportportal/rp_api_client.py | 3 ++- tests/unit/test_rp_api_client.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reportportal/rp_api_client.py b/src/reportportal/rp_api_client.py index 87cfa40..5526ac4 100644 --- a/src/reportportal/rp_api_client.py +++ b/src/reportportal/rp_api_client.py @@ -92,9 +92,10 @@ def normalize_timestamp(timestamp: Optional[Any]) -> Optional[int]: dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) return int(dt.timestamp() * 1000) except (ValueError, AttributeError) as e: - logger.warning(f"Failed to parse timestamp '{timestamp}': {e}") + logger.warning("Failed to parse timestamp '%s': %s", timestamp, e) return None + logger.warning("Timestamp was unexpected type: %s", type(timestamp)) return None def normalize_timestamps_in_dict(self, data: Dict[str, Any], diff --git a/tests/unit/test_rp_api_client.py b/tests/unit/test_rp_api_client.py index 2a54140..4d69c2f 100644 --- a/tests/unit/test_rp_api_client.py +++ b/tests/unit/test_rp_api_client.py @@ -2,7 +2,6 @@ Unit tests for ReportPortal API Client timestamp normalization. """ -import pytest from reportportal.rp_api_client import ReportPortalAPIClient From fee91c08b2d8dfd274d02acc7294369d88396b15 Mon Sep 17 00:00:00 2001 From: Zdenek Kraus Date: Thu, 16 Apr 2026 13:55:53 +0200 Subject: [PATCH 4/5] fixup! fixup! fixup! FIX(api-client) Handle ReportPortal API timestamp format change Signed-off-by: Zdenek Kraus --- src/reportportal/rp_api_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reportportal/rp_api_client.py b/src/reportportal/rp_api_client.py index 5526ac4..ec5f91d 100644 --- a/src/reportportal/rp_api_client.py +++ b/src/reportportal/rp_api_client.py @@ -92,10 +92,10 @@ def normalize_timestamp(timestamp: Optional[Any]) -> Optional[int]: dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) return int(dt.timestamp() * 1000) except (ValueError, AttributeError) as e: - logger.warning("Failed to parse timestamp '%s': %s", timestamp, e) + logger.warning("Failed to parse timestamp '{}': {}", timestamp, e) return None - logger.warning("Timestamp was unexpected type: %s", type(timestamp)) + logger.warning("Timestamp was unexpected type: {}", type(timestamp)) return None def normalize_timestamps_in_dict(self, data: Dict[str, Any], From 5585a75feca90ad8243897bba19fec671e03a701 Mon Sep 17 00:00:00 2001 From: Zdenek Kraus Date: Thu, 16 Apr 2026 15:17:03 +0200 Subject: [PATCH 5/5] fixup! fixup! fixup! fixup! FIX(api-client) Handle ReportPortal API timestamp format change Signed-off-by: Zdenek Kraus --- src/reportportal/rp_api_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/reportportal/rp_api_client.py b/src/reportportal/rp_api_client.py index ec5f91d..4252846 100644 --- a/src/reportportal/rp_api_client.py +++ b/src/reportportal/rp_api_client.py @@ -46,7 +46,6 @@ def __init__(self, url: str, project: str, token: str): url: ReportPortal URL (e.g., https://reportportal.example.com) project: ReportPortal project name token: ReportPortal API token - logger: Optional logger instance (if not provided, creates a default logger) """ self.url = url self.project = project