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 ADDED
@@ -0,0 +1,4 @@
1
+ """H2ORB – H2O Result Browser package."""
2
+
3
+ __version__ = "0.1.0"
4
+
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
@@ -0,0 +1,2 @@
1
+ """HTTP API exposed by the application."""
2
+
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Request
4
+
5
+ from h2orb.aqua.client import AquaClient
6
+
7
+
8
+ def get_aqua_client(request: Request) -> AquaClient:
9
+ return request.app.state.aqua_client
10
+
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
@@ -0,0 +1,2 @@
1
+ """AQUA REST API integration."""
2
+
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
+
@@ -0,0 +1,2 @@
1
+ """Framework-independent business model."""
2
+
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,2 @@
1
+ """Application services and use cases."""
2
+
@@ -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
+