Skip to content

Commit 46b276d

Browse files
committed
feat: release version 4.1.2 with freezegun compatibility improvements
- Updated version to 4.1.2 in pyproject.toml. - Introduced `get_real_time()` method in QaseUtils to bypass time mocking libraries like `freezegun`, ensuring accurate timestamp reporting. - Updated Execution and StepExecution models to use `get_real_time()` for start and end timestamps. - Added integration tests to verify correct behavior with freezegun. - Updated changelog to reflect the new version and changes. This release enhances compatibility with time mocking libraries, preventing timestamp validation errors in Qase API. Fix #415
1 parent d8ebc0a commit 46b276d

8 files changed

Lines changed: 328 additions & 9 deletions

File tree

qase-python-commons/changelog.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
# qase-python-commons@4.2.1
1+
# qase-python-commons@4.1.2
2+
3+
## What's new
4+
5+
- 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).
6+
7+
# qase-python-commons@4.1.1
28

39
## What's new
410

qase-python-commons/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "qase-python-commons"
7-
version = "4.1.1"
7+
version = "4.1.2"
88
description = "A library for Qase TestOps and Qase Report"
99
readme = "README.md"
1010
authors = [{name = "Qase Team", email = "support@qase.io"}]
@@ -42,6 +42,7 @@ testing = [
4242
"more_itertools",
4343
"requests",
4444
"urllib3",
45+
"freezegun",
4546
]
4647

4748
[tool.tox]

qase-python-commons/src/qase/commons/models/result.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(self,
2525
stacktrace: Optional[str] = None,
2626
thread: Optional[str] = QaseUtils.get_thread_name()
2727
):
28-
self.start_time = time.time()
28+
self.start_time = QaseUtils.get_real_time()
2929
self.status = status
3030
self.end_time = end_time
3131
self.duration = duration
@@ -48,7 +48,7 @@ def get_status(self):
4848
return self.status
4949

5050
def complete(self):
51-
self.end_time = time.time()
51+
self.end_time = QaseUtils.get_real_time()
5252
self.duration = (int)((self.end_time - self.start_time) * 1000)
5353

5454

qase-python-commons/src/qase/commons/models/step.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Optional, Union, Dict, List, Type
66
from .attachment import Attachment
77
from .basemodel import BaseModel
8+
from .. import QaseUtils
89

910

1011
class StepType(Enum):
@@ -95,7 +96,7 @@ def __init__(self, duration: int):
9596

9697
class StepExecution(BaseModel):
9798
def __init__(self, status: Optional[str] = 'untested', end_time: int = 0, duration: int = 0):
98-
self.start_time = time.time()
99+
self.start_time = QaseUtils.get_real_time()
99100
self.status = status
100101
self.end_time = end_time
101102
self.duration = duration
@@ -108,7 +109,7 @@ def set_status(self, status: Optional[str]):
108109
raise ValueError('Step status must be one of: passed, failed, skipped, blocked, untested, invalid')
109110

110111
def complete(self):
111-
self.end_time = time.time()
112+
self.end_time = QaseUtils.get_real_time()
112113
self.duration = int((self.end_time - self.start_time) * 1000)
113114

114115
def add_attachment(self, attachment: Attachment):

qase-python-commons/src/qase/commons/reporters/report.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ def __init__(
3131

3232
def start_run(self):
3333
self._check_report_path()
34-
self.start_time = str(time.time())
34+
self.start_time = str(QaseUtils.get_real_time())
3535

3636
def complete_run(self):
37-
self.end_time = str(time.time())
37+
self.end_time = str(QaseUtils.get_real_time())
3838
self._compile_report()
3939

4040
def complete_worker(self):

qase-python-commons/src/qase/commons/utils.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,88 @@
66
import pip
77
import string
88
import uuid
9+
import time
910

1011

1112
class QaseUtils:
1213

14+
@staticmethod
15+
def get_real_time() -> float:
16+
"""
17+
Get real system time, bypassing time mocking libraries like freezegun.
18+
19+
This is necessary when reporting test results to external systems that validate
20+
timestamps against current time, even when tests are using time mocking.
21+
22+
Returns:
23+
float: Current Unix timestamp in seconds with microsecond precision
24+
"""
25+
# Try to get the original time function if it was wrapped by freezegun
26+
# freezegun stores the original function in __wrapped__ attribute
27+
if hasattr(time.time, '__wrapped__'):
28+
return time.time.__wrapped__()
29+
30+
# Fallback: use direct system call via ctypes
31+
# This works on Unix-like systems and Windows
32+
try:
33+
import ctypes
34+
import ctypes.util
35+
36+
if sys.platform == 'win32':
37+
# Windows: use GetSystemTimeAsFileTime
38+
class FILETIME(ctypes.Structure):
39+
_fields_ = [("dwLowDateTime", ctypes.c_uint32),
40+
("dwHighDateTime", ctypes.c_uint32)]
41+
42+
kernel32 = ctypes.windll.kernel32
43+
ft = FILETIME()
44+
kernel32.GetSystemTimeAsFileTime(ctypes.byref(ft))
45+
46+
# Convert FILETIME to Unix timestamp
47+
# FILETIME is 100-nanosecond intervals since January 1, 1601
48+
timestamp = (ft.dwHighDateTime << 32) + ft.dwLowDateTime
49+
# Convert to seconds and adjust epoch (1601 -> 1970)
50+
return (timestamp / 10000000.0) - 11644473600.0
51+
else:
52+
# Unix-like systems: use gettimeofday for microsecond precision
53+
# Try multiple approaches to find libc
54+
libc = None
55+
56+
# Method 1: Use find_library (works on most systems)
57+
libc_path = ctypes.util.find_library('c')
58+
if libc_path:
59+
try:
60+
libc = ctypes.CDLL(libc_path)
61+
except OSError:
62+
pass
63+
64+
# Method 2: Try common library names directly (for Alpine Linux, musl libc, etc.)
65+
if libc is None:
66+
for lib_name in ['libc.so.6', 'libc.so', 'libc.dylib']:
67+
try:
68+
libc = ctypes.CDLL(lib_name)
69+
break
70+
except OSError:
71+
continue
72+
73+
if libc is None:
74+
raise OSError("Could not load C library")
75+
76+
class timeval(ctypes.Structure):
77+
_fields_ = [("tv_sec", ctypes.c_long),
78+
("tv_usec", ctypes.c_long)]
79+
80+
tv = timeval()
81+
libc.gettimeofday(ctypes.byref(tv), None)
82+
83+
return float(tv.tv_sec) + (float(tv.tv_usec) / 1000000.0)
84+
except Exception:
85+
# Last resort: return the potentially mocked time
86+
# This will still work in normal cases without freezegun
87+
# If freezegun is active, the user might see timestamp validation errors
88+
# but the core functionality will continue to work
89+
return time.time()
90+
1391
@staticmethod
1492
def build_tree(items):
1593
nodes = {item.id: item for item in items}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""
2+
Integration tests for freezegun compatibility.
3+
4+
These tests verify that Qase reporters work correctly when tests use freezegun
5+
to mock time. The issue is that freezegun mocks time.time(), which affects
6+
execution timestamps, but Qase API requires real timestamps.
7+
"""
8+
9+
import time
10+
import pytest
11+
12+
try:
13+
from freezegun import freeze_time
14+
FREEZEGUN_AVAILABLE = True
15+
except ImportError:
16+
FREEZEGUN_AVAILABLE = False
17+
18+
from qase.commons.models.result import Execution, Result
19+
from qase.commons.models.step import Step, StepType, StepTextData
20+
21+
22+
@pytest.mark.skipif(not FREEZEGUN_AVAILABLE, reason="freezegun not installed")
23+
class TestFreezegunIntegration:
24+
"""Test that Qase works correctly with freezegun"""
25+
26+
def test_execution_timestamps_with_freezegun(self):
27+
"""
28+
Test that Execution timestamps are real time even when freezegun is active.
29+
30+
This reproduces the issue from GitHub issue #415 where users get
31+
"Data is invalid" errors when using freezegun with Qase steps.
32+
"""
33+
# Get current real time
34+
real_time_before = time.time()
35+
36+
# Freeze time to 2023-02-01
37+
with freeze_time("2023-02-01"):
38+
# time.time() returns frozen time
39+
frozen_time = time.time()
40+
assert frozen_time < 1700000000, "Time should be frozen in the past"
41+
42+
# Create execution (this is what happens when test starts)
43+
execution = Execution()
44+
45+
# Small delay to ensure end_time > start_time
46+
time.sleep(0.1) # This is also mocked by freezegun
47+
48+
# Complete execution
49+
execution.complete()
50+
51+
# Verify that execution times are real (not frozen)
52+
# They should be close to current time, not 2023-02-01
53+
assert execution.start_time > frozen_time + 60000000, \
54+
f"start_time {execution.start_time} should be real time, not frozen time {frozen_time}"
55+
56+
assert execution.end_time > frozen_time + 60000000, \
57+
f"end_time {execution.end_time} should be real time, not frozen time {frozen_time}"
58+
59+
assert execution.start_time >= real_time_before, \
60+
f"start_time {execution.start_time} should be >= {real_time_before}"
61+
62+
# Duration should be positive
63+
assert execution.duration >= 0, \
64+
f"duration {execution.duration} should be non-negative"
65+
66+
# Verify timestamps would be accepted by Qase API
67+
# (API requires timestamps >= current time - some tolerance)
68+
current_timestamp = 1760383058 # Example from error message
69+
# Our timestamps should be much greater than this example from Oct 2025
70+
# Since we're running tests later
71+
assert execution.start_time > 1700000000, \
72+
f"Timestamp {execution.start_time} should be valid for Qase API"
73+
74+
def test_step_execution_with_freezegun(self):
75+
"""
76+
Test that Step execution timestamps are real time when freezegun is active.
77+
"""
78+
real_time_before = time.time()
79+
80+
with freeze_time("2023-02-01"):
81+
frozen_time = time.time()
82+
83+
# Create a step (this is what happens with qase.step())
84+
step = Step(
85+
step_type=StepType.TEXT,
86+
id="test-step-1",
87+
data=StepTextData(action="Test action")
88+
)
89+
90+
# Simulate some work
91+
time.sleep(0.01)
92+
93+
# Complete the step
94+
step.execution.complete()
95+
96+
# Verify timestamps are real
97+
assert step.execution.start_time > frozen_time + 60000000, \
98+
"step start_time should be real time"
99+
100+
assert step.execution.end_time > frozen_time + 60000000, \
101+
"step end_time should be real time"
102+
103+
assert step.execution.start_time >= real_time_before, \
104+
"step start_time should be >= real time before test"
105+
106+
assert step.execution.duration >= 0, \
107+
"step duration should be non-negative"
108+
109+
def test_result_with_steps_freezegun(self):
110+
"""
111+
Test complete Result with steps when freezegun is active.
112+
113+
This simulates the exact scenario from the user's issue.
114+
"""
115+
with freeze_time("2023-02-01"):
116+
frozen_time = time.time()
117+
118+
# Create result
119+
result = Result(title="Test with freezegun", signature="test-sig")
120+
121+
# Simulate test execution with step
122+
step = Step(
123+
step_type=StepType.TEXT,
124+
id="step-1",
125+
data=StepTextData(action="Doing something")
126+
)
127+
128+
time.sleep(0.01)
129+
step.execution.complete()
130+
131+
result.steps.append(step)
132+
result.execution.set_status("passed")
133+
result.execution.complete()
134+
135+
# Verify all timestamps are real (not frozen)
136+
assert result.execution.start_time > frozen_time + 60000000, \
137+
"result start_time should not be frozen"
138+
139+
assert result.execution.end_time > frozen_time + 60000000, \
140+
"result end_time should not be frozen"
141+
142+
assert step.execution.start_time > frozen_time + 60000000, \
143+
"step start_time should not be frozen"
144+
145+
assert step.execution.end_time > frozen_time + 60000000, \
146+
"step end_time should not be frozen"
147+
148+
# All durations should be valid
149+
assert result.execution.duration >= 0
150+
assert step.execution.duration >= 0
151+
152+
def test_multiple_freezegun_contexts(self):
153+
"""
154+
Test that timestamps work correctly across multiple freezegun contexts.
155+
"""
156+
executions = []
157+
158+
# First frozen context
159+
with freeze_time("2023-01-01"):
160+
exec1 = Execution()
161+
time.sleep(0.01)
162+
exec1.complete()
163+
executions.append(exec1)
164+
165+
# Second frozen context (different time)
166+
with freeze_time("2023-06-01"):
167+
exec2 = Execution()
168+
time.sleep(0.01)
169+
exec2.complete()
170+
executions.append(exec2)
171+
172+
# Outside frozen context
173+
exec3 = Execution()
174+
time.sleep(0.01)
175+
exec3.complete()
176+
executions.append(exec3)
177+
178+
# All executions should have real timestamps
179+
# and should be close to each other (not separated by months)
180+
for i, exec in enumerate(executions):
181+
assert exec.start_time > 1700000000, \
182+
f"Execution {i} should have real timestamp"
183+
assert exec.duration >= 0, \
184+
f"Execution {i} should have valid duration"
185+
186+
# Timestamps should be sequential (each one after the previous)
187+
for i in range(len(executions) - 1):
188+
assert executions[i].end_time <= executions[i + 1].start_time + 1, \
189+
f"Execution {i+1} should start after execution {i} ends"
190+

0 commit comments

Comments
 (0)