h2orb 0.1.1__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.
- h2orb/__init__.py +4 -0
- h2orb/__main__.py +21 -0
- h2orb/api/__init__.py +2 -0
- h2orb/api/dependencies.py +10 -0
- h2orb/api/routes.py +133 -0
- h2orb/aqua/__init__.py +2 -0
- h2orb/aqua/client.py +247 -0
- h2orb/check_connection.py +25 -0
- h2orb/config.py +38 -0
- h2orb/domain/__init__.py +2 -0
- h2orb/domain/models.py +80 -0
- h2orb/main.py +43 -0
- h2orb/services/__init__.py +2 -0
- h2orb/services/aggregation.py +66 -0
- h2orb/services/navigation.py +57 -0
- h2orb/services/report.py +566 -0
- h2orb/static/app.css +117 -0
- h2orb/static/app.js +706 -0
- h2orb/templates/index.html +105 -0
- h2orb-0.1.1.dist-info/METADATA +324 -0
- h2orb-0.1.1.dist-info/RECORD +24 -0
- h2orb-0.1.1.dist-info/WHEEL +4 -0
- h2orb-0.1.1.dist-info/entry_points.txt +3 -0
- h2orb-0.1.1.dist-info/licenses/LICENSE +77 -0
h2orb/__init__.py
ADDED
h2orb/__main__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
|
|
5
|
+
from h2orb.config import get_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
settings = get_settings()
|
|
10
|
+
uvicorn.run(
|
|
11
|
+
"h2orb.main:app",
|
|
12
|
+
host=settings.host,
|
|
13
|
+
port=settings.port,
|
|
14
|
+
reload=settings.environment == "development",
|
|
15
|
+
log_level=settings.log_level.lower(),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
|
21
|
+
|
h2orb/api/__init__.py
ADDED
h2orb/api/routes.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
7
|
+
from fastapi.responses import HTMLResponse
|
|
8
|
+
from fastapi.templating import Jinja2Templates
|
|
9
|
+
|
|
10
|
+
from h2orb.api.dependencies import get_aqua_client
|
|
11
|
+
from h2orb.aqua.client import AquaApiError, AquaClient, AquaConfigurationError
|
|
12
|
+
from h2orb.config import get_settings
|
|
13
|
+
from h2orb.services.navigation import map_projects, map_scenarios, map_subfolders
|
|
14
|
+
from h2orb.services.report import build_markdown_report, build_report
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
templates = Jinja2Templates(directory=Path(__file__).resolve().parents[1] / "templates")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("/", response_class=HTMLResponse)
|
|
21
|
+
async def index(request: Request) -> HTMLResponse:
|
|
22
|
+
settings = get_settings()
|
|
23
|
+
return templates.TemplateResponse(
|
|
24
|
+
request=request,
|
|
25
|
+
name="index.html",
|
|
26
|
+
context={
|
|
27
|
+
"app_name": settings.app_name,
|
|
28
|
+
"environment": settings.environment,
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/health", tags=["operations"])
|
|
34
|
+
def health() -> dict[str, str]:
|
|
35
|
+
return {"status": "ok"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/api/v1/status", tags=["application"])
|
|
39
|
+
def application_status() -> dict[str, str]:
|
|
40
|
+
return {
|
|
41
|
+
"application": "ready",
|
|
42
|
+
"aquaIntegration": "awaiting-swagger-mapping",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.get("/api/v1/aqua/projects", tags=["aqua"])
|
|
47
|
+
async def aqua_projects(
|
|
48
|
+
client: Annotated[AquaClient, Depends(get_aqua_client)],
|
|
49
|
+
) -> dict[str, object]:
|
|
50
|
+
try:
|
|
51
|
+
payload = await client.get_project_tree([], include_projects=True)
|
|
52
|
+
except AquaConfigurationError as exc:
|
|
53
|
+
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
|
54
|
+
except AquaApiError as exc:
|
|
55
|
+
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
|
|
56
|
+
return {"items": map_projects(payload)}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.get("/api/v1/aqua/projects/{project_id}/folders", tags=["aqua"])
|
|
60
|
+
async def aqua_folders(
|
|
61
|
+
project_id: int,
|
|
62
|
+
client: Annotated[AquaClient, Depends(get_aqua_client)],
|
|
63
|
+
folder_id: Annotated[int, Query(ge=0)] = 0,
|
|
64
|
+
) -> dict[str, object]:
|
|
65
|
+
try:
|
|
66
|
+
payload = await client.get_project_tree([f"{project_id}_{folder_id}"])
|
|
67
|
+
except AquaConfigurationError as exc:
|
|
68
|
+
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
|
69
|
+
except AquaApiError as exc:
|
|
70
|
+
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
|
|
71
|
+
return {"items": map_subfolders(payload)}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.get("/api/v1/aqua/projects/{project_id}/scenarios", tags=["aqua"])
|
|
75
|
+
async def aqua_scenarios(
|
|
76
|
+
project_id: int,
|
|
77
|
+
client: Annotated[AquaClient, Depends(get_aqua_client)],
|
|
78
|
+
folder_id: Annotated[int, Query(ge=0)] = 0,
|
|
79
|
+
) -> dict[str, object]:
|
|
80
|
+
try:
|
|
81
|
+
items = await client.get_all_test_scenarios(project_id, folder_id)
|
|
82
|
+
except AquaConfigurationError as exc:
|
|
83
|
+
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
|
84
|
+
except AquaApiError as exc:
|
|
85
|
+
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
|
|
86
|
+
scenarios = map_scenarios(items)
|
|
87
|
+
return {"count": len(scenarios), "items": scenarios}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.get("/api/v1/aqua/projects/{project_id}/report", tags=["aqua"])
|
|
91
|
+
async def aqua_report(
|
|
92
|
+
project_id: int,
|
|
93
|
+
client: Annotated[AquaClient, Depends(get_aqua_client)],
|
|
94
|
+
folder_id: Annotated[int, Query(ge=0)] = 0,
|
|
95
|
+
) -> dict[str, object]:
|
|
96
|
+
settings = get_settings()
|
|
97
|
+
try:
|
|
98
|
+
raw_scenarios = await client.get_all_test_scenarios(project_id, folder_id)
|
|
99
|
+
scenarios = map_scenarios(raw_scenarios)
|
|
100
|
+
report = await build_report(
|
|
101
|
+
client,
|
|
102
|
+
scenarios,
|
|
103
|
+
selected_folder_id=folder_id,
|
|
104
|
+
aqua_web_base_url=str(settings.aqua_web_base_url),
|
|
105
|
+
noise_marker=settings.noise_marker,
|
|
106
|
+
)
|
|
107
|
+
except AquaConfigurationError as exc:
|
|
108
|
+
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
|
|
109
|
+
except AquaApiError as exc:
|
|
110
|
+
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(exc)) from exc
|
|
111
|
+
return {"projectId": project_id, "folderId": folder_id, **report}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.post("/api/v1/report/markdown", tags=["application"])
|
|
115
|
+
async def markdown_report(payload: dict[str, object]) -> dict[str, str]:
|
|
116
|
+
tree = payload.get("tree")
|
|
117
|
+
if not isinstance(tree, dict):
|
|
118
|
+
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Missing report tree")
|
|
119
|
+
generated_at = str(payload.get("generatedAt") or "")
|
|
120
|
+
language = str(payload.get("language") or "de")
|
|
121
|
+
if language not in {"de", "en"}:
|
|
122
|
+
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Unsupported language")
|
|
123
|
+
raw_keys = payload.get("summaryKeys") or []
|
|
124
|
+
if not isinstance(raw_keys, list) or not all(isinstance(key, str) for key in raw_keys):
|
|
125
|
+
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Invalid summary keys")
|
|
126
|
+
return {
|
|
127
|
+
"markdown": build_markdown_report(
|
|
128
|
+
tree,
|
|
129
|
+
generated_at=generated_at,
|
|
130
|
+
language=language,
|
|
131
|
+
summary_keys=raw_keys,
|
|
132
|
+
)
|
|
133
|
+
}
|
h2orb/aqua/__init__.py
ADDED
h2orb/aqua/client.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import ssl
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from h2orb.config import Settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AquaApiError(RuntimeError):
|
|
16
|
+
"""Raised when AQUA cannot fulfil a request."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AquaConfigurationError(RuntimeError):
|
|
20
|
+
"""Raised when required AQUA connection settings are missing."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class _OAuthToken:
|
|
25
|
+
access_token: str
|
|
26
|
+
refresh_token: str | None
|
|
27
|
+
expires_at: float
|
|
28
|
+
|
|
29
|
+
def is_valid(self) -> bool:
|
|
30
|
+
return time.monotonic() < self.expires_at
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AquaClient:
|
|
34
|
+
"""Shared asynchronous AQUA client safe for concurrent application requests.
|
|
35
|
+
|
|
36
|
+
Endpoint-specific methods are added after mapping the v26.29.0.0 Swagger document.
|
|
37
|
+
Keeping raw requests private prevents AQUA transport details leaking into services.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, settings: Settings) -> None:
|
|
41
|
+
self._username = settings.aqua_username
|
|
42
|
+
self._password = settings.aqua_password
|
|
43
|
+
self._token: _OAuthToken | None = None
|
|
44
|
+
self._token_lock = asyncio.Lock()
|
|
45
|
+
verify: bool | ssl.SSLContext = settings.aqua_verify_tls
|
|
46
|
+
if settings.aqua_verify_tls and settings.aqua_ca_bundle is not None:
|
|
47
|
+
verify = ssl.create_default_context(cafile=str(settings.aqua_ca_bundle))
|
|
48
|
+
self._client = httpx.AsyncClient(
|
|
49
|
+
base_url=str(settings.aqua_base_url).rstrip("/") + "/",
|
|
50
|
+
verify=verify,
|
|
51
|
+
timeout=httpx.Timeout(settings.aqua_timeout_seconds),
|
|
52
|
+
limits=httpx.Limits(
|
|
53
|
+
max_connections=settings.aqua_max_connections,
|
|
54
|
+
max_keepalive_connections=settings.aqua_max_connections,
|
|
55
|
+
),
|
|
56
|
+
headers={"Accept": "application/json"},
|
|
57
|
+
trust_env=False,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
async def close(self) -> None:
|
|
61
|
+
try:
|
|
62
|
+
if self._token is not None:
|
|
63
|
+
await self._client.delete(
|
|
64
|
+
"api/Session",
|
|
65
|
+
headers={"Authorization": f"Bearer {self._token.access_token}"},
|
|
66
|
+
)
|
|
67
|
+
except httpx.HTTPError:
|
|
68
|
+
pass
|
|
69
|
+
finally:
|
|
70
|
+
self._token = None
|
|
71
|
+
await self._client.aclose()
|
|
72
|
+
|
|
73
|
+
async def get_project_tree(
|
|
74
|
+
self,
|
|
75
|
+
parent_folders: list[str],
|
|
76
|
+
*,
|
|
77
|
+
include_projects: bool = False,
|
|
78
|
+
include_archived: bool = False,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
return await self._get(
|
|
81
|
+
"api/Navigation/Tree",
|
|
82
|
+
parentFolders=json.dumps(parent_folders),
|
|
83
|
+
includeProjects=include_projects,
|
|
84
|
+
includeArchived=include_archived,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def get_test_scenarios(
|
|
88
|
+
self,
|
|
89
|
+
project_id: int,
|
|
90
|
+
folder_id: int = 0,
|
|
91
|
+
*,
|
|
92
|
+
include_subfolders: bool = True,
|
|
93
|
+
start_at: int = 0,
|
|
94
|
+
max_results: int = 50,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
return await self._get(
|
|
97
|
+
"api/TestScenario/ItemList",
|
|
98
|
+
projectId=project_id,
|
|
99
|
+
folderId=folder_id,
|
|
100
|
+
includeSubfolders=include_subfolders,
|
|
101
|
+
includeArchived=False,
|
|
102
|
+
startAt=start_at,
|
|
103
|
+
maxResults=max_results,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
async def get_all_test_scenarios(
|
|
107
|
+
self,
|
|
108
|
+
project_id: int,
|
|
109
|
+
folder_id: int = 0,
|
|
110
|
+
*,
|
|
111
|
+
include_subfolders: bool = True,
|
|
112
|
+
page_size: int = 200,
|
|
113
|
+
) -> list[dict[str, Any]]:
|
|
114
|
+
items: list[dict[str, Any]] = []
|
|
115
|
+
start_at = 0
|
|
116
|
+
while True:
|
|
117
|
+
page = await self.get_test_scenarios(
|
|
118
|
+
project_id,
|
|
119
|
+
folder_id,
|
|
120
|
+
include_subfolders=include_subfolders,
|
|
121
|
+
start_at=start_at,
|
|
122
|
+
max_results=page_size,
|
|
123
|
+
)
|
|
124
|
+
page_items = page.get("Items") or []
|
|
125
|
+
items.extend(page_items)
|
|
126
|
+
total = int(page.get("Count", len(items)))
|
|
127
|
+
if not page_items or len(items) >= total:
|
|
128
|
+
return items
|
|
129
|
+
start_at += len(page_items)
|
|
130
|
+
|
|
131
|
+
async def get_test_jobs(self, scenario_id: int) -> list[dict[str, Any]]:
|
|
132
|
+
return await self._get(f"api/TestScenario/{scenario_id}/TestJob")
|
|
133
|
+
|
|
134
|
+
async def get_scenario_executions(
|
|
135
|
+
self,
|
|
136
|
+
scenario_id: int,
|
|
137
|
+
*,
|
|
138
|
+
start_at: int = 0,
|
|
139
|
+
max_results: int = 100,
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
return await self._get(
|
|
142
|
+
f"api/TestScenario/{scenario_id}/Execution",
|
|
143
|
+
startAt=start_at,
|
|
144
|
+
maxResults=max_results,
|
|
145
|
+
orderBy="ExecutionDate DESC",
|
|
146
|
+
includeStatistics=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
async def get_all_scenario_executions(
|
|
150
|
+
self, scenario_id: int, *, page_size: int = 200
|
|
151
|
+
) -> list[dict[str, Any]]:
|
|
152
|
+
items: list[dict[str, Any]] = []
|
|
153
|
+
start_at = 0
|
|
154
|
+
while True:
|
|
155
|
+
page = await self.get_scenario_executions(
|
|
156
|
+
scenario_id, start_at=start_at, max_results=page_size
|
|
157
|
+
)
|
|
158
|
+
page_items = page.get("Items") or []
|
|
159
|
+
items.extend(item for item in page_items if not item.get("Irrelevant"))
|
|
160
|
+
total = int(page.get("Count", len(items)))
|
|
161
|
+
if not page_items or start_at + len(page_items) >= total:
|
|
162
|
+
return items
|
|
163
|
+
start_at += len(page_items)
|
|
164
|
+
|
|
165
|
+
async def get_test_execution(self, execution_id: int) -> dict[str, Any]:
|
|
166
|
+
return await self._get(
|
|
167
|
+
f"api/TestExecution/{execution_id}",
|
|
168
|
+
richTextFormatToInclude="None",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def get_defect(
|
|
172
|
+
self, defect_id: int, *, with_enclosure: bool = False
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
return await self._get(
|
|
175
|
+
f"api/Defect/{defect_id}",
|
|
176
|
+
withEnclosure=with_enclosure,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
async def get_execution_relations(self, execution_id: int) -> dict[str, Any]:
|
|
180
|
+
return await self._get(f"api/TestExecution/{execution_id}/Relation")
|
|
181
|
+
|
|
182
|
+
async def _get(self, path: str, **params: Any) -> Any:
|
|
183
|
+
return await self._request("GET", path, params=params or None)
|
|
184
|
+
|
|
185
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
186
|
+
token = await self._access_token()
|
|
187
|
+
try:
|
|
188
|
+
response = await self._client.request(
|
|
189
|
+
method,
|
|
190
|
+
path,
|
|
191
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
192
|
+
**kwargs,
|
|
193
|
+
)
|
|
194
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
195
|
+
self._token = None
|
|
196
|
+
token = await self._access_token()
|
|
197
|
+
response = await self._client.request(
|
|
198
|
+
method,
|
|
199
|
+
path,
|
|
200
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
201
|
+
**kwargs,
|
|
202
|
+
)
|
|
203
|
+
response.raise_for_status()
|
|
204
|
+
except httpx.HTTPError as exc:
|
|
205
|
+
status = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
|
206
|
+
suffix = f" with status {status}" if status is not None else ""
|
|
207
|
+
raise AquaApiError(f"AQUA request failed{suffix}") from exc
|
|
208
|
+
return response.json()
|
|
209
|
+
|
|
210
|
+
async def _access_token(self) -> str:
|
|
211
|
+
if self._token is not None and self._token.is_valid():
|
|
212
|
+
return self._token.access_token
|
|
213
|
+
|
|
214
|
+
async with self._token_lock:
|
|
215
|
+
if self._token is not None and self._token.is_valid():
|
|
216
|
+
return self._token.access_token
|
|
217
|
+
self._token = await self._request_token()
|
|
218
|
+
return self._token.access_token
|
|
219
|
+
|
|
220
|
+
async def _request_token(self) -> _OAuthToken:
|
|
221
|
+
if not self._username or not self._password:
|
|
222
|
+
raise AquaConfigurationError(
|
|
223
|
+
"ARB_AQUA_USERNAME and ARB_AQUA_PASSWORD must be configured"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
response = await self._client.post(
|
|
228
|
+
"api/token",
|
|
229
|
+
data={
|
|
230
|
+
"grant_type": "password",
|
|
231
|
+
"username": self._username,
|
|
232
|
+
"password": self._password,
|
|
233
|
+
},
|
|
234
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
235
|
+
)
|
|
236
|
+
response.raise_for_status()
|
|
237
|
+
payload = response.json()
|
|
238
|
+
access_token = payload["access_token"]
|
|
239
|
+
except (httpx.HTTPError, KeyError, ValueError, TypeError) as exc:
|
|
240
|
+
raise AquaApiError("AQUA authentication failed") from exc
|
|
241
|
+
|
|
242
|
+
expires_in = max(float(payload.get("expires_in", 300)), 1.0)
|
|
243
|
+
return _OAuthToken(
|
|
244
|
+
access_token=access_token,
|
|
245
|
+
refresh_token=payload.get("refresh_token"),
|
|
246
|
+
expires_at=time.monotonic() + max(expires_in - 30.0, 1.0),
|
|
247
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from h2orb.aqua.client import AquaClient
|
|
6
|
+
from h2orb.config import get_settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def check_connection() -> int:
|
|
10
|
+
client = AquaClient(get_settings())
|
|
11
|
+
try:
|
|
12
|
+
data = await client.get_project_tree([], include_projects=True)
|
|
13
|
+
project_count = len(data.get("Projects") or [])
|
|
14
|
+
print(f"AQUA connection successful; visible projects: {project_count}")
|
|
15
|
+
return 0
|
|
16
|
+
finally:
|
|
17
|
+
await client.close()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
raise SystemExit(asyncio.run(check_connection()))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
main()
|
h2orb/config.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, HttpUrl
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Settings(BaseSettings):
|
|
11
|
+
model_config = SettingsConfigDict(
|
|
12
|
+
env_file=".env",
|
|
13
|
+
env_prefix="ARB_",
|
|
14
|
+
case_sensitive=False,
|
|
15
|
+
extra="ignore",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
app_name: str = "H2ORB – H2O Result Browser"
|
|
19
|
+
environment: str = "development"
|
|
20
|
+
host: str = "127.0.0.1"
|
|
21
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
22
|
+
log_level: str = "INFO"
|
|
23
|
+
|
|
24
|
+
aqua_base_url: HttpUrl = HttpUrl("https://aqua.example.invalid")
|
|
25
|
+
aqua_web_base_url: HttpUrl = HttpUrl("https://app.aqua-cloud.io/aquaWebNG")
|
|
26
|
+
noise_marker: str = "[NOISE]"
|
|
27
|
+
aqua_username: str | None = None
|
|
28
|
+
aqua_password: str | None = None
|
|
29
|
+
aqua_verify_tls: bool = True
|
|
30
|
+
aqua_ca_bundle: Path | None = None
|
|
31
|
+
aqua_timeout_seconds: float = Field(default=30.0, gt=0)
|
|
32
|
+
aqua_max_connections: int = Field(default=20, ge=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache
|
|
36
|
+
def get_settings() -> Settings:
|
|
37
|
+
return Settings()
|
|
38
|
+
|
h2orb/domain/__init__.py
ADDED
h2orb/domain/models.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestKind(StrEnum):
|
|
9
|
+
MANUAL = "manual"
|
|
10
|
+
AUTOMATED = "automated"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExecutionStatus(StrEnum):
|
|
14
|
+
PASSED = "Passed"
|
|
15
|
+
FAILED = "Failed"
|
|
16
|
+
ABORTED = "Aborted"
|
|
17
|
+
NOT_EXECUTED = "Not Executed"
|
|
18
|
+
BLOCKED = "Blocked"
|
|
19
|
+
NOT_COMPLETED = "Not Completed"
|
|
20
|
+
NOT_APPLICABLE = "Not Applicable"
|
|
21
|
+
UNKNOWN = "Unknown"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class Defect:
|
|
26
|
+
defect_id: int | str
|
|
27
|
+
formatted_id: str | None = None
|
|
28
|
+
name: str | None = None
|
|
29
|
+
url: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class ScenarioExecution:
|
|
34
|
+
scenario_id: int | str
|
|
35
|
+
scenario_name: str
|
|
36
|
+
test_case_id: int | str
|
|
37
|
+
test_case_name: str
|
|
38
|
+
test_kind: TestKind
|
|
39
|
+
status: ExecutionStatus
|
|
40
|
+
executed_at: datetime | None = None
|
|
41
|
+
defects: tuple[Defect, ...] = ()
|
|
42
|
+
scenario_url: str | None = None
|
|
43
|
+
test_case_url: str | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def has_defect(self) -> bool:
|
|
47
|
+
return bool(self.defects)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def failure_class(self) -> str | None:
|
|
51
|
+
if self.status is not ExecutionStatus.FAILED:
|
|
52
|
+
return None
|
|
53
|
+
return "F" if self.has_defect else "X"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class StatusTotals:
|
|
58
|
+
total: int = 0
|
|
59
|
+
by_status: dict[ExecutionStatus, int] = field(default_factory=dict)
|
|
60
|
+
failed_with_defect: int = 0
|
|
61
|
+
failed_without_defect: int = 0
|
|
62
|
+
|
|
63
|
+
def add(self, execution: ScenarioExecution) -> None:
|
|
64
|
+
self.total += 1
|
|
65
|
+
self.by_status[execution.status] = self.by_status.get(execution.status, 0) + 1
|
|
66
|
+
if execution.failure_class == "F":
|
|
67
|
+
self.failed_with_defect += 1
|
|
68
|
+
elif execution.failure_class == "X":
|
|
69
|
+
self.failed_without_defect += 1
|
|
70
|
+
|
|
71
|
+
def percentage(self, count: int) -> float:
|
|
72
|
+
return round(count * 100 / self.total, 2) if self.total else 0.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class ReportTotals:
|
|
77
|
+
overall: StatusTotals
|
|
78
|
+
manual: StatusTotals
|
|
79
|
+
automated: StatusTotals
|
|
80
|
+
by_scenario: dict[int | str, StatusTotals]
|
h2orb/main.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
|
|
10
|
+
from h2orb.api.routes import router
|
|
11
|
+
from h2orb.aqua.client import AquaClient
|
|
12
|
+
from h2orb.config import get_settings
|
|
13
|
+
|
|
14
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@asynccontextmanager
|
|
18
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
19
|
+
settings = get_settings()
|
|
20
|
+
app.state.aqua_client = AquaClient(settings)
|
|
21
|
+
try:
|
|
22
|
+
yield
|
|
23
|
+
finally:
|
|
24
|
+
await app.state.aqua_client.close()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_app() -> FastAPI:
|
|
28
|
+
settings = get_settings()
|
|
29
|
+
application = FastAPI(
|
|
30
|
+
title=settings.app_name,
|
|
31
|
+
version="0.1.0",
|
|
32
|
+
lifespan=lifespan,
|
|
33
|
+
)
|
|
34
|
+
application.mount(
|
|
35
|
+
"/static",
|
|
36
|
+
StaticFiles(directory=PACKAGE_DIR / "static"),
|
|
37
|
+
name="static",
|
|
38
|
+
)
|
|
39
|
+
application.include_router(router)
|
|
40
|
+
return application
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = create_app()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from h2orb.domain.models import (
|
|
6
|
+
ReportTotals,
|
|
7
|
+
ScenarioExecution,
|
|
8
|
+
StatusTotals,
|
|
9
|
+
TestKind,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def latest_scenario_executions(
|
|
14
|
+
executions: Iterable[ScenarioExecution],
|
|
15
|
+
) -> list[ScenarioExecution]:
|
|
16
|
+
"""Return one latest execution per scenario/test-case occurrence.
|
|
17
|
+
|
|
18
|
+
A test case used in two scenarios deliberately produces two result rows.
|
|
19
|
+
"""
|
|
20
|
+
latest: dict[tuple[int | str, int | str], ScenarioExecution] = {}
|
|
21
|
+
for execution in executions:
|
|
22
|
+
key = (execution.scenario_id, execution.test_case_id)
|
|
23
|
+
current = latest.get(key)
|
|
24
|
+
if current is None or _is_newer(execution, current):
|
|
25
|
+
latest[key] = execution
|
|
26
|
+
return list(latest.values())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def aggregate(executions: Iterable[ScenarioExecution]) -> ReportTotals:
|
|
30
|
+
overall = StatusTotals()
|
|
31
|
+
manual = StatusTotals()
|
|
32
|
+
automated = StatusTotals()
|
|
33
|
+
by_scenario: dict[int | str, StatusTotals] = {}
|
|
34
|
+
|
|
35
|
+
for execution in latest_scenario_executions(executions):
|
|
36
|
+
overall.add(execution)
|
|
37
|
+
by_scenario.setdefault(execution.scenario_id, StatusTotals()).add(execution)
|
|
38
|
+
if execution.test_kind is TestKind.MANUAL:
|
|
39
|
+
manual.add(execution)
|
|
40
|
+
else:
|
|
41
|
+
automated.add(execution)
|
|
42
|
+
|
|
43
|
+
return ReportTotals(
|
|
44
|
+
overall=overall,
|
|
45
|
+
manual=manual,
|
|
46
|
+
automated=automated,
|
|
47
|
+
by_scenario=by_scenario,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def visible_details(
|
|
52
|
+
executions: Iterable[ScenarioExecution], *, show_passed: bool
|
|
53
|
+
) -> list[ScenarioExecution]:
|
|
54
|
+
latest = latest_scenario_executions(executions)
|
|
55
|
+
if show_passed:
|
|
56
|
+
return latest
|
|
57
|
+
return [execution for execution in latest if execution.status.value != "Passed"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_newer(candidate: ScenarioExecution, current: ScenarioExecution) -> bool:
|
|
61
|
+
if candidate.executed_at is None:
|
|
62
|
+
return False
|
|
63
|
+
if current.executed_at is None:
|
|
64
|
+
return True
|
|
65
|
+
return candidate.executed_at > current.executed_at
|
|
66
|
+
|