pyplaykit 0.1.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.
- core/__init__.py +0 -0
- core/base_page.py +94 -0
- core/base_test.py +27 -0
- core/browser_factory.py +30 -0
- core/playwright_manager.py +40 -0
- dist_pkg/__init__.py +5 -0
- dist_pkg/distribution.py +51 -0
- integrations/__init__.py +21 -0
- integrations/adapters.py +155 -0
- orchestration/__init__.py +5 -0
- orchestration/planner.py +137 -0
- pages/__init__.py +0 -0
- pages/dashboard_page.py +24 -0
- pages/login_page.py +31 -0
- pages/orangehrm_login_page.py +58 -0
- pages/saucedemo_inventory_page.py +100 -0
- pages/saucedemo_login_page.py +39 -0
- plugins/__init__.py +16 -0
- plugins/api_plugin.py +11 -0
- plugins/base.py +21 -0
- plugins/data_plugin.py +11 -0
- plugins/registry.py +77 -0
- plugins/security_plugin.py +11 -0
- pyplaykit-0.1.0.dist-info/METADATA +340 -0
- pyplaykit-0.1.0.dist-info/RECORD +42 -0
- pyplaykit-0.1.0.dist-info/WHEEL +5 -0
- pyplaykit-0.1.0.dist-info/top_level.txt +8 -0
- resilience/__init__.py +5 -0
- resilience/locator_recovery.py +72 -0
- utils/__init__.py +0 -0
- utils/api_client.py +125 -0
- utils/assertion_helper.py +21 -0
- utils/data_loader.py +36 -0
- utils/data_validator.py +426 -0
- utils/environment_validator.py +105 -0
- utils/file_reader.py +35 -0
- utils/logger.py +36 -0
- utils/observability.py +164 -0
- utils/response_validator.py +58 -0
- utils/screenshot_helper.py +17 -0
- utils/tdm.py +213 -0
- utils/yaml_config_reader.py +104 -0
utils/data_validator.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Reusable data validation helpers for data, API, and UI output assertions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime, timezone
|
|
7
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DataValidator:
|
|
12
|
+
"""Assertion helpers for record-level and collection-level data checks."""
|
|
13
|
+
|
|
14
|
+
TRUE_STRINGS = {"true", "1", "yes", "y", "t"}
|
|
15
|
+
FALSE_STRINGS = {"false", "0", "no", "n", "f"}
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def assert_required_fields(record: dict[str, Any], required_fields: list[str]) -> None:
|
|
19
|
+
"""Assert all required fields are present in record."""
|
|
20
|
+
missing = [field for field in required_fields if field not in record]
|
|
21
|
+
if missing:
|
|
22
|
+
raise AssertionError(f"Missing required fields: {missing}. Record: {record}")
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def assert_types(record: dict[str, Any], field_type_map: dict[str, type | tuple[type, ...]]) -> None:
|
|
26
|
+
"""Assert each mapped field has expected type."""
|
|
27
|
+
for field, expected_type in field_type_map.items():
|
|
28
|
+
if field not in record:
|
|
29
|
+
raise AssertionError(f"Field '{field}' not found in record: {record}")
|
|
30
|
+
if not isinstance(record[field], expected_type):
|
|
31
|
+
raise AssertionError(
|
|
32
|
+
f"Field '{field}' expected type {expected_type}, found {type(record[field])}."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def assert_records_equal(
|
|
37
|
+
actual: dict[str, Any],
|
|
38
|
+
expected: dict[str, Any],
|
|
39
|
+
ignore_fields: set[str] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Assert two records are equal after removing ignored top-level fields."""
|
|
42
|
+
ignored = ignore_fields or set()
|
|
43
|
+
normalized_actual = {k: v for k, v in actual.items() if k not in ignored}
|
|
44
|
+
normalized_expected = {k: v for k, v in expected.items() if k not in ignored}
|
|
45
|
+
if normalized_actual != normalized_expected:
|
|
46
|
+
raise AssertionError(
|
|
47
|
+
f"Record mismatch. Expected: {normalized_expected}, Actual: {normalized_actual}, Ignored: {ignored}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def assert_collection_contains_record(
|
|
52
|
+
records: list[dict[str, Any]],
|
|
53
|
+
expected_subset: dict[str, Any],
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Assert at least one record contains all key/value pairs in expected subset."""
|
|
56
|
+
for record in records:
|
|
57
|
+
if all(record.get(key) == value for key, value in expected_subset.items()):
|
|
58
|
+
return
|
|
59
|
+
raise AssertionError(
|
|
60
|
+
f"No record matched expected subset: {expected_subset}. Records count: {len(records)}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def assert_boolean_like_equal(actual: Any, expected: Any) -> None:
|
|
65
|
+
"""Assert values are equal after coercing common boolean-like inputs."""
|
|
66
|
+
actual_bool = DataValidator.to_bool(actual)
|
|
67
|
+
expected_bool = DataValidator.to_bool(expected)
|
|
68
|
+
if actual_bool != expected_bool:
|
|
69
|
+
raise AssertionError(
|
|
70
|
+
f"Boolean-like mismatch. Expected '{expected}' ({expected_bool}), found '{actual}' ({actual_bool})."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def assert_floats_equal(
|
|
75
|
+
actual: Any,
|
|
76
|
+
expected: Any,
|
|
77
|
+
precision: int = 2,
|
|
78
|
+
tolerance: float | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Assert numeric values are equal by decimal precision or optional tolerance."""
|
|
81
|
+
actual_decimal = DataValidator._to_decimal(actual)
|
|
82
|
+
expected_decimal = DataValidator._to_decimal(expected)
|
|
83
|
+
|
|
84
|
+
if tolerance is not None:
|
|
85
|
+
if abs(actual_decimal - expected_decimal) > Decimal(str(tolerance)):
|
|
86
|
+
raise AssertionError(
|
|
87
|
+
f"Float mismatch with tolerance {tolerance}. Expected '{expected}', found '{actual}'."
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
quantizer = Decimal("1").scaleb(-precision)
|
|
92
|
+
actual_quantized = actual_decimal.quantize(quantizer, rounding=ROUND_HALF_UP)
|
|
93
|
+
expected_quantized = expected_decimal.quantize(quantizer, rounding=ROUND_HALF_UP)
|
|
94
|
+
if actual_quantized != expected_quantized:
|
|
95
|
+
raise AssertionError(
|
|
96
|
+
f"Float mismatch at precision {precision}. Expected '{expected_quantized}', found '{actual_quantized}'."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def normalize_string(
|
|
101
|
+
value: Any,
|
|
102
|
+
remove_special_chars: bool = True,
|
|
103
|
+
trim_spaces: bool = True,
|
|
104
|
+
collapse_spaces: bool = True,
|
|
105
|
+
case: str = "lower",
|
|
106
|
+
) -> str:
|
|
107
|
+
"""Normalize string for stable comparisons across formatting variations."""
|
|
108
|
+
normalized = "" if value is None else str(value)
|
|
109
|
+
|
|
110
|
+
# Preserve leading/trailing spaces if not trimming
|
|
111
|
+
leading_spaces = ""
|
|
112
|
+
trailing_spaces = ""
|
|
113
|
+
if not trim_spaces:
|
|
114
|
+
if isinstance(value, str):
|
|
115
|
+
leading_spaces = re.match(r"^\s*", value).group(0) if re.match(r"^\s*", value) else ""
|
|
116
|
+
trailing_spaces = re.search(r"\s*$", value).group(0) if re.search(r"\s*$", value) else ""
|
|
117
|
+
|
|
118
|
+
# Temporarily trim for internal processing, re-add later
|
|
119
|
+
normalized = normalized.strip()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if remove_special_chars:
|
|
123
|
+
normalized = re.sub(r"[^\w\s]", " ", normalized)
|
|
124
|
+
if collapse_spaces:
|
|
125
|
+
normalized = re.sub(r"\s+", " ", normalized)
|
|
126
|
+
|
|
127
|
+
# Apply trimming if requested
|
|
128
|
+
if trim_spaces:
|
|
129
|
+
normalized = normalized.strip()
|
|
130
|
+
|
|
131
|
+
if case == "lower":
|
|
132
|
+
normalized = normalized.lower()
|
|
133
|
+
elif case == "upper":
|
|
134
|
+
normalized = normalized.upper()
|
|
135
|
+
elif case != "preserve":
|
|
136
|
+
raise ValueError("case must be one of: lower, upper, preserve")
|
|
137
|
+
|
|
138
|
+
# Re-add leading/trailing spaces if not trimming was requested
|
|
139
|
+
if not trim_spaces:
|
|
140
|
+
normalized = leading_spaces + normalized + trailing_spaces
|
|
141
|
+
|
|
142
|
+
return normalized
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def assert_strings_equal_normalized(
|
|
146
|
+
actual: Any,
|
|
147
|
+
expected: Any,
|
|
148
|
+
remove_special_chars: bool = True,
|
|
149
|
+
trim_spaces: bool = True,
|
|
150
|
+
collapse_spaces: bool = True,
|
|
151
|
+
case: str = "lower",
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Assert strings are equal after applying configurable normalization."""
|
|
154
|
+
actual_normalized = DataValidator.normalize_string(
|
|
155
|
+
actual,
|
|
156
|
+
remove_special_chars=remove_special_chars,
|
|
157
|
+
trim_spaces=trim_spaces,
|
|
158
|
+
collapse_spaces=collapse_spaces,
|
|
159
|
+
case=case,
|
|
160
|
+
)
|
|
161
|
+
expected_normalized = DataValidator.normalize_string(
|
|
162
|
+
expected,
|
|
163
|
+
remove_special_chars=remove_special_chars,
|
|
164
|
+
trim_spaces=trim_spaces,
|
|
165
|
+
collapse_spaces=collapse_spaces,
|
|
166
|
+
case=case,
|
|
167
|
+
)
|
|
168
|
+
if actual_normalized != expected_normalized:
|
|
169
|
+
raise AssertionError(
|
|
170
|
+
"String mismatch after normalization. "
|
|
171
|
+
f"Expected '{expected_normalized}', found '{actual_normalized}'."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def assert_values_equal(
|
|
176
|
+
actual: Any,
|
|
177
|
+
expected: Any,
|
|
178
|
+
float_precision: int = 2,
|
|
179
|
+
float_tolerance: float | None = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Smart value assertion covering boolean-like, numeric, and string-like inputs."""
|
|
182
|
+
if DataValidator._is_boolean_like(actual) and DataValidator._is_boolean_like(expected):
|
|
183
|
+
DataValidator.assert_boolean_like_equal(actual, expected)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if DataValidator._is_number_like(actual) and DataValidator._is_number_like(expected):
|
|
187
|
+
DataValidator.assert_floats_equal(
|
|
188
|
+
actual,
|
|
189
|
+
expected,
|
|
190
|
+
precision=float_precision,
|
|
191
|
+
tolerance=float_tolerance,
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
DataValidator.assert_strings_equal_normalized(actual, expected)
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def unix_ticks_to_datetime(value: Any, unit: str = "auto", tz: timezone = UTC) -> datetime:
|
|
199
|
+
"""Convert Unix ticks to timezone-aware datetime.
|
|
200
|
+
|
|
201
|
+
Supported units: seconds, milliseconds, microseconds, nanoseconds, auto.
|
|
202
|
+
"""
|
|
203
|
+
ticks = float(DataValidator._to_decimal(value))
|
|
204
|
+
resolved_unit = DataValidator._resolve_unix_tick_unit(ticks, unit)
|
|
205
|
+
if resolved_unit == "seconds":
|
|
206
|
+
seconds = ticks
|
|
207
|
+
elif resolved_unit == "milliseconds":
|
|
208
|
+
seconds = ticks / 1_000
|
|
209
|
+
elif resolved_unit == "microseconds":
|
|
210
|
+
seconds = ticks / 1_000_000
|
|
211
|
+
elif resolved_unit == "nanoseconds":
|
|
212
|
+
seconds = ticks / 1_000_000_000
|
|
213
|
+
else: # pragma: no cover
|
|
214
|
+
raise ValueError("unit must be one of: auto, seconds, milliseconds, microseconds, nanoseconds")
|
|
215
|
+
return datetime.fromtimestamp(seconds, UTC).astimezone(tz)
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def datetime_to_unix_ticks(value: Any, unit: str = "milliseconds") -> int | float:
|
|
219
|
+
"""Convert datetime-like input to Unix ticks in the desired unit."""
|
|
220
|
+
dt_value = DataValidator._coerce_datetime(value)
|
|
221
|
+
epoch_seconds = dt_value.timestamp()
|
|
222
|
+
if unit == "seconds":
|
|
223
|
+
return epoch_seconds
|
|
224
|
+
if unit == "milliseconds":
|
|
225
|
+
return int(round(epoch_seconds * 1_000))
|
|
226
|
+
if unit == "microseconds":
|
|
227
|
+
return int(round(epoch_seconds * 1_000_000))
|
|
228
|
+
if unit == "nanoseconds":
|
|
229
|
+
return int(round(epoch_seconds * 1_000_000_000))
|
|
230
|
+
raise ValueError("unit must be one of: seconds, milliseconds, microseconds, nanoseconds")
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def parse_datetime(
|
|
234
|
+
value: Any,
|
|
235
|
+
input_formats: list[str] | None = None,
|
|
236
|
+
assume_tz: timezone = UTC,
|
|
237
|
+
) -> datetime:
|
|
238
|
+
"""Parse datetime input into UTC timezone-aware datetime."""
|
|
239
|
+
if isinstance(value, datetime):
|
|
240
|
+
parsed = value
|
|
241
|
+
elif DataValidator._is_number_like(value):
|
|
242
|
+
parsed = DataValidator.unix_ticks_to_datetime(value, unit="auto", tz=UTC)
|
|
243
|
+
elif isinstance(value, str):
|
|
244
|
+
text_value = value.strip()
|
|
245
|
+
try:
|
|
246
|
+
parsed = datetime.fromisoformat(text_value.replace("Z", "+00:00"))
|
|
247
|
+
except ValueError:
|
|
248
|
+
if not input_formats:
|
|
249
|
+
raise ValueError(f"Unable to parse datetime value: '{value}'")
|
|
250
|
+
parsed = None
|
|
251
|
+
for fmt in input_formats:
|
|
252
|
+
try:
|
|
253
|
+
parsed = datetime.strptime(text_value, fmt)
|
|
254
|
+
break
|
|
255
|
+
except ValueError:
|
|
256
|
+
continue
|
|
257
|
+
if parsed is None:
|
|
258
|
+
raise ValueError(f"Unable to parse datetime value: '{value}'")
|
|
259
|
+
else:
|
|
260
|
+
raise ValueError(f"Unsupported datetime value: '{value}'")
|
|
261
|
+
|
|
262
|
+
if parsed.tzinfo is None:
|
|
263
|
+
parsed = parsed.replace(tzinfo=assume_tz)
|
|
264
|
+
return parsed.astimezone(UTC)
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def assert_datetimes_equal(
|
|
268
|
+
actual: Any,
|
|
269
|
+
expected: Any,
|
|
270
|
+
tolerance_seconds: float = 0,
|
|
271
|
+
actual_unit: str = "auto",
|
|
272
|
+
expected_unit: str = "auto",
|
|
273
|
+
) -> None:
|
|
274
|
+
"""Assert two datetime-like values are equal within tolerance."""
|
|
275
|
+
actual_dt = DataValidator._coerce_datetime(actual, tick_unit=actual_unit)
|
|
276
|
+
expected_dt = DataValidator._coerce_datetime(expected, tick_unit=expected_unit)
|
|
277
|
+
delta_seconds = abs((actual_dt - expected_dt).total_seconds())
|
|
278
|
+
if delta_seconds > tolerance_seconds:
|
|
279
|
+
raise AssertionError(
|
|
280
|
+
"Datetime mismatch. "
|
|
281
|
+
f"Expected '{DataValidator.format_datetime_for_report(expected_dt)}', "
|
|
282
|
+
f"found '{DataValidator.format_datetime_for_report(actual_dt)}', "
|
|
283
|
+
f"delta={delta_seconds:.6f}s, tolerance={tolerance_seconds:.6f}s."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def format_datetime_for_report(
|
|
288
|
+
value: Any,
|
|
289
|
+
input_unit: str = "auto",
|
|
290
|
+
output_format: str = "%Y-%m-%d %H:%M:%S.%f %Z",
|
|
291
|
+
) -> str:
|
|
292
|
+
"""Format datetime-like input into report-friendly UTC datetime text."""
|
|
293
|
+
dt_value = DataValidator._coerce_datetime(value, tick_unit=input_unit)
|
|
294
|
+
return dt_value.astimezone(UTC).strftime(output_format)
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def assert_record_datetime_fields(
|
|
298
|
+
actual_record: dict[str, Any],
|
|
299
|
+
expected_record: dict[str, Any],
|
|
300
|
+
field_rules: dict[str, dict[str, Any]],
|
|
301
|
+
default_tolerance_seconds: float = 0,
|
|
302
|
+
default_actual_unit: str = "auto",
|
|
303
|
+
default_expected_unit: str = "auto",
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Compare datetime fields between two records using per-field rules.
|
|
306
|
+
|
|
307
|
+
field_rules example:
|
|
308
|
+
{
|
|
309
|
+
"created_at_ms": {
|
|
310
|
+
"expected_field": "created_at",
|
|
311
|
+
"actual_unit": "milliseconds",
|
|
312
|
+
"expected_unit": "auto",
|
|
313
|
+
"tolerance_seconds": 1,
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
"""
|
|
317
|
+
if not field_rules:
|
|
318
|
+
raise ValueError("field_rules must include at least one datetime field rule")
|
|
319
|
+
|
|
320
|
+
errors: list[str] = []
|
|
321
|
+
for actual_field, rule in field_rules.items():
|
|
322
|
+
expected_field = rule.get("expected_field", actual_field)
|
|
323
|
+
actual_unit = rule.get("actual_unit", default_actual_unit)
|
|
324
|
+
expected_unit = rule.get("expected_unit", default_expected_unit)
|
|
325
|
+
tolerance_seconds = rule.get("tolerance_seconds", default_tolerance_seconds)
|
|
326
|
+
|
|
327
|
+
if actual_field not in actual_record:
|
|
328
|
+
errors.append(f"Actual record missing field '{actual_field}'")
|
|
329
|
+
continue
|
|
330
|
+
if expected_field not in expected_record:
|
|
331
|
+
errors.append(f"Expected record missing field '{expected_field}'")
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
DataValidator.assert_datetimes_equal(
|
|
336
|
+
actual=actual_record[actual_field],
|
|
337
|
+
expected=expected_record[expected_field],
|
|
338
|
+
tolerance_seconds=tolerance_seconds,
|
|
339
|
+
actual_unit=actual_unit,
|
|
340
|
+
expected_unit=expected_unit,
|
|
341
|
+
)
|
|
342
|
+
except AssertionError as exc:
|
|
343
|
+
errors.append(f"{actual_field} vs {expected_field}: {exc}")
|
|
344
|
+
|
|
345
|
+
if errors:
|
|
346
|
+
joined = "; ".join(errors)
|
|
347
|
+
raise AssertionError(f"Datetime field validation failed: {joined}")
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def to_bool(value: Any) -> bool:
|
|
351
|
+
"""Convert common boolean-like values into bool."""
|
|
352
|
+
if isinstance(value, bool):
|
|
353
|
+
return value
|
|
354
|
+
if isinstance(value, (int, float)) and value in (0, 1):
|
|
355
|
+
return bool(value)
|
|
356
|
+
if isinstance(value, str):
|
|
357
|
+
cleaned = value.strip().lower()
|
|
358
|
+
if cleaned in DataValidator.TRUE_STRINGS:
|
|
359
|
+
return True
|
|
360
|
+
if cleaned in DataValidator.FALSE_STRINGS:
|
|
361
|
+
return False
|
|
362
|
+
raise ValueError(f"Unsupported boolean-like value: '{value}'")
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _is_boolean_like(value: Any) -> bool:
|
|
366
|
+
if isinstance(value, bool):
|
|
367
|
+
return True
|
|
368
|
+
if isinstance(value, (int, float)) and value in (0, 1):
|
|
369
|
+
return True
|
|
370
|
+
if isinstance(value, str):
|
|
371
|
+
cleaned = value.strip().lower()
|
|
372
|
+
return cleaned in DataValidator.TRUE_STRINGS or cleaned in DataValidator.FALSE_STRINGS
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _is_number_like(value: Any) -> bool:
|
|
377
|
+
if isinstance(value, bool):
|
|
378
|
+
return False
|
|
379
|
+
if isinstance(value, (int, float, Decimal)):
|
|
380
|
+
return True
|
|
381
|
+
if isinstance(value, str):
|
|
382
|
+
try:
|
|
383
|
+
Decimal(value.strip())
|
|
384
|
+
return True
|
|
385
|
+
except Exception:
|
|
386
|
+
return False
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _to_decimal(value: Any) -> Decimal:
|
|
391
|
+
if isinstance(value, Decimal):
|
|
392
|
+
return value
|
|
393
|
+
if isinstance(value, bool):
|
|
394
|
+
raise ValueError("Boolean values are not supported for float conversion")
|
|
395
|
+
try:
|
|
396
|
+
return Decimal(str(value).strip())
|
|
397
|
+
except Exception as exc:
|
|
398
|
+
raise ValueError(f"Unsupported numeric value: '{value}'") from exc
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _coerce_datetime(value: Any, tick_unit: str = "auto") -> datetime:
|
|
402
|
+
if isinstance(value, datetime):
|
|
403
|
+
parsed = value
|
|
404
|
+
if parsed.tzinfo is None:
|
|
405
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
406
|
+
return parsed.astimezone(UTC)
|
|
407
|
+
if DataValidator._is_number_like(value):
|
|
408
|
+
return DataValidator.unix_ticks_to_datetime(value, unit=tick_unit, tz=UTC)
|
|
409
|
+
return DataValidator.parse_datetime(value)
|
|
410
|
+
|
|
411
|
+
@staticmethod
|
|
412
|
+
def _resolve_unix_tick_unit(ticks: float, unit: str) -> str:
|
|
413
|
+
valid_units = {"auto", "seconds", "milliseconds", "microseconds", "nanoseconds"}
|
|
414
|
+
if unit not in valid_units:
|
|
415
|
+
raise ValueError(f"unit must be one of: {', '.join(sorted(valid_units))}")
|
|
416
|
+
|
|
417
|
+
if unit != "auto":
|
|
418
|
+
return unit
|
|
419
|
+
absolute_ticks = abs(Decimal(str(ticks)))
|
|
420
|
+
if absolute_ticks >= Decimal('1e17'):
|
|
421
|
+
return "nanoseconds"
|
|
422
|
+
if absolute_ticks >= Decimal('1e14'):
|
|
423
|
+
return "microseconds"
|
|
424
|
+
if absolute_ticks >= Decimal('1e11'):
|
|
425
|
+
return "milliseconds"
|
|
426
|
+
return "seconds"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Environment readiness checks for URL/API availability and optional DB validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
from urllib.error import HTTPError, URLError
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _check_url_availability(url: str, timeout_seconds: int) -> dict[str, str]:
|
|
12
|
+
request = Request(url=url, method="GET")
|
|
13
|
+
try:
|
|
14
|
+
with urlopen(request, timeout=timeout_seconds) as response:
|
|
15
|
+
return {
|
|
16
|
+
"name": "url_availability",
|
|
17
|
+
"status": "passed",
|
|
18
|
+
"details": f"URL reachable with status {response.getcode()}",
|
|
19
|
+
}
|
|
20
|
+
except HTTPError as exc:
|
|
21
|
+
return {
|
|
22
|
+
"name": "url_availability",
|
|
23
|
+
"status": "failed",
|
|
24
|
+
"details": f"HTTP error {exc.code}",
|
|
25
|
+
}
|
|
26
|
+
except URLError as exc:
|
|
27
|
+
return {
|
|
28
|
+
"name": "url_availability",
|
|
29
|
+
"status": "failed",
|
|
30
|
+
"details": f"URL error: {exc.reason}",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_api_health(base_url: str, health_endpoint: str, timeout_seconds: int) -> dict[str, str]:
|
|
35
|
+
health_url = urljoin(base_url.rstrip("/") + "/", health_endpoint.lstrip("/"))
|
|
36
|
+
request = Request(url=health_url, method="GET")
|
|
37
|
+
try:
|
|
38
|
+
with urlopen(request, timeout=timeout_seconds) as response:
|
|
39
|
+
status_code = response.getcode()
|
|
40
|
+
if status_code == 200:
|
|
41
|
+
return {
|
|
42
|
+
"name": "api_health",
|
|
43
|
+
"status": "passed",
|
|
44
|
+
"details": f"Health endpoint responded with status {status_code}",
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
"name": "api_health",
|
|
48
|
+
"status": "failed",
|
|
49
|
+
"details": f"Health endpoint returned status {status_code}",
|
|
50
|
+
}
|
|
51
|
+
except HTTPError as exc:
|
|
52
|
+
return {
|
|
53
|
+
"name": "api_health",
|
|
54
|
+
"status": "failed",
|
|
55
|
+
"details": f"Health endpoint HTTP error {exc.code}",
|
|
56
|
+
}
|
|
57
|
+
except URLError as exc:
|
|
58
|
+
return {
|
|
59
|
+
"name": "api_health",
|
|
60
|
+
"status": "failed",
|
|
61
|
+
"details": f"Health endpoint URL error: {exc.reason}",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _check_db_connectivity(db_check: Callable[[], tuple[bool, str]]) -> dict[str, str]:
|
|
66
|
+
try:
|
|
67
|
+
is_connected, details = db_check()
|
|
68
|
+
return {
|
|
69
|
+
"name": "db_connectivity",
|
|
70
|
+
"status": "passed" if is_connected else "failed",
|
|
71
|
+
"details": details,
|
|
72
|
+
}
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
return {
|
|
75
|
+
"name": "db_connectivity",
|
|
76
|
+
"status": "failed",
|
|
77
|
+
"details": f"DB connectivity check failed: {exc}",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_environment_readiness_checks(
|
|
82
|
+
base_url: str,
|
|
83
|
+
timeout_seconds: int = 5,
|
|
84
|
+
health_endpoint: str = "/health",
|
|
85
|
+
include_url_check: bool = True,
|
|
86
|
+
db_check: Callable[[], tuple[bool, str]] | None = None,
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
"""Run configured readiness checks and return aggregate status."""
|
|
89
|
+
checks: list[dict[str, str]] = []
|
|
90
|
+
|
|
91
|
+
if include_url_check:
|
|
92
|
+
checks.append(_check_url_availability(base_url, timeout_seconds=timeout_seconds))
|
|
93
|
+
|
|
94
|
+
if health_endpoint:
|
|
95
|
+
checks.append(_check_api_health(base_url, health_endpoint=health_endpoint, timeout_seconds=timeout_seconds))
|
|
96
|
+
|
|
97
|
+
if db_check is not None:
|
|
98
|
+
checks.append(_check_db_connectivity(db_check))
|
|
99
|
+
|
|
100
|
+
failed_checks = [check for check in checks if check["status"] == "failed"]
|
|
101
|
+
return {
|
|
102
|
+
"ready": len(failed_checks) == 0,
|
|
103
|
+
"checks": checks,
|
|
104
|
+
"failed_checks": failed_checks,
|
|
105
|
+
}
|
utils/file_reader.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import configparser
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
def read_json_file(file_path: str) -> Dict[str, Any]:
|
|
7
|
+
"""Reads a JSON file and returns its content as a dictionary."""
|
|
8
|
+
path = Path(file_path)
|
|
9
|
+
if not path.is_file():
|
|
10
|
+
raise FileNotFoundError(f"JSON file not found: {file_path}")
|
|
11
|
+
with path.open("r", encoding="utf-8") as f:
|
|
12
|
+
return json.load(f)
|
|
13
|
+
|
|
14
|
+
def read_ini_file(file_path: str, section: Optional[str] = None, preserve_key_case: bool = False) -> Dict[str, Any]:
|
|
15
|
+
"""
|
|
16
|
+
Reads an INI file and returns its content.
|
|
17
|
+
If section is provided, returns only that section.
|
|
18
|
+
If preserve_key_case is True, keys are case-sensitive.
|
|
19
|
+
"""
|
|
20
|
+
path = Path(file_path)
|
|
21
|
+
if not path.is_file():
|
|
22
|
+
raise FileNotFoundError(f"INI file not found: {file_path}")
|
|
23
|
+
|
|
24
|
+
config = configparser.ConfigParser()
|
|
25
|
+
if preserve_key_case:
|
|
26
|
+
config.optionxform = str # Preserve case for keys
|
|
27
|
+
|
|
28
|
+
config.read(file_path)
|
|
29
|
+
|
|
30
|
+
if section:
|
|
31
|
+
if section not in config:
|
|
32
|
+
raise KeyError(f"Section '{section}' not found in INI file: {file_path}")
|
|
33
|
+
return dict(config[section])
|
|
34
|
+
else:
|
|
35
|
+
return {s: dict(config[s]) for s in config.sections()}
|
utils/logger.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Framework logging utility with centralized setup."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_logger(
|
|
9
|
+
name: str = "pyplaykit",
|
|
10
|
+
log_file: str = "reports/framework.log",
|
|
11
|
+
level: int = logging.INFO,
|
|
12
|
+
fmt: Optional[str] = None,
|
|
13
|
+
) -> logging.Logger:
|
|
14
|
+
"""Return configured logger instance."""
|
|
15
|
+
logger = logging.getLogger(name)
|
|
16
|
+
if logger.handlers:
|
|
17
|
+
return logger
|
|
18
|
+
|
|
19
|
+
logger.setLevel(level)
|
|
20
|
+
logger.propagate = False
|
|
21
|
+
Path(log_file).parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
formatter = logging.Formatter(
|
|
24
|
+
fmt=fmt or "%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
|
25
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
file_handler = logging.FileHandler(log_file)
|
|
29
|
+
file_handler.setFormatter(formatter)
|
|
30
|
+
logger.addHandler(file_handler)
|
|
31
|
+
|
|
32
|
+
stream_handler = logging.StreamHandler()
|
|
33
|
+
stream_handler.setFormatter(formatter)
|
|
34
|
+
logger.addHandler(stream_handler)
|
|
35
|
+
|
|
36
|
+
return logger
|