diff --git a/qase-python-commons/changelog.md b/qase-python-commons/changelog.md index bdac7362..39775dad 100644 --- a/qase-python-commons/changelog.md +++ b/qase-python-commons/changelog.md @@ -1,4 +1,10 @@ -# qase-python-commons@4.2.1 +# qase-python-commons@4.1.2 + +## What's new + +- Fixed compatibility with time mocking libraries like `freezegun`. When tests use `freezegun` to mock time, Qase reporters now correctly report real timestamps instead of mocked ones. This prevents "Data is invalid" errors from Qase API when execution timestamps are in the past. Resolves [#415](https://github.com/qase-tms/qase-python/issues/415). + +# qase-python-commons@4.1.1 ## What's new diff --git a/qase-python-commons/pyproject.toml b/qase-python-commons/pyproject.toml index 4ebde19f..936c7354 100644 --- a/qase-python-commons/pyproject.toml +++ b/qase-python-commons/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qase-python-commons" -version = "4.1.1" +version = "4.1.2" description = "A library for Qase TestOps and Qase Report" readme = "README.md" authors = [{name = "Qase Team", email = "support@qase.io"}] @@ -42,6 +42,7 @@ testing = [ "more_itertools", "requests", "urllib3", + "freezegun", ] [tool.tox] diff --git a/qase-python-commons/src/qase/commons/models/result.py b/qase-python-commons/src/qase/commons/models/result.py index 396ef3ec..804967ff 100644 --- a/qase-python-commons/src/qase/commons/models/result.py +++ b/qase-python-commons/src/qase/commons/models/result.py @@ -25,7 +25,7 @@ def __init__(self, stacktrace: Optional[str] = None, thread: Optional[str] = QaseUtils.get_thread_name() ): - self.start_time = time.time() + self.start_time = QaseUtils.get_real_time() self.status = status self.end_time = end_time self.duration = duration @@ -48,7 +48,7 @@ def get_status(self): return self.status def complete(self): - self.end_time = time.time() + self.end_time = QaseUtils.get_real_time() self.duration = (int)((self.end_time - self.start_time) * 1000) diff --git a/qase-python-commons/src/qase/commons/models/step.py b/qase-python-commons/src/qase/commons/models/step.py index 5f1a0eec..13e27f66 100644 --- a/qase-python-commons/src/qase/commons/models/step.py +++ b/qase-python-commons/src/qase/commons/models/step.py @@ -5,6 +5,7 @@ from typing import Optional, Union, Dict, List, Type from .attachment import Attachment from .basemodel import BaseModel +from .. import QaseUtils class StepType(Enum): @@ -95,7 +96,7 @@ def __init__(self, duration: int): class StepExecution(BaseModel): def __init__(self, status: Optional[str] = 'untested', end_time: int = 0, duration: int = 0): - self.start_time = time.time() + self.start_time = QaseUtils.get_real_time() self.status = status self.end_time = end_time self.duration = duration @@ -108,7 +109,7 @@ def set_status(self, status: Optional[str]): raise ValueError('Step status must be one of: passed, failed, skipped, blocked, untested, invalid') def complete(self): - self.end_time = time.time() + self.end_time = QaseUtils.get_real_time() self.duration = int((self.end_time - self.start_time) * 1000) def add_attachment(self, attachment: Attachment): diff --git a/qase-python-commons/src/qase/commons/reporters/report.py b/qase-python-commons/src/qase/commons/reporters/report.py index a2134fe9..cb5d79e1 100644 --- a/qase-python-commons/src/qase/commons/reporters/report.py +++ b/qase-python-commons/src/qase/commons/reporters/report.py @@ -31,10 +31,10 @@ def __init__( def start_run(self): self._check_report_path() - self.start_time = str(time.time()) + self.start_time = str(QaseUtils.get_real_time()) def complete_run(self): - self.end_time = str(time.time()) + self.end_time = str(QaseUtils.get_real_time()) self._compile_report() def complete_worker(self): diff --git a/qase-python-commons/src/qase/commons/utils.py b/qase-python-commons/src/qase/commons/utils.py index d9b44789..55f52451 100644 --- a/qase-python-commons/src/qase/commons/utils.py +++ b/qase-python-commons/src/qase/commons/utils.py @@ -6,10 +6,88 @@ import pip import string import uuid +import time class QaseUtils: + @staticmethod + def get_real_time() -> float: + """ + Get real system time, bypassing time mocking libraries like freezegun. + + This is necessary when reporting test results to external systems that validate + timestamps against current time, even when tests are using time mocking. + + Returns: + float: Current Unix timestamp in seconds with microsecond precision + """ + # Try to get the original time function if it was wrapped by freezegun + # freezegun stores the original function in __wrapped__ attribute + if hasattr(time.time, '__wrapped__'): + return time.time.__wrapped__() + + # Fallback: use direct system call via ctypes + # This works on Unix-like systems and Windows + try: + import ctypes + import ctypes.util + + if sys.platform == 'win32': + # Windows: use GetSystemTimeAsFileTime + class FILETIME(ctypes.Structure): + _fields_ = [("dwLowDateTime", ctypes.c_uint32), + ("dwHighDateTime", ctypes.c_uint32)] + + kernel32 = ctypes.windll.kernel32 + ft = FILETIME() + kernel32.GetSystemTimeAsFileTime(ctypes.byref(ft)) + + # Convert FILETIME to Unix timestamp + # FILETIME is 100-nanosecond intervals since January 1, 1601 + timestamp = (ft.dwHighDateTime << 32) + ft.dwLowDateTime + # Convert to seconds and adjust epoch (1601 -> 1970) + return (timestamp / 10000000.0) - 11644473600.0 + else: + # Unix-like systems: use gettimeofday for microsecond precision + # Try multiple approaches to find libc + libc = None + + # Method 1: Use find_library (works on most systems) + libc_path = ctypes.util.find_library('c') + if libc_path: + try: + libc = ctypes.CDLL(libc_path) + except OSError: + pass + + # Method 2: Try common library names directly (for Alpine Linux, musl libc, etc.) + if libc is None: + for lib_name in ['libc.so.6', 'libc.so', 'libc.dylib']: + try: + libc = ctypes.CDLL(lib_name) + break + except OSError: + continue + + if libc is None: + raise OSError("Could not load C library") + + class timeval(ctypes.Structure): + _fields_ = [("tv_sec", ctypes.c_long), + ("tv_usec", ctypes.c_long)] + + tv = timeval() + libc.gettimeofday(ctypes.byref(tv), None) + + return float(tv.tv_sec) + (float(tv.tv_usec) / 1000000.0) + except Exception: + # Last resort: return the potentially mocked time + # This will still work in normal cases without freezegun + # If freezegun is active, the user might see timestamp validation errors + # but the core functionality will continue to work + return time.time() + @staticmethod def build_tree(items): nodes = {item.id: item for item in items} diff --git a/qase-python-commons/tests/tests_qase_commons/test_freezegun_integration.py b/qase-python-commons/tests/tests_qase_commons/test_freezegun_integration.py new file mode 100644 index 00000000..892ea2f9 --- /dev/null +++ b/qase-python-commons/tests/tests_qase_commons/test_freezegun_integration.py @@ -0,0 +1,190 @@ +""" +Integration tests for freezegun compatibility. + +These tests verify that Qase reporters work correctly when tests use freezegun +to mock time. The issue is that freezegun mocks time.time(), which affects +execution timestamps, but Qase API requires real timestamps. +""" + +import time +import pytest + +try: + from freezegun import freeze_time + FREEZEGUN_AVAILABLE = True +except ImportError: + FREEZEGUN_AVAILABLE = False + +from qase.commons.models.result import Execution, Result +from qase.commons.models.step import Step, StepType, StepTextData + + +@pytest.mark.skipif(not FREEZEGUN_AVAILABLE, reason="freezegun not installed") +class TestFreezegunIntegration: + """Test that Qase works correctly with freezegun""" + + def test_execution_timestamps_with_freezegun(self): + """ + Test that Execution timestamps are real time even when freezegun is active. + + This reproduces the issue from GitHub issue #415 where users get + "Data is invalid" errors when using freezegun with Qase steps. + """ + # Get current real time + real_time_before = time.time() + + # Freeze time to 2023-02-01 + with freeze_time("2023-02-01"): + # time.time() returns frozen time + frozen_time = time.time() + assert frozen_time < 1700000000, "Time should be frozen in the past" + + # Create execution (this is what happens when test starts) + execution = Execution() + + # Small delay to ensure end_time > start_time + time.sleep(0.1) # This is also mocked by freezegun + + # Complete execution + execution.complete() + + # Verify that execution times are real (not frozen) + # They should be close to current time, not 2023-02-01 + assert execution.start_time > frozen_time + 60000000, \ + f"start_time {execution.start_time} should be real time, not frozen time {frozen_time}" + + assert execution.end_time > frozen_time + 60000000, \ + f"end_time {execution.end_time} should be real time, not frozen time {frozen_time}" + + assert execution.start_time >= real_time_before, \ + f"start_time {execution.start_time} should be >= {real_time_before}" + + # Duration should be positive + assert execution.duration >= 0, \ + f"duration {execution.duration} should be non-negative" + + # Verify timestamps would be accepted by Qase API + # (API requires timestamps >= current time - some tolerance) + current_timestamp = 1760383058 # Example from error message + # Our timestamps should be much greater than this example from Oct 2025 + # Since we're running tests later + assert execution.start_time > 1700000000, \ + f"Timestamp {execution.start_time} should be valid for Qase API" + + def test_step_execution_with_freezegun(self): + """ + Test that Step execution timestamps are real time when freezegun is active. + """ + real_time_before = time.time() + + with freeze_time("2023-02-01"): + frozen_time = time.time() + + # Create a step (this is what happens with qase.step()) + step = Step( + step_type=StepType.TEXT, + id="test-step-1", + data=StepTextData(action="Test action") + ) + + # Simulate some work + time.sleep(0.01) + + # Complete the step + step.execution.complete() + + # Verify timestamps are real + assert step.execution.start_time > frozen_time + 60000000, \ + "step start_time should be real time" + + assert step.execution.end_time > frozen_time + 60000000, \ + "step end_time should be real time" + + assert step.execution.start_time >= real_time_before, \ + "step start_time should be >= real time before test" + + assert step.execution.duration >= 0, \ + "step duration should be non-negative" + + def test_result_with_steps_freezegun(self): + """ + Test complete Result with steps when freezegun is active. + + This simulates the exact scenario from the user's issue. + """ + with freeze_time("2023-02-01"): + frozen_time = time.time() + + # Create result + result = Result(title="Test with freezegun", signature="test-sig") + + # Simulate test execution with step + step = Step( + step_type=StepType.TEXT, + id="step-1", + data=StepTextData(action="Doing something") + ) + + time.sleep(0.01) + step.execution.complete() + + result.steps.append(step) + result.execution.set_status("passed") + result.execution.complete() + + # Verify all timestamps are real (not frozen) + assert result.execution.start_time > frozen_time + 60000000, \ + "result start_time should not be frozen" + + assert result.execution.end_time > frozen_time + 60000000, \ + "result end_time should not be frozen" + + assert step.execution.start_time > frozen_time + 60000000, \ + "step start_time should not be frozen" + + assert step.execution.end_time > frozen_time + 60000000, \ + "step end_time should not be frozen" + + # All durations should be valid + assert result.execution.duration >= 0 + assert step.execution.duration >= 0 + + def test_multiple_freezegun_contexts(self): + """ + Test that timestamps work correctly across multiple freezegun contexts. + """ + executions = [] + + # First frozen context + with freeze_time("2023-01-01"): + exec1 = Execution() + time.sleep(0.01) + exec1.complete() + executions.append(exec1) + + # Second frozen context (different time) + with freeze_time("2023-06-01"): + exec2 = Execution() + time.sleep(0.01) + exec2.complete() + executions.append(exec2) + + # Outside frozen context + exec3 = Execution() + time.sleep(0.01) + exec3.complete() + executions.append(exec3) + + # All executions should have real timestamps + # and should be close to each other (not separated by months) + for i, exec in enumerate(executions): + assert exec.start_time > 1700000000, \ + f"Execution {i} should have real timestamp" + assert exec.duration >= 0, \ + f"Execution {i} should have valid duration" + + # Timestamps should be sequential (each one after the previous) + for i in range(len(executions) - 1): + assert executions[i].end_time <= executions[i + 1].start_time + 1, \ + f"Execution {i+1} should start after execution {i} ends" + diff --git a/qase-python-commons/tests/tests_qase_commons/test_utils.py b/qase-python-commons/tests/tests_qase_commons/test_utils.py index 3dde825d..0d2fd5a2 100644 --- a/qase-python-commons/tests/tests_qase_commons/test_utils.py +++ b/qase-python-commons/tests/tests_qase_commons/test_utils.py @@ -3,9 +3,16 @@ import threading import sys import pip +import time from qase.commons.utils import QaseUtils +try: + from freezegun import freeze_time + FREEZEGUN_AVAILABLE = True +except ImportError: + FREEZEGUN_AVAILABLE = False + def test_build_tree(): # Mocking item objects item1 = Mock() @@ -43,4 +50,40 @@ def test_get_host_data(): def test_get_filename(): path = '/path/to/file.txt' filename = QaseUtils.get_filename(path) - assert filename == 'file.txt' \ No newline at end of file + assert filename == 'file.txt' + +def test_get_real_time(): + """Test that get_real_time() returns current time""" + before = time.time() + real_time = QaseUtils.get_real_time() + after = time.time() + + # The real time should be between before and after + assert before <= real_time <= after + +def test_get_real_time_with_freezegun(): + """Test that get_real_time() returns real time even when freezegun is active""" + if not FREEZEGUN_AVAILABLE: + # Skip test if freezegun is not installed + return + + # Get current time before freezing + real_time_before = time.time() + + # Freeze time to a date in the past (2023-02-01) + with freeze_time("2023-02-01"): + # time.time() should return the frozen time + frozen_time = time.time() + + # get_real_time() should return the real current time + real_time = QaseUtils.get_real_time() + + # The frozen time should be much earlier than real time + # 2023-02-01 timestamp is around 1675209600 + assert frozen_time < 1700000000, f"Expected frozen time to be in the past, got {frozen_time}" + + # Real time should be close to current time (not frozen) + assert real_time > real_time_before, f"Expected real time {real_time} to be >= {real_time_before}" + + # Real time should be significantly greater than frozen time + assert real_time > frozen_time + 60000000, f"Expected real time {real_time} to be much greater than frozen time {frozen_time}"