snapline-core 0.1.10__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.
@@ -0,0 +1,5 @@
1
+ /*.iml
2
+ .idea
3
+ dist/
4
+ build/
5
+ *.egg-info/
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: snapline-core
3
+ Version: 0.1.10
4
+ Summary: Declarative snapshot and reconciliation testing — Snapline core
5
+ Project-URL: Homepage, https://github.com/vaagatech/snapline-python
6
+ Project-URL: Repository, https://github.com/vaagatech/snapline-python
7
+ Author-email: VaagaTech <info@vaagatech.com>
8
+ License-Expression: MIT
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: snapline-api-adapters==0.1.10
11
+ Requires-Dist: snapline-auth-adapters==0.1.10
12
+ Requires-Dist: snapline-engine==0.1.10
@@ -0,0 +1,19 @@
1
+ """Basic Snapline usage example."""
2
+ from snapline.engine import reconcile
3
+
4
+ live = {"email": "alice@example.com", "status": "synced", "pincode": "123456"}
5
+ expected = {"email": "alice@example.com", "status": "synced"}
6
+
7
+ result = reconcile(
8
+ live,
9
+ expected,
10
+ {
11
+ "ignoreFields": ["pincode"],
12
+ "transformations": {
13
+ "status": lambda value, _key=None, _parent=None: str(value).upper(),
14
+ },
15
+ },
16
+ )
17
+
18
+ print("match:", result["match"])
19
+ print("diff:", result["diff"])
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "snapline-core"
7
+ version = "0.1.10"
8
+ description = "Declarative snapshot and reconciliation testing — Snapline core"
9
+ license = "MIT"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "VaagaTech", email = "info@vaagatech.com" }]
12
+ dependencies = [
13
+ "snapline-engine==0.1.10",
14
+ "snapline-api-adapters==0.1.10",
15
+ "snapline-auth-adapters==0.1.10",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/vaagatech/snapline-python"
20
+ Repository = "https://github.com/vaagatech/snapline-python"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["snapline"]
@@ -0,0 +1,48 @@
1
+ from snapline.api_adapters import api, execute_api, resolve_url
2
+ from snapline.auth_adapters import AuthAdapter, auth
3
+ from snapline.engine import assert_against_file, load_json_file, reconcile
4
+
5
+ from .api_config.to_api_request_config import to_api_request_config
6
+ from .cross_system.run_api_to_db import run_api_to_db
7
+ from .cross_system.run_db_to_api import run_db_to_api
8
+ from .db import (
9
+ SqliteConnection,
10
+ create_sqlite_connection,
11
+ db,
12
+ exec_sqlite_file,
13
+ exec_sqlite_sql,
14
+ seed_db,
15
+ )
16
+ from .db.db_connection import DbConnection
17
+ from .db_comparison.run_db_comparison import run_db_comparison
18
+ from .reporting.write_report import build_report, render_report, write_test_report
19
+ from .test_suite import test_suite
20
+
21
+ execute_api_request = execute_api
22
+
23
+ __all__ = [
24
+ "AuthAdapter",
25
+ "DbConnection",
26
+ "SqliteConnection",
27
+ "api",
28
+ "assert_against_file",
29
+ "auth",
30
+ "build_report",
31
+ "create_sqlite_connection",
32
+ "db",
33
+ "execute_api",
34
+ "execute_api_request",
35
+ "exec_sqlite_file",
36
+ "exec_sqlite_sql",
37
+ "load_json_file",
38
+ "reconcile",
39
+ "render_report",
40
+ "resolve_url",
41
+ "run_api_to_db",
42
+ "run_db_comparison",
43
+ "run_db_to_api",
44
+ "seed_db",
45
+ "test_suite",
46
+ "to_api_request_config",
47
+ "write_test_report",
48
+ ]
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from ..types import ApiFileTestConfig, ApiRequestConfig
7
+
8
+
9
+ def to_api_request_config(config: ApiFileTestConfig | dict[str, Any]) -> ApiRequestConfig:
10
+ protocol = config.get("protocol")
11
+
12
+ if protocol == "soap":
13
+ return {
14
+ "protocol": "soap",
15
+ "endpoint": config.get("endpoint", "/"),
16
+ "soapAction": config.get("soapAction"),
17
+ "envelope": config.get("envelope"),
18
+ "inputFile": config.get("inputFile"),
19
+ "headers": config.get("headers"),
20
+ }
21
+
22
+ if protocol == "graphql":
23
+ return {
24
+ "protocol": "graphql",
25
+ "endpoint": config.get("endpoint", "/graphql"),
26
+ "query": config.get("query"),
27
+ "queryFile": config.get("queryFile"),
28
+ "variables": config.get("variables"),
29
+ "variablesFile": config.get("variablesFile"),
30
+ "inputFile": config.get("inputFile"),
31
+ "dataPath": config.get("dataPath"),
32
+ "headers": config.get("headers"),
33
+ }
34
+
35
+ return {
36
+ "protocol": "rest",
37
+ "endpoint": config.get("endpoint", "/"),
38
+ "method": config.get("method"),
39
+ "inputFile": config.get("inputFile"),
40
+ "body": config.get("body"),
41
+ "headers": config.get("headers"),
42
+ }
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from snapline.api_adapters import execute_api
6
+ from snapline.engine import reconcile
7
+
8
+ from ..types import ApiToDbConfig, CrossSystemResult
9
+
10
+
11
+ async def run_api_to_db(
12
+ config: ApiToDbConfig | dict[str, Any],
13
+ auth_headers: dict[str, str] | None = None,
14
+ base_url: str | None = None,
15
+ fetch_impl: Any | None = None,
16
+ ) -> CrossSystemResult:
17
+ api_config = dict(config["api"])
18
+ expected_status = api_config.pop("expectedStatus", 200)
19
+
20
+ response = execute_api(
21
+ api_config,
22
+ {
23
+ "baseUrl": base_url,
24
+ "authHeaders": auth_headers or {},
25
+ "fetchImpl": fetch_impl,
26
+ },
27
+ )
28
+
29
+ if response["status"] != expected_status:
30
+ return {
31
+ "match": False,
32
+ "source": response["data"],
33
+ "target": None,
34
+ "diff": {
35
+ "path": "(http)",
36
+ "actual": response["status"],
37
+ "expected": expected_status,
38
+ "message": f"Expected status {expected_status}, got {response['status']}",
39
+ },
40
+ }
41
+
42
+ rows = await config["db"]["db"].query(
43
+ config["db"]["query"],
44
+ config["db"].get("params", {}),
45
+ )
46
+ db_data = rows[0] if rows else None
47
+
48
+ result = reconcile(
49
+ response["data"],
50
+ db_data,
51
+ {
52
+ "ignoreFields": config.get("ignoreFields", []),
53
+ "transformations": config.get("transformations", {}),
54
+ "dataMapping": config.get("dataMapping", {}),
55
+ },
56
+ )
57
+
58
+ return {
59
+ "match": result["match"],
60
+ "source": result["processed"],
61
+ "target": result["expected"],
62
+ "diff": result["diff"],
63
+ }
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from snapline.api_adapters import execute_api
6
+ from snapline.engine import reconcile
7
+
8
+ from ..types import CrossSystemResult, DbToApiConfig
9
+
10
+
11
+ async def run_db_to_api(
12
+ config: DbToApiConfig | dict[str, Any],
13
+ auth_headers: dict[str, str] | None = None,
14
+ base_url: str | None = None,
15
+ fetch_impl: Any | None = None,
16
+ ) -> CrossSystemResult:
17
+ rows = await config["db"]["db"].query(
18
+ config["db"]["query"],
19
+ config["db"].get("params", {}),
20
+ )
21
+ db_data = rows[0] if rows else None
22
+
23
+ api_config = dict(config["api"])
24
+ expected_status = api_config.pop("expectedStatus", 200)
25
+ input_from_db = config.get("inputFromDb", True)
26
+
27
+ response = execute_api(
28
+ api_config,
29
+ {
30
+ "baseUrl": base_url,
31
+ "authHeaders": auth_headers or {},
32
+ "fetchImpl": fetch_impl,
33
+ "inputFromRow": db_data if input_from_db and db_data else None,
34
+ },
35
+ )
36
+
37
+ if response["status"] != expected_status:
38
+ return {
39
+ "match": False,
40
+ "source": db_data,
41
+ "target": response["data"],
42
+ "diff": {
43
+ "path": "(http)",
44
+ "actual": response["status"],
45
+ "expected": expected_status,
46
+ "message": f"Expected status {expected_status}, got {response['status']}",
47
+ },
48
+ }
49
+
50
+ result = reconcile(
51
+ db_data,
52
+ response["data"],
53
+ {
54
+ "ignoreFields": config.get("ignoreFields", []),
55
+ "transformations": config.get("transformations", {}),
56
+ "dataMapping": config.get("dataMapping", {}),
57
+ },
58
+ )
59
+
60
+ return {
61
+ "match": result["match"],
62
+ "source": result["processed"],
63
+ "target": result["expected"],
64
+ "diff": result["diff"],
65
+ }
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..types import DbRow
6
+ from .db_connection import DbConnection
7
+ from .sqlite_connection import (
8
+ SqliteConnection,
9
+ create_sqlite_connection,
10
+ exec_sqlite_file,
11
+ exec_sqlite_sql,
12
+ )
13
+
14
+ _db_registry: dict[str, list[DbRow]] = {}
15
+
16
+
17
+ def seed_db(connection_string: str, rows: list[DbRow]) -> None:
18
+ _db_registry[connection_string] = rows
19
+
20
+
21
+ def create_db_connection(dialect: str, connection_string: str) -> DbConnection:
22
+ seed = _db_registry.get(connection_string, [])
23
+ return DbConnection(dialect, connection_string, seed)
24
+
25
+
26
+ class DbFactory:
27
+ @staticmethod
28
+ def postgres(connection_string: str) -> DbConnection:
29
+ return create_db_connection("postgres", connection_string)
30
+
31
+ @staticmethod
32
+ def mysql(connection_string: str) -> DbConnection:
33
+ return create_db_connection("mysql", connection_string)
34
+
35
+ @staticmethod
36
+ def sqlite(path: str = ":memory:") -> SqliteConnection:
37
+ return create_sqlite_connection(path)
38
+
39
+ @staticmethod
40
+ def seed(connection_string: str, rows: list[DbRow]) -> None:
41
+ seed_db(connection_string, rows)
42
+
43
+
44
+ db = DbFactory()
45
+
46
+ __all__ = [
47
+ "SqliteConnection",
48
+ "create_sqlite_connection",
49
+ "db",
50
+ "exec_sqlite_file",
51
+ "exec_sqlite_sql",
52
+ "seed_db",
53
+ ]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from ..types import DbDialect, DbRow
7
+
8
+
9
+ class DbConnection:
10
+ def __init__(
11
+ self,
12
+ dialect: DbDialect,
13
+ connection_string: str,
14
+ rows: list[DbRow] | None = None,
15
+ ) -> None:
16
+ self.dialect = dialect
17
+ self.connection_string = connection_string
18
+ self._rows = list(rows or [])
19
+
20
+ async def query(self, query: str, params: dict[str, Any] | None = None) -> list[DbRow]:
21
+ normalized = re.sub(r"\s+", " ", query).strip().lower()
22
+ if not normalized.startswith("select"):
23
+ raise ValueError(f"Unsupported query: {query}")
24
+
25
+ results = list(self._rows)
26
+ params = params or {}
27
+
28
+ for key, value in params.items():
29
+ results = [row for row in results if row.get(key) == value]
30
+
31
+ columns_match = re.search(r"select\s+(.+?)\s+from", query, flags=re.IGNORECASE)
32
+ if columns_match and columns_match.group(1).strip() != "*":
33
+ columns = [column.strip() for column in columns_match.group(1).split(",")]
34
+ projected: list[DbRow] = []
35
+ for row in results:
36
+ projected.append({column: row[column] for column in columns if column in row})
37
+ results = projected
38
+
39
+ return results
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..types import DbRow
9
+
10
+
11
+ def _normalize_named_params(query: str) -> str:
12
+ return re.sub(r":([a-zA-Z_][a-zA-Z0-9_]*)", r"@\1", query)
13
+
14
+
15
+ class SqliteConnection:
16
+ def __init__(self, database: sqlite3.Connection) -> None:
17
+ self._database = database
18
+
19
+ def exec(self, sql: str) -> None:
20
+ self._database.executescript(sql)
21
+
22
+ async def query(self, query: str, params: dict[str, Any] | None = None) -> list[DbRow]:
23
+ normalized = _normalize_named_params(query)
24
+ cursor = self._database.execute(normalized, params or {})
25
+ columns = [description[0] for description in cursor.description or []]
26
+ return [dict(zip(columns, row)) for row in cursor.fetchall()]
27
+
28
+ def close(self) -> None:
29
+ self._database.close()
30
+
31
+
32
+ def create_sqlite_connection(path: str = ":memory:") -> SqliteConnection:
33
+ database = sqlite3.connect(path)
34
+ database.row_factory = sqlite3.Row
35
+ database.execute("PRAGMA foreign_keys = ON")
36
+ return SqliteConnection(database)
37
+
38
+
39
+ def exec_sqlite_sql(connection: SqliteConnection, sql: str) -> None:
40
+ connection.exec(sql)
41
+
42
+
43
+ def exec_sqlite_file(connection: SqliteConnection, file_path: str | Path) -> None:
44
+ connection.exec(Path(file_path).read_text(encoding="utf-8"))
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from snapline.engine import reconcile
4
+
5
+ from ..types import CrossSystemResult, DbComparisonConfig
6
+
7
+
8
+ async def run_db_comparison(db_comparison: DbComparisonConfig | dict) -> CrossSystemResult:
9
+ source_rows = await db_comparison["sourceDb"].query(
10
+ db_comparison["query"],
11
+ db_comparison.get("params", {}),
12
+ )
13
+ target_rows = await db_comparison["targetDb"].query(
14
+ db_comparison["query"],
15
+ db_comparison.get("params", {}),
16
+ )
17
+
18
+ source_data = source_rows[0] if source_rows else None
19
+ target_data = target_rows[0] if target_rows else None
20
+
21
+ result = reconcile(
22
+ source_data,
23
+ target_data,
24
+ {
25
+ "ignoreFields": db_comparison.get("ignoreFields", []),
26
+ "transformations": db_comparison.get("transformations", {}),
27
+ "dataMapping": db_comparison.get("dataMapping", {}),
28
+ },
29
+ )
30
+
31
+ return {
32
+ "match": result["match"],
33
+ "source": result["processed"],
34
+ "target": result["expected"],
35
+ "diff": result["diff"],
36
+ }
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ from typing import Any
6
+
7
+ from .types import TestRunReport
8
+
9
+
10
+ def _escape(value: str) -> str:
11
+ return html.escape(value)
12
+
13
+
14
+ def _render_diff_block(diff: Any) -> str:
15
+ if not diff:
16
+ return ""
17
+ payload = _escape(json.dumps(diff, indent=2))
18
+ return f'<pre class="diff">{payload}</pre>'
19
+
20
+
21
+ def render_html_report(report: TestRunReport) -> str:
22
+ suite_rows = []
23
+ for suite in report["suites"]:
24
+ step_rows = []
25
+ for step in suite["results"]:
26
+ status_class = "pass" if step["passed"] else "fail"
27
+ message = (
28
+ f'<p class="message">{_escape(step["message"])}</p>'
29
+ if step.get("message")
30
+ else ""
31
+ )
32
+ diff = _render_diff_block(step.get("diff")) if not step["passed"] else ""
33
+ step_rows.append(
34
+ f"""
35
+ <li class="step {status_class}">
36
+ <span class="badge">{"PASS" if step["passed"] else "FAIL"}</span>
37
+ <span class="step-name">{_escape(step["step"])}</span>
38
+ {message}
39
+ {diff}
40
+ </li>"""
41
+ )
42
+
43
+ suite_rows.append(
44
+ f"""
45
+ <section class="suite {"pass" if suite["passed"] else "fail"}">
46
+ <h2>{_escape(suite["name"])}</h2>
47
+ <p class="suite-status">{"PASSED" if suite["passed"] else "FAILED"}</p>
48
+ <ul class="steps">{''.join(step_rows)}</ul>
49
+ </section>"""
50
+ )
51
+
52
+ duration = report["summary"].get("durationMs") or 0
53
+ return f"""<!DOCTYPE html>
54
+ <html lang="en">
55
+ <head>
56
+ <meta charset="utf-8">
57
+ <meta name="viewport" content="width=device-width, initial-scale=1">
58
+ <title>snapline-engine Test Report</title>
59
+ <style>
60
+ :root {{
61
+ color-scheme: light dark;
62
+ --bg: #0f172a;
63
+ --panel: #111827;
64
+ --text: #e5e7eb;
65
+ --muted: #94a3b8;
66
+ --pass: #16a34a;
67
+ --fail: #dc2626;
68
+ --border: #334155;
69
+ }}
70
+ body {{
71
+ margin: 0;
72
+ font-family: ui-sans-serif, system-ui, sans-serif;
73
+ background: var(--bg);
74
+ color: var(--text);
75
+ line-height: 1.5;
76
+ }}
77
+ main {{ max-width: 960px; margin: 0 auto; padding: 2rem; }}
78
+ h1 {{ margin-top: 0; }}
79
+ .summary {{
80
+ display: grid;
81
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
82
+ gap: 1rem;
83
+ margin: 1.5rem 0 2rem;
84
+ }}
85
+ .metric {{
86
+ background: var(--panel);
87
+ border: 1px solid var(--border);
88
+ border-radius: 12px;
89
+ padding: 1rem;
90
+ }}
91
+ .metric strong {{ display: block; font-size: 1.5rem; }}
92
+ .metric span {{ color: var(--muted); font-size: 0.875rem; }}
93
+ .suite {{
94
+ background: var(--panel);
95
+ border: 1px solid var(--border);
96
+ border-radius: 12px;
97
+ padding: 1rem 1.25rem;
98
+ margin-bottom: 1rem;
99
+ }}
100
+ .suite.pass {{ border-left: 4px solid var(--pass); }}
101
+ .suite.fail {{ border-left: 4px solid var(--fail); }}
102
+ .suite h2 {{ margin: 0 0 0.25rem; font-size: 1.125rem; }}
103
+ .suite-status {{ margin: 0 0 1rem; color: var(--muted); }}
104
+ .steps {{ list-style: none; padding: 0; margin: 0; }}
105
+ .step {{ padding: 0.75rem 0; border-top: 1px solid var(--border); }}
106
+ .badge {{
107
+ display: inline-block;
108
+ min-width: 3rem;
109
+ margin-right: 0.5rem;
110
+ padding: 0.125rem 0.5rem;
111
+ border-radius: 999px;
112
+ font-size: 0.75rem;
113
+ font-weight: 700;
114
+ text-align: center;
115
+ }}
116
+ .step.pass .badge {{ background: rgba(22, 163, 74, 0.2); color: var(--pass); }}
117
+ .step.fail .badge {{ background: rgba(220, 38, 38, 0.2); color: var(--fail); }}
118
+ .message {{ margin: 0.5rem 0 0; color: var(--muted); }}
119
+ .diff {{
120
+ margin: 0.75rem 0 0;
121
+ padding: 0.75rem;
122
+ overflow-x: auto;
123
+ background: #020617;
124
+ border-radius: 8px;
125
+ border: 1px solid var(--border);
126
+ font-size: 0.8125rem;
127
+ }}
128
+ footer {{ margin-top: 2rem; color: var(--muted); font-size: 0.875rem; }}
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <main>
133
+ <h1>snapline-engine Test Report</h1>
134
+ <p>Generated at {_escape(report["generatedAt"])}</p>
135
+ <div class="summary">
136
+ <div class="metric"><strong>{report["summary"]["total"]}</strong><span>Total suites</span></div>
137
+ <div class="metric"><strong>{report["summary"]["passed"]}</strong><span>Passed</span></div>
138
+ <div class="metric"><strong>{report["summary"]["failed"]}</strong><span>Failed</span></div>
139
+ <div class="metric"><strong>{duration}ms</strong><span>Duration</span></div>
140
+ </div>
141
+ {''.join(suite_rows)}
142
+ <footer>
143
+ Upload this file to any CI dashboard, artifact store, or reporting system.
144
+ </footer>
145
+ </main>
146
+ </body>
147
+ </html>"""
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from .types import TestRunReport
6
+
7
+
8
+ def render_json_report(report: TestRunReport) -> str:
9
+ return json.dumps(report, indent=2)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .types import TestRunReport
7
+
8
+
9
+ def _format_diff(diff: Any) -> str:
10
+ if not diff:
11
+ return "none"
12
+ return json.dumps(diff, indent=2).replace("\n", "\n ")
13
+
14
+
15
+ def render_text_report(report: TestRunReport) -> str:
16
+ lines = [
17
+ "snapline-engine — Test Run Report",
18
+ "=====================================",
19
+ f"Generated: {report['generatedAt']}",
20
+ f"Duration: {report['summary'].get('durationMs') or 0}ms",
21
+ "",
22
+ f"Total: {report['summary']['total']}",
23
+ f"Passed: {report['summary']['passed']}",
24
+ f"Failed: {report['summary']['failed']}",
25
+ "",
26
+ ]
27
+
28
+ for suite in report["suites"]:
29
+ lines.append(f"{'PASS' if suite['passed'] else 'FAIL'} {suite['name']}")
30
+ for step in suite["results"]:
31
+ lines.append(f" {'✓' if step['passed'] else '✗'} {step['step']}")
32
+ if not step["passed"] and step.get("message"):
33
+ lines.append(f" message: {step['message']}")
34
+ if not step["passed"] and step.get("diff"):
35
+ lines.append(f" diff:\n {_format_diff(step['diff'])}")
36
+ lines.append("")
37
+
38
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+
8
+ from ..types import TestSuiteResult
9
+
10
+ ReportFormat = Literal["json", "html", "text"]
11
+
12
+
13
+ class ReportConfig(dict):
14
+ format: ReportFormat
15
+ outputPath: str
16
+
17
+
18
+ class TestRunReportMeta(dict):
19
+ durationMs: int | None
20
+ environment: dict[str, Any] | None
21
+
22
+
23
+ class TestRunReport(dict):
24
+ generatedAt: str
25
+ framework: str
26
+ summary: dict[str, Any]
27
+ environment: dict[str, Any] | None
28
+ suites: list[TestSuiteResult]
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ..types import TestSuiteResult
8
+ from .html_reporter import render_html_report
9
+ from .json_reporter import render_json_report
10
+ from .text_reporter import render_text_report
11
+ from .types import ReportConfig, TestRunReport, TestRunReportMeta
12
+
13
+ FRAMEWORK_NAME = "snapline-engine"
14
+
15
+
16
+ def build_report(
17
+ suites: list[TestSuiteResult],
18
+ meta: TestRunReportMeta | dict[str, Any] | None = None,
19
+ ) -> TestRunReport:
20
+ meta = meta or {}
21
+ passed = sum(1 for suite in suites if suite["passed"])
22
+ failed = len(suites) - passed
23
+
24
+ return {
25
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
26
+ "framework": FRAMEWORK_NAME,
27
+ "summary": {
28
+ "total": len(suites),
29
+ "passed": passed,
30
+ "failed": failed,
31
+ "durationMs": meta.get("durationMs"),
32
+ },
33
+ "environment": meta.get("environment"),
34
+ "suites": suites,
35
+ }
36
+
37
+
38
+ def render_report(report: TestRunReport, format: str) -> str:
39
+ if format == "json":
40
+ return render_json_report(report)
41
+ if format == "html":
42
+ return render_html_report(report)
43
+ if format == "text":
44
+ return render_text_report(report)
45
+ raise ValueError(f"Unsupported report format: {format}")
46
+
47
+
48
+ def write_test_report(
49
+ suites: list[TestSuiteResult],
50
+ config: ReportConfig | dict[str, Any],
51
+ meta: TestRunReportMeta | dict[str, Any] | None = None,
52
+ ) -> str:
53
+ report = build_report(suites, meta)
54
+ content = render_report(report, config["format"])
55
+ output_path = Path(config["outputPath"])
56
+ output_path.parent.mkdir(parents=True, exist_ok=True)
57
+ output_path.write_text(content, encoding="utf-8")
58
+ return str(output_path)
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from snapline.api_adapters import execute_api
7
+ from snapline.engine import assert_against_file
8
+
9
+ from .api_config.to_api_request_config import to_api_request_config
10
+ from .cross_system.run_api_to_db import run_api_to_db
11
+ from .cross_system.run_db_to_api import run_db_to_api
12
+ from .db_comparison.run_db_comparison import run_db_comparison
13
+ from .types import TestStepResult, TestSuiteConfig, TestSuiteResult
14
+
15
+
16
+ def _log_step_result(
17
+ label: str,
18
+ match: bool,
19
+ diff: Any,
20
+ on_fail,
21
+ ) -> None:
22
+ if match:
23
+ print(f" ✓ {label}")
24
+ else:
25
+ on_fail()
26
+ print(f" ✗ {label}")
27
+ print(f" diff: {json.dumps(diff)}")
28
+
29
+
30
+ async def test_suite(name: str, config: TestSuiteConfig | dict[str, Any]) -> TestSuiteResult:
31
+ auth_adapter = config.get("auth")
32
+ api = config.get("api")
33
+ db_comparison = config.get("dbComparison")
34
+ api_to_db = config.get("apiToDb")
35
+ db_to_api = config.get("dbToApi")
36
+ base_url = config.get("baseUrl")
37
+ fetch_impl = config.get("fetchImpl")
38
+
39
+ results: list[TestStepResult] = []
40
+ passed = True
41
+
42
+ def fail() -> None:
43
+ nonlocal passed
44
+ passed = False
45
+
46
+ print(f"\n▶ {name}")
47
+
48
+ auth_headers: dict[str, str] = {}
49
+ if auth_adapter:
50
+ auth_result = await auth_adapter.initialize()
51
+ auth_headers = auth_result["headers"]
52
+ results.append(
53
+ {
54
+ "step": "auth",
55
+ "passed": True,
56
+ "token": "[redacted]" if auth_result.get("token") else None,
57
+ }
58
+ )
59
+ print(" ✓ auth initialized")
60
+
61
+ if api:
62
+ expected_file = api.get("expectedFile")
63
+ ignore_fields = api.get("ignoreFields", [])
64
+ transformations = api.get("transformations", {})
65
+ data_mapping = api.get("dataMapping", {})
66
+ expected_status = api.get("expectedStatus", 200)
67
+
68
+ api_request = to_api_request_config(api)
69
+ response = execute_api(
70
+ api_request,
71
+ {
72
+ "baseUrl": base_url,
73
+ "authHeaders": auth_headers,
74
+ "fetchImpl": fetch_impl,
75
+ },
76
+ )
77
+
78
+ if response["status"] != expected_status:
79
+ fail()
80
+ results.append(
81
+ {
82
+ "step": "api",
83
+ "passed": False,
84
+ "message": f"Expected status {expected_status}, got {response['status']}",
85
+ "data": response["data"],
86
+ }
87
+ )
88
+ print(
89
+ f" ✗ api status mismatch (expected {expected_status}, got {response['status']})"
90
+ )
91
+ elif expected_file:
92
+ assertion = assert_against_file(
93
+ response["data"],
94
+ expected_file,
95
+ {
96
+ "ignoreFields": ignore_fields,
97
+ "transformations": transformations,
98
+ "dataMapping": data_mapping,
99
+ },
100
+ )
101
+ results.append(
102
+ {
103
+ "step": "api-file",
104
+ "passed": assertion["match"],
105
+ "diff": assertion["diff"],
106
+ "processed": assertion["processed"],
107
+ }
108
+ )
109
+ _log_step_result(
110
+ "api response reconciled with fixture file",
111
+ assertion["match"],
112
+ assertion["diff"],
113
+ fail,
114
+ )
115
+ else:
116
+ results.append({"step": "api", "passed": True, "data": response["data"]})
117
+ print(" ✓ api request completed")
118
+
119
+ if db_comparison:
120
+ db_result = await run_db_comparison(db_comparison)
121
+ results.append({"step": "db-to-db", "passed": db_result["match"], **db_result})
122
+ _log_step_result("db-to-db reconciliation passed", db_result["match"], db_result["diff"], fail)
123
+
124
+ if api_to_db:
125
+ result = await run_api_to_db(api_to_db, auth_headers, base_url, fetch_impl)
126
+ results.append({"step": "api-to-db", "passed": result["match"], **result})
127
+ _log_step_result("api-to-db reconciliation passed", result["match"], result["diff"], fail)
128
+
129
+ if db_to_api:
130
+ result = await run_db_to_api(db_to_api, auth_headers, base_url, fetch_impl)
131
+ results.append({"step": "db-to-api", "passed": result["match"], **result})
132
+ _log_step_result("db-to-api reconciliation passed", result["match"], result["diff"], fail)
133
+
134
+ summary = "PASSED" if passed else "FAILED"
135
+ print(f"\n{'✅' if passed else '❌'} {name}: {summary}\n")
136
+
137
+ return {"name": name, "passed": passed, "results": results}
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, Protocol
4
+
5
+ from snapline.api_adapters.types import ApiRequestConfig
6
+ from snapline.auth_adapters import AuthAdapter
7
+ from snapline.engine.types import DataMappingMap, DiffResult, ReconcileOptions, TransformationMap
8
+
9
+ DbRow = dict[str, Any]
10
+ DbDialect = Literal["postgres", "mysql", "sqlite"]
11
+
12
+
13
+ class DbConnectionLike(Protocol):
14
+ async def query(self, query: str, params: dict[str, Any] | None = None) -> list[DbRow]: ...
15
+
16
+
17
+ class DbQueryConfig(dict):
18
+ db: DbConnectionLike
19
+ query: str
20
+ params: dict[str, Any] | None
21
+
22
+
23
+ class ApiFileTestConfig(ReconcileOptions):
24
+ endpoint: str | None
25
+ method: str | None
26
+ inputFile: str | None
27
+ expectedFile: str | None
28
+ body: Any
29
+ headers: dict[str, str] | None
30
+ expectedStatus: int | None
31
+ protocol: Literal["rest", "soap", "graphql"] | None
32
+ soapAction: str | None
33
+ envelope: str | None
34
+ query: str | None
35
+ queryFile: str | None
36
+ variables: dict[str, Any] | None
37
+ variablesFile: str | None
38
+ dataPath: str | None
39
+
40
+
41
+ class DbComparisonConfig(ReconcileOptions):
42
+ sourceDb: DbConnectionLike
43
+ targetDb: DbConnectionLike
44
+ query: str
45
+ params: dict[str, Any] | None
46
+
47
+
48
+ class ApiToDbConfig(ReconcileOptions):
49
+ api: ApiRequestConfig
50
+ db: DbQueryConfig
51
+
52
+
53
+ class DbToApiConfig(ReconcileOptions):
54
+ db: DbQueryConfig
55
+ api: ApiRequestConfig
56
+ inputFromDb: bool | None
57
+
58
+
59
+ class TestSuiteConfig(dict):
60
+ auth: AuthAdapter | None
61
+ api: ApiFileTestConfig | None
62
+ dbComparison: DbComparisonConfig | None
63
+ apiToDb: ApiToDbConfig | None
64
+ dbToApi: DbToApiConfig | None
65
+ baseUrl: str | None
66
+ fetchImpl: Any | None
67
+
68
+
69
+ class TestStepResult(dict):
70
+ step: str
71
+ passed: bool
72
+ message: str | None
73
+ data: Any
74
+ diff: DiffResult | None
75
+ processed: Any
76
+ token: str | None
77
+ source: Any
78
+ target: Any
79
+ match: bool | None
80
+
81
+
82
+ class TestSuiteResult(dict):
83
+ name: str
84
+ passed: bool
85
+ results: list[TestStepResult]
86
+
87
+
88
+ class CrossSystemResult(dict):
89
+ match: bool
90
+ source: Any
91
+ target: Any
92
+ diff: DiffResult | None
93
+
94
+
95
+ DbComparisonResult = CrossSystemResult