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.
@@ -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