pyplaykit 1.4.0__tar.gz → 1.5.0__tar.gz
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.
- {pyplaykit-1.4.0/pyplaykit.egg-info → pyplaykit-1.5.0}/PKG-INFO +39 -2
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/README.md +38 -1
- {pyplaykit-1.4.0 → pyplaykit-1.5.0/pyplaykit.egg-info}/PKG-INFO +39 -2
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pyplaykit.egg-info/SOURCES.txt +1 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pyproject.toml +1 -1
- pyplaykit-1.5.0/utils/__init__.py +5 -0
- pyplaykit-1.5.0/utils/data_comparison_report.py +487 -0
- pyplaykit-1.4.0/utils/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/MANIFEST.in +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/components/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/components/base_component.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/components/data_table.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/components/form_field.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/components/modal.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/config/config.yaml +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/config/environments.yaml +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/config/projects/customs_modernization.yaml +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/config/projects/dispatcho.yaml +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/config/projects/timelyquote.yaml +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/core/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/core/base_page.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/core/base_test.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/core/browser_factory.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/core/playwright_manager.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/dist_pkg/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/dist_pkg/distribution.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/integrations/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/integrations/adapters.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/orchestration/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/orchestration/enforcer.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/orchestration/planner.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pages/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pages/dashboard_page.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pages/login_page.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pages/orangehrm_login_page.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pages/saucedemo_inventory_page.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pages/saucedemo_login_page.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/plugins/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/plugins/api_plugin.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/plugins/base.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/plugins/data_plugin.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/plugins/registry.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/plugins/security_plugin.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pyplaykit/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pyplaykit/cli.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pyplaykit/plugin.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pyplaykit.egg-info/dependency_links.txt +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pyplaykit.egg-info/entry_points.txt +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pyplaykit.egg-info/requires.txt +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/pyplaykit.egg-info/top_level.txt +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/resilience/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/resilience/locator_recovery.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/setup.cfg +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/test_data/api/ecom/auth_payloads.json +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/test_data/api/ecom/product_payloads.json +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/test_data/api_test_data.json +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/test_data/data_validation_test_data.json +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/test_data/login_test_data.json +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/test_data/orangehrm_login_data.json +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/test_data/ui_test_data.json +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/api_client.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/api_test_helper.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/assertion_helper.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/auth_manager.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/data_loader.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/data_source.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/data_validator.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_client.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_drivers/__init__.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_drivers/db2.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_drivers/mysql.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_drivers/oracle.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_drivers/snowflake.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_drivers/sqlite.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_drivers/sqlserver.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_factory.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_fixtures.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_pool.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/db_validation_helper.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/environment_validator.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/file_reader.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/graphql_client.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/logger.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/observability.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/response_validator.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/screenshot_helper.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/soap_client.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/tdm.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/transformation_validator.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/ui_db_validator.py +0 -0
- {pyplaykit-1.4.0 → pyplaykit-1.5.0}/utils/yaml_config_reader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyplaykit
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Enterprise Python + Playwright automation framework
|
|
5
5
|
Author-email: Shan Konduru <shankonduru@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -49,10 +49,11 @@ PyPlayKit is a **multi-project enterprise test automation framework** built with
|
|
|
49
49
|
- 👥 **Scalable**: Designed for 50-100 QE engineers working simultaneously
|
|
50
50
|
- 🎯 **Zero Merge Conflicts**: Project-first organization eliminates conflicts
|
|
51
51
|
- 📊 **Data Comparison**: File-to-File, File-to-DB, DB-to-File validation (Excel, CSV, JSON)
|
|
52
|
+
- 📈 **Interactive HTML Reports**: Filterable validation reports with KPIs ⭐ **NEW**
|
|
52
53
|
- 📚 **Comprehensive Documentation**: 8,700+ lines across 15+ guides
|
|
53
54
|
- 🔧 **Extensible**: Plugin architecture with orchestration support
|
|
54
55
|
- 🔍 **Self-Healing**: Locator recovery with fallback chains
|
|
55
|
-
-
|
|
56
|
+
- 🔭 **Observable**: Comprehensive metrics and reporting
|
|
56
57
|
|
|
57
58
|
---
|
|
58
59
|
|
|
@@ -509,6 +510,42 @@ pytest examples/data_comparison_examples.py -v -s
|
|
|
509
510
|
pytest examples/data_comparison_examples.py::test_excel_to_excel_basic -v -s
|
|
510
511
|
```
|
|
511
512
|
|
|
513
|
+
### Interactive HTML Reports ⭐ **NEW**
|
|
514
|
+
|
|
515
|
+
Generate filterable, searchable HTML validation reports:
|
|
516
|
+
|
|
517
|
+
```python
|
|
518
|
+
from utils.data_comparison_report import DataComparisonReport
|
|
519
|
+
|
|
520
|
+
# Initialize report
|
|
521
|
+
report = DataComparisonReport()
|
|
522
|
+
report.set_comparison_type("FILE_TO_FILE (Excel)")
|
|
523
|
+
report.set_source("baseline.xlsx", 100)
|
|
524
|
+
report.set_target("current.xlsx", 100)
|
|
525
|
+
|
|
526
|
+
# Add mismatch
|
|
527
|
+
report.add_mismatch(
|
|
528
|
+
mismatch_type="VALUE_MISMATCH",
|
|
529
|
+
row_index=5,
|
|
530
|
+
mismatching_fields=["Price"],
|
|
531
|
+
source_value=99.99,
|
|
532
|
+
target_value=100.00,
|
|
533
|
+
reason="Price increased by $0.01"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# Generate interactive HTML report
|
|
537
|
+
report.generate_report("reports/data_validation/report.html")
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**Report Features:**
|
|
541
|
+
- 📊 KPI dashboard with pass/fail rates
|
|
542
|
+
- 🔍 Column-level filtering and search
|
|
543
|
+
- 📋 Row-by-row mismatch details
|
|
544
|
+
- 🎨 Color-coded status indicators
|
|
545
|
+
- 📱 Mobile-responsive design
|
|
546
|
+
|
|
547
|
+
See **[examples/data_comparison_with_html_report.py](examples/data_comparison_with_html_report.py)** for complete examples.
|
|
548
|
+
|
|
512
549
|
### Full Documentation
|
|
513
550
|
|
|
514
551
|
- **[docs/PIP_INSTALL_GUIDE.md](docs/PIP_INSTALL_GUIDE.md#step-8--data-comparison-patterns)** - Complete patterns guide
|
|
@@ -13,10 +13,11 @@ PyPlayKit is a **multi-project enterprise test automation framework** built with
|
|
|
13
13
|
- 👥 **Scalable**: Designed for 50-100 QE engineers working simultaneously
|
|
14
14
|
- 🎯 **Zero Merge Conflicts**: Project-first organization eliminates conflicts
|
|
15
15
|
- 📊 **Data Comparison**: File-to-File, File-to-DB, DB-to-File validation (Excel, CSV, JSON)
|
|
16
|
+
- 📈 **Interactive HTML Reports**: Filterable validation reports with KPIs ⭐ **NEW**
|
|
16
17
|
- 📚 **Comprehensive Documentation**: 8,700+ lines across 15+ guides
|
|
17
18
|
- 🔧 **Extensible**: Plugin architecture with orchestration support
|
|
18
19
|
- 🔍 **Self-Healing**: Locator recovery with fallback chains
|
|
19
|
-
-
|
|
20
|
+
- 🔭 **Observable**: Comprehensive metrics and reporting
|
|
20
21
|
|
|
21
22
|
---
|
|
22
23
|
|
|
@@ -473,6 +474,42 @@ pytest examples/data_comparison_examples.py -v -s
|
|
|
473
474
|
pytest examples/data_comparison_examples.py::test_excel_to_excel_basic -v -s
|
|
474
475
|
```
|
|
475
476
|
|
|
477
|
+
### Interactive HTML Reports ⭐ **NEW**
|
|
478
|
+
|
|
479
|
+
Generate filterable, searchable HTML validation reports:
|
|
480
|
+
|
|
481
|
+
```python
|
|
482
|
+
from utils.data_comparison_report import DataComparisonReport
|
|
483
|
+
|
|
484
|
+
# Initialize report
|
|
485
|
+
report = DataComparisonReport()
|
|
486
|
+
report.set_comparison_type("FILE_TO_FILE (Excel)")
|
|
487
|
+
report.set_source("baseline.xlsx", 100)
|
|
488
|
+
report.set_target("current.xlsx", 100)
|
|
489
|
+
|
|
490
|
+
# Add mismatch
|
|
491
|
+
report.add_mismatch(
|
|
492
|
+
mismatch_type="VALUE_MISMATCH",
|
|
493
|
+
row_index=5,
|
|
494
|
+
mismatching_fields=["Price"],
|
|
495
|
+
source_value=99.99,
|
|
496
|
+
target_value=100.00,
|
|
497
|
+
reason="Price increased by $0.01"
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# Generate interactive HTML report
|
|
501
|
+
report.generate_report("reports/data_validation/report.html")
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
**Report Features:**
|
|
505
|
+
- 📊 KPI dashboard with pass/fail rates
|
|
506
|
+
- 🔍 Column-level filtering and search
|
|
507
|
+
- 📋 Row-by-row mismatch details
|
|
508
|
+
- 🎨 Color-coded status indicators
|
|
509
|
+
- 📱 Mobile-responsive design
|
|
510
|
+
|
|
511
|
+
See **[examples/data_comparison_with_html_report.py](examples/data_comparison_with_html_report.py)** for complete examples.
|
|
512
|
+
|
|
476
513
|
### Full Documentation
|
|
477
514
|
|
|
478
515
|
- **[docs/PIP_INSTALL_GUIDE.md](docs/PIP_INSTALL_GUIDE.md#step-8--data-comparison-patterns)** - Complete patterns guide
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyplaykit
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Enterprise Python + Playwright automation framework
|
|
5
5
|
Author-email: Shan Konduru <shankonduru@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -49,10 +49,11 @@ PyPlayKit is a **multi-project enterprise test automation framework** built with
|
|
|
49
49
|
- 👥 **Scalable**: Designed for 50-100 QE engineers working simultaneously
|
|
50
50
|
- 🎯 **Zero Merge Conflicts**: Project-first organization eliminates conflicts
|
|
51
51
|
- 📊 **Data Comparison**: File-to-File, File-to-DB, DB-to-File validation (Excel, CSV, JSON)
|
|
52
|
+
- 📈 **Interactive HTML Reports**: Filterable validation reports with KPIs ⭐ **NEW**
|
|
52
53
|
- 📚 **Comprehensive Documentation**: 8,700+ lines across 15+ guides
|
|
53
54
|
- 🔧 **Extensible**: Plugin architecture with orchestration support
|
|
54
55
|
- 🔍 **Self-Healing**: Locator recovery with fallback chains
|
|
55
|
-
-
|
|
56
|
+
- 🔭 **Observable**: Comprehensive metrics and reporting
|
|
56
57
|
|
|
57
58
|
---
|
|
58
59
|
|
|
@@ -509,6 +510,42 @@ pytest examples/data_comparison_examples.py -v -s
|
|
|
509
510
|
pytest examples/data_comparison_examples.py::test_excel_to_excel_basic -v -s
|
|
510
511
|
```
|
|
511
512
|
|
|
513
|
+
### Interactive HTML Reports ⭐ **NEW**
|
|
514
|
+
|
|
515
|
+
Generate filterable, searchable HTML validation reports:
|
|
516
|
+
|
|
517
|
+
```python
|
|
518
|
+
from utils.data_comparison_report import DataComparisonReport
|
|
519
|
+
|
|
520
|
+
# Initialize report
|
|
521
|
+
report = DataComparisonReport()
|
|
522
|
+
report.set_comparison_type("FILE_TO_FILE (Excel)")
|
|
523
|
+
report.set_source("baseline.xlsx", 100)
|
|
524
|
+
report.set_target("current.xlsx", 100)
|
|
525
|
+
|
|
526
|
+
# Add mismatch
|
|
527
|
+
report.add_mismatch(
|
|
528
|
+
mismatch_type="VALUE_MISMATCH",
|
|
529
|
+
row_index=5,
|
|
530
|
+
mismatching_fields=["Price"],
|
|
531
|
+
source_value=99.99,
|
|
532
|
+
target_value=100.00,
|
|
533
|
+
reason="Price increased by $0.01"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# Generate interactive HTML report
|
|
537
|
+
report.generate_report("reports/data_validation/report.html")
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
**Report Features:**
|
|
541
|
+
- 📊 KPI dashboard with pass/fail rates
|
|
542
|
+
- 🔍 Column-level filtering and search
|
|
543
|
+
- 📋 Row-by-row mismatch details
|
|
544
|
+
- 🎨 Color-coded status indicators
|
|
545
|
+
- 📱 Mobile-responsive design
|
|
546
|
+
|
|
547
|
+
See **[examples/data_comparison_with_html_report.py](examples/data_comparison_with_html_report.py)** for complete examples.
|
|
548
|
+
|
|
512
549
|
### Full Documentation
|
|
513
550
|
|
|
514
551
|
- **[docs/PIP_INSTALL_GUIDE.md](docs/PIP_INSTALL_GUIDE.md#step-8--data-comparison-patterns)** - Complete patterns guide
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTML/CSS Data Comparison Report Generator for PyPlayKit.
|
|
3
|
+
|
|
4
|
+
Generates interactive, filterable HTML reports for data validation scenarios:
|
|
5
|
+
- File-to-File comparison (Excel, CSV, JSON)
|
|
6
|
+
- File-to-Database comparison
|
|
7
|
+
- Database-to-File comparison
|
|
8
|
+
|
|
9
|
+
Features:
|
|
10
|
+
- Overall validation status with KPIs
|
|
11
|
+
- Validation summary section
|
|
12
|
+
- Filterable/searchable discrepancy table
|
|
13
|
+
- Row-by-row mismatch details
|
|
14
|
+
- Field-level difference highlighting
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DataComparisonReport:
|
|
26
|
+
"""Generate interactive HTML reports for data validation results."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self.comparison_type = "UNKNOWN"
|
|
30
|
+
self.source_name = ""
|
|
31
|
+
self.target_name = ""
|
|
32
|
+
self.source_count = 0
|
|
33
|
+
self.target_count = 0
|
|
34
|
+
self.passed_count = 0
|
|
35
|
+
self.failed_count = 0
|
|
36
|
+
self.source_only_count = 0
|
|
37
|
+
self.target_only_count = 0
|
|
38
|
+
self.mismatches: list[dict[str, Any]] = []
|
|
39
|
+
self.validation_summary = ""
|
|
40
|
+
self.metadata: dict[str, str] = {}
|
|
41
|
+
|
|
42
|
+
def set_comparison_type(self, comp_type: str) -> None:
|
|
43
|
+
"""Set the comparison type (FILE_TO_FILE, FILE_TO_DB, DB_TO_FILE)."""
|
|
44
|
+
self.comparison_type = comp_type
|
|
45
|
+
|
|
46
|
+
def set_source(self, name: str, count: int) -> None:
|
|
47
|
+
"""Set source details."""
|
|
48
|
+
self.source_name = name
|
|
49
|
+
self.source_count = count
|
|
50
|
+
|
|
51
|
+
def set_target(self, name: str, count: int) -> None:
|
|
52
|
+
"""Set target details."""
|
|
53
|
+
self.target_name = name
|
|
54
|
+
self.target_count = count
|
|
55
|
+
|
|
56
|
+
def set_counts(self, passed: int, failed: int, source_only: int = 0, target_only: int = 0) -> None:
|
|
57
|
+
"""Set validation result counts."""
|
|
58
|
+
self.passed_count = passed
|
|
59
|
+
self.failed_count = failed
|
|
60
|
+
self.source_only_count = source_only
|
|
61
|
+
self.target_only_count = target_only
|
|
62
|
+
|
|
63
|
+
def add_mismatch(
|
|
64
|
+
self,
|
|
65
|
+
mismatch_type: str,
|
|
66
|
+
row_index: int | None,
|
|
67
|
+
mismatching_fields: list[str],
|
|
68
|
+
source_value: Any,
|
|
69
|
+
target_value: Any,
|
|
70
|
+
reason: str,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Add a single mismatch record."""
|
|
73
|
+
self.mismatches.append({
|
|
74
|
+
"type": mismatch_type,
|
|
75
|
+
"row": row_index,
|
|
76
|
+
"mismatching_fields": mismatching_fields,
|
|
77
|
+
"source_value": self._safe_str(source_value),
|
|
78
|
+
"target_value": self._safe_str(target_value),
|
|
79
|
+
"reason": reason,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
def set_validation_summary(self, summary: str) -> None:
|
|
83
|
+
"""Set the validation summary text."""
|
|
84
|
+
self.validation_summary = summary
|
|
85
|
+
|
|
86
|
+
def add_metadata(self, key: str, value: str) -> None:
|
|
87
|
+
"""Add metadata key-value pair."""
|
|
88
|
+
self.metadata[key] = value
|
|
89
|
+
|
|
90
|
+
def generate_report(self, output_path: str | Path) -> None:
|
|
91
|
+
"""Generate and save the HTML report."""
|
|
92
|
+
output_path = Path(output_path)
|
|
93
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
|
|
95
|
+
html_content = self._build_html()
|
|
96
|
+
|
|
97
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
98
|
+
f.write(html_content)
|
|
99
|
+
|
|
100
|
+
def _safe_str(self, value: Any) -> str:
|
|
101
|
+
"""Safely convert value to string."""
|
|
102
|
+
if value is None:
|
|
103
|
+
return "NULL"
|
|
104
|
+
if isinstance(value, (dict, list)):
|
|
105
|
+
return json.dumps(value, default=str)
|
|
106
|
+
return str(value)
|
|
107
|
+
|
|
108
|
+
def _calculate_pass_rate(self) -> float:
|
|
109
|
+
"""Calculate pass rate percentage."""
|
|
110
|
+
total = self.passed_count + self.failed_count
|
|
111
|
+
if total == 0:
|
|
112
|
+
return 0.0
|
|
113
|
+
return (self.passed_count / total) * 100
|
|
114
|
+
|
|
115
|
+
def _calculate_fail_rate(self) -> float:
|
|
116
|
+
"""Calculate fail rate percentage."""
|
|
117
|
+
total = self.passed_count + self.failed_count
|
|
118
|
+
if total == 0:
|
|
119
|
+
return 0.0
|
|
120
|
+
return (self.failed_count / total) * 100
|
|
121
|
+
|
|
122
|
+
def _get_overall_status(self) -> str:
|
|
123
|
+
"""Determine overall validation status."""
|
|
124
|
+
if self.failed_count == 0 and self.source_only_count == 0 and self.target_only_count == 0:
|
|
125
|
+
return "PASSED"
|
|
126
|
+
return "FAILED"
|
|
127
|
+
|
|
128
|
+
def _build_html(self) -> str:
|
|
129
|
+
"""Build complete HTML report."""
|
|
130
|
+
overall_status = self._get_overall_status()
|
|
131
|
+
pass_rate = self._calculate_pass_rate()
|
|
132
|
+
fail_rate = self._calculate_fail_rate()
|
|
133
|
+
generated_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
134
|
+
|
|
135
|
+
# Build mismatch table rows
|
|
136
|
+
mismatch_rows = ""
|
|
137
|
+
if not self.mismatches:
|
|
138
|
+
mismatch_rows = "<tr><td colspan='6' style='text-align:center;color:#15803d;font-weight:600;'>✓ No discrepancies found</td></tr>"
|
|
139
|
+
else:
|
|
140
|
+
for idx, mismatch in enumerate(self.mismatches, 1):
|
|
141
|
+
row_display = str(mismatch["row"]) if mismatch["row"] is not None else "N/A"
|
|
142
|
+
fields_display = ", ".join(mismatch["mismatching_fields"]) if mismatch["mismatching_fields"] else "N/A"
|
|
143
|
+
|
|
144
|
+
mismatch_rows += f"""
|
|
145
|
+
<tr>
|
|
146
|
+
<td>{self._escape_html(mismatch['type'])}</td>
|
|
147
|
+
<td>{row_display}</td>
|
|
148
|
+
<td>{self._escape_html(fields_display)}</td>
|
|
149
|
+
<td><div style="max-width:300px;overflow:auto;">{self._escape_html(mismatch['source_value'])}</div></td>
|
|
150
|
+
<td><div style="max-width:300px;overflow:auto;">{self._escape_html(mismatch['target_value'])}</div></td>
|
|
151
|
+
<td>{self._escape_html(mismatch['reason'])}</td>
|
|
152
|
+
</tr>"""
|
|
153
|
+
|
|
154
|
+
# Build metadata section
|
|
155
|
+
metadata_html = ""
|
|
156
|
+
for key, value in self.metadata.items():
|
|
157
|
+
metadata_html += f'<div><strong>{self._escape_html(key)}:</strong> {self._escape_html(value)}</div>\n '
|
|
158
|
+
|
|
159
|
+
# Status pill class
|
|
160
|
+
status_class = "status-pill-success" if overall_status == "PASSED" else "status-pill-danger"
|
|
161
|
+
|
|
162
|
+
return f"""<!doctype html>
|
|
163
|
+
<html lang="en">
|
|
164
|
+
<head>
|
|
165
|
+
<meta charset="utf-8" />
|
|
166
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
167
|
+
<title>PyPlayKit Data Validation Report</title>
|
|
168
|
+
<style>
|
|
169
|
+
:root {{
|
|
170
|
+
--bg: #f4f7fb;
|
|
171
|
+
--surface: #ffffff;
|
|
172
|
+
--line: #d9e2ec;
|
|
173
|
+
--text: #0f172a;
|
|
174
|
+
--muted: #475569;
|
|
175
|
+
--brand: #0b3b66;
|
|
176
|
+
}}
|
|
177
|
+
|
|
178
|
+
* {{ box-sizing: border-box; }}
|
|
179
|
+
|
|
180
|
+
body {{
|
|
181
|
+
margin: 0;
|
|
182
|
+
font-family: "Segoe UI", Tahoma, sans-serif;
|
|
183
|
+
color: var(--text);
|
|
184
|
+
background: radial-gradient(circle at top right, #d9ebff, var(--bg) 35%);
|
|
185
|
+
min-height: 100vh;
|
|
186
|
+
display: flex;
|
|
187
|
+
flex-direction: column;
|
|
188
|
+
}}
|
|
189
|
+
|
|
190
|
+
.page {{
|
|
191
|
+
max-width: 1400px;
|
|
192
|
+
width: 100%;
|
|
193
|
+
margin: 0 auto;
|
|
194
|
+
padding: 24px;
|
|
195
|
+
flex: 1;
|
|
196
|
+
}}
|
|
197
|
+
|
|
198
|
+
.brand-header {{
|
|
199
|
+
background: linear-gradient(130deg, var(--brand), #155a96);
|
|
200
|
+
color: #fff;
|
|
201
|
+
border-radius: 14px;
|
|
202
|
+
padding: 20px;
|
|
203
|
+
box-shadow: 0 8px 24px rgba(17, 24, 39, 0.12);
|
|
204
|
+
margin-bottom: 18px;
|
|
205
|
+
}}
|
|
206
|
+
|
|
207
|
+
.brand-title {{ margin: 0; font-size: 30px; letter-spacing: 0.4px; }}
|
|
208
|
+
.brand-sub {{ margin: 6px 0 0 0; opacity: 0.92; }}
|
|
209
|
+
|
|
210
|
+
.status-pill-success {{
|
|
211
|
+
display: inline-block;
|
|
212
|
+
margin-top: 14px;
|
|
213
|
+
padding: 8px 14px;
|
|
214
|
+
border-radius: 999px;
|
|
215
|
+
font-weight: 700;
|
|
216
|
+
color: #0f5132;
|
|
217
|
+
background: #dcfce7;
|
|
218
|
+
border: 1px solid #14532d;
|
|
219
|
+
}}
|
|
220
|
+
|
|
221
|
+
.status-pill-danger {{
|
|
222
|
+
display: inline-block;
|
|
223
|
+
margin-top: 14px;
|
|
224
|
+
padding: 8px 14px;
|
|
225
|
+
border-radius: 999px;
|
|
226
|
+
font-weight: 700;
|
|
227
|
+
color: #991b1b;
|
|
228
|
+
background: #fee2e2;
|
|
229
|
+
border: 1px solid #7f1d1d;
|
|
230
|
+
}}
|
|
231
|
+
|
|
232
|
+
.kpi-grid {{
|
|
233
|
+
display: grid;
|
|
234
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
235
|
+
gap: 12px;
|
|
236
|
+
margin-bottom: 16px;
|
|
237
|
+
}}
|
|
238
|
+
|
|
239
|
+
.kpi-card {{
|
|
240
|
+
background: var(--surface);
|
|
241
|
+
border: 1px solid var(--line);
|
|
242
|
+
border-radius: 12px;
|
|
243
|
+
padding: 12px 14px;
|
|
244
|
+
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.05);
|
|
245
|
+
}}
|
|
246
|
+
|
|
247
|
+
.kpi-card.success {{
|
|
248
|
+
background: #f0fdf4;
|
|
249
|
+
border-color: #86efac;
|
|
250
|
+
box-shadow: 0 4px 14px rgba(34, 197, 94, 0.15);
|
|
251
|
+
}}
|
|
252
|
+
|
|
253
|
+
.kpi-card.danger {{
|
|
254
|
+
background: #fef2f2;
|
|
255
|
+
border-color: #fca5a5;
|
|
256
|
+
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.15);
|
|
257
|
+
}}
|
|
258
|
+
|
|
259
|
+
.kpi-card.warning {{
|
|
260
|
+
background: #fffbeb;
|
|
261
|
+
border-color: #fcd34d;
|
|
262
|
+
box-shadow: 0 4px 14px rgba(245, 158, 11, 0.15);
|
|
263
|
+
}}
|
|
264
|
+
|
|
265
|
+
.kpi-label {{
|
|
266
|
+
color: var(--muted);
|
|
267
|
+
font-size: 12px;
|
|
268
|
+
text-transform: uppercase;
|
|
269
|
+
letter-spacing: 0.4px;
|
|
270
|
+
margin-bottom: 6px;
|
|
271
|
+
}}
|
|
272
|
+
|
|
273
|
+
.kpi-card.success .kpi-label {{ color: #15803d; }}
|
|
274
|
+
.kpi-card.danger .kpi-label {{ color: #991b1b; }}
|
|
275
|
+
.kpi-card.warning .kpi-label {{ color: #92400e; }}
|
|
276
|
+
|
|
277
|
+
.kpi-value {{ font-size: 24px; font-weight: 700; line-height: 1; }}
|
|
278
|
+
|
|
279
|
+
.kpi-card.success .kpi-value {{ color: #15803d; }}
|
|
280
|
+
.kpi-card.danger .kpi-value {{ color: #dc2626; }}
|
|
281
|
+
.kpi-card.warning .kpi-value {{ color: #d97706; }}
|
|
282
|
+
|
|
283
|
+
.card {{
|
|
284
|
+
background: var(--surface);
|
|
285
|
+
border: 1px solid var(--line);
|
|
286
|
+
border-radius: 12px;
|
|
287
|
+
padding: 16px;
|
|
288
|
+
margin-bottom: 16px;
|
|
289
|
+
}}
|
|
290
|
+
|
|
291
|
+
.meta-grid {{
|
|
292
|
+
display: grid;
|
|
293
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
294
|
+
gap: 8px 16px;
|
|
295
|
+
color: var(--muted);
|
|
296
|
+
}}
|
|
297
|
+
|
|
298
|
+
.table-wrap {{
|
|
299
|
+
width: 100%;
|
|
300
|
+
overflow-x: auto;
|
|
301
|
+
overflow-y: auto;
|
|
302
|
+
max-height: 60vh;
|
|
303
|
+
border: 1px solid var(--line);
|
|
304
|
+
border-radius: 10px;
|
|
305
|
+
}}
|
|
306
|
+
|
|
307
|
+
table {{
|
|
308
|
+
width: max-content;
|
|
309
|
+
min-width: 100%;
|
|
310
|
+
border-collapse: collapse;
|
|
311
|
+
font-size: 13px;
|
|
312
|
+
}}
|
|
313
|
+
|
|
314
|
+
th, td {{
|
|
315
|
+
border: 1px solid #cfd8e3;
|
|
316
|
+
padding: 8px;
|
|
317
|
+
text-align: left;
|
|
318
|
+
vertical-align: top;
|
|
319
|
+
}}
|
|
320
|
+
|
|
321
|
+
th {{
|
|
322
|
+
background: #eef4fb;
|
|
323
|
+
position: sticky;
|
|
324
|
+
top: 0;
|
|
325
|
+
z-index: 2;
|
|
326
|
+
font-weight: 600;
|
|
327
|
+
}}
|
|
328
|
+
|
|
329
|
+
.filters th {{
|
|
330
|
+
background: #f8fafc;
|
|
331
|
+
padding: 6px;
|
|
332
|
+
position: sticky;
|
|
333
|
+
top: 38px;
|
|
334
|
+
z-index: 1;
|
|
335
|
+
}}
|
|
336
|
+
|
|
337
|
+
.filters input {{
|
|
338
|
+
width: 100%;
|
|
339
|
+
border: 1px solid #cbd5e1;
|
|
340
|
+
border-radius: 6px;
|
|
341
|
+
padding: 6px 8px;
|
|
342
|
+
font-size: 12px;
|
|
343
|
+
}}
|
|
344
|
+
|
|
345
|
+
.filters input:focus {{
|
|
346
|
+
outline: none;
|
|
347
|
+
border-color: #3b82f6;
|
|
348
|
+
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
349
|
+
}}
|
|
350
|
+
|
|
351
|
+
.footer {{
|
|
352
|
+
background: #0b3b66;
|
|
353
|
+
color: #e2ecf7;
|
|
354
|
+
padding: 16px 24px;
|
|
355
|
+
text-align: center;
|
|
356
|
+
font-size: 14px;
|
|
357
|
+
}}
|
|
358
|
+
|
|
359
|
+
.footer a {{
|
|
360
|
+
color: #f8bf3b;
|
|
361
|
+
text-decoration: none;
|
|
362
|
+
font-weight: 600;
|
|
363
|
+
}}
|
|
364
|
+
|
|
365
|
+
.footer a:hover {{
|
|
366
|
+
text-decoration: underline;
|
|
367
|
+
}}
|
|
368
|
+
|
|
369
|
+
@media (max-width: 720px) {{
|
|
370
|
+
.brand-title {{ font-size: 24px; }}
|
|
371
|
+
.filters th {{ top: 74px; }}
|
|
372
|
+
.kpi-grid {{ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }}
|
|
373
|
+
}}
|
|
374
|
+
</style>
|
|
375
|
+
</head>
|
|
376
|
+
<body>
|
|
377
|
+
<main class="page">
|
|
378
|
+
<header class="brand-header">
|
|
379
|
+
<h1 class="brand-title">PyPlayKit Data Validation Report</h1>
|
|
380
|
+
<p class="brand-sub">Data Comparison & Validation | Automated Reconciliation</p>
|
|
381
|
+
<span class="{status_class}">{overall_status}</span>
|
|
382
|
+
</header>
|
|
383
|
+
|
|
384
|
+
<section class="kpi-grid">
|
|
385
|
+
<article class="kpi-card"><div class="kpi-label">Source Count</div><div class="kpi-value">{self.source_count}</div></article>
|
|
386
|
+
<article class="kpi-card"><div class="kpi-label">Target Count</div><div class="kpi-value">{self.target_count}</div></article>
|
|
387
|
+
<article class="kpi-card success"><div class="kpi-label">Passed</div><div class="kpi-value">{self.passed_count}</div></article>
|
|
388
|
+
<article class="kpi-card danger"><div class="kpi-label">Failed</div><div class="kpi-value">{self.failed_count}</div></article>
|
|
389
|
+
<article class="kpi-card success"><div class="kpi-label">Pass Rate</div><div class="kpi-value">{pass_rate:.2f}%</div></article>
|
|
390
|
+
<article class="kpi-card danger"><div class="kpi-label">Fail Rate</div><div class="kpi-value">{fail_rate:.2f}%</div></article>
|
|
391
|
+
<article class="kpi-card warning"><div class="kpi-label">Source Only</div><div class="kpi-value">{self.source_only_count}</div></article>
|
|
392
|
+
<article class="kpi-card warning"><div class="kpi-label">Target Only</div><div class="kpi-value">{self.target_only_count}</div></article>
|
|
393
|
+
</section>
|
|
394
|
+
|
|
395
|
+
<section class="card">
|
|
396
|
+
<div class="meta-grid">
|
|
397
|
+
<div><strong>Comparison Type:</strong> {self.comparison_type}</div>
|
|
398
|
+
<div><strong>Generated:</strong> {generated_timestamp}</div>
|
|
399
|
+
<div><strong>Source:</strong> {self._escape_html(self.source_name)}</div>
|
|
400
|
+
<div><strong>Target:</strong> {self._escape_html(self.target_name)}</div>
|
|
401
|
+
{metadata_html}
|
|
402
|
+
</div>
|
|
403
|
+
<p style="margin-top:12px;"><strong>Summary:</strong> {self._escape_html(self.validation_summary)}</p>
|
|
404
|
+
</section>
|
|
405
|
+
|
|
406
|
+
<section class="card">
|
|
407
|
+
<h2 style="margin-top:0;">Discrepancy Details</h2>
|
|
408
|
+
<p style="color: var(--muted); margin-top: -4px;">Use the filter boxes in each column to search and narrow results. Total discrepancies: <strong>{len(self.mismatches)}</strong></p>
|
|
409
|
+
<div class="table-wrap">
|
|
410
|
+
<table id="mismatchTable">
|
|
411
|
+
<thead>
|
|
412
|
+
<tr>
|
|
413
|
+
<th>Type</th>
|
|
414
|
+
<th>Row</th>
|
|
415
|
+
<th>Mismatching Fields</th>
|
|
416
|
+
<th>Source Value</th>
|
|
417
|
+
<th>Target Value</th>
|
|
418
|
+
<th>Reason</th>
|
|
419
|
+
</tr>
|
|
420
|
+
<tr class="filters">
|
|
421
|
+
<th><input type="text" oninput="filterTable(0, this.value)" placeholder="Filter type" /></th>
|
|
422
|
+
<th><input type="text" oninput="filterTable(1, this.value)" placeholder="Filter row" /></th>
|
|
423
|
+
<th><input type="text" oninput="filterTable(2, this.value)" placeholder="Filter fields" /></th>
|
|
424
|
+
<th><input type="text" oninput="filterTable(3, this.value)" placeholder="Filter source" /></th>
|
|
425
|
+
<th><input type="text" oninput="filterTable(4, this.value)" placeholder="Filter target" /></th>
|
|
426
|
+
<th><input type="text" oninput="filterTable(5, this.value)" placeholder="Filter reason" /></th>
|
|
427
|
+
</tr>
|
|
428
|
+
</thead>
|
|
429
|
+
<tbody>
|
|
430
|
+
{mismatch_rows}
|
|
431
|
+
</tbody>
|
|
432
|
+
</table>
|
|
433
|
+
</div>
|
|
434
|
+
</section>
|
|
435
|
+
</main>
|
|
436
|
+
|
|
437
|
+
<footer class="footer">
|
|
438
|
+
<div>PyPlayKit Framework - Enterprise Test Automation | Designed by <a href="https://www.linkedin.com/in/shankonduru" target="_blank" rel="noopener noreferrer">ShanKonduru</a></div>
|
|
439
|
+
<div>Available at <a href="https://github.com/ShanKonduru/pyplaykit" target="_blank" rel="noopener noreferrer">GitHub</a></div>
|
|
440
|
+
</footer>
|
|
441
|
+
|
|
442
|
+
<script>
|
|
443
|
+
const activeFilters = ["", "", "", "", "", ""];
|
|
444
|
+
|
|
445
|
+
function filterTable(columnIndex, searchValue) {{
|
|
446
|
+
activeFilters[columnIndex] = (searchValue || "").toLowerCase();
|
|
447
|
+
const table = document.getElementById("mismatchTable");
|
|
448
|
+
const rows = table.tBodies[0].rows;
|
|
449
|
+
|
|
450
|
+
let visibleCount = 0;
|
|
451
|
+
|
|
452
|
+
for (let i = 0; i < rows.length; i += 1) {{
|
|
453
|
+
let showRow = true;
|
|
454
|
+
|
|
455
|
+
for (let col = 0; col < activeFilters.length; col += 1) {{
|
|
456
|
+
const term = activeFilters[col];
|
|
457
|
+
if (!term) {{
|
|
458
|
+
continue;
|
|
459
|
+
}}
|
|
460
|
+
|
|
461
|
+
const cell = rows[i].cells[col];
|
|
462
|
+
const text = (cell ? cell.textContent : "").toLowerCase();
|
|
463
|
+
if (text.indexOf(term) === -1) {{
|
|
464
|
+
showRow = false;
|
|
465
|
+
break;
|
|
466
|
+
}}
|
|
467
|
+
}}
|
|
468
|
+
|
|
469
|
+
rows[i].style.display = showRow ? "" : "none";
|
|
470
|
+
if (showRow) visibleCount++;
|
|
471
|
+
}}
|
|
472
|
+
}}
|
|
473
|
+
</script>
|
|
474
|
+
</body>
|
|
475
|
+
</html>"""
|
|
476
|
+
|
|
477
|
+
def _escape_html(self, text: str) -> str:
|
|
478
|
+
"""Escape HTML special characters."""
|
|
479
|
+
if not isinstance(text, str):
|
|
480
|
+
text = str(text)
|
|
481
|
+
return (
|
|
482
|
+
text.replace("&", "&")
|
|
483
|
+
.replace("<", "<")
|
|
484
|
+
.replace(">", ">")
|
|
485
|
+
.replace('"', """)
|
|
486
|
+
.replace("'", "'")
|
|
487
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|