qasight 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. qasight/__init__.py +83 -0
  2. qasight/accounts.py +272 -0
  3. qasight/cli/__init__.py +5 -0
  4. qasight/cli/app.py +359 -0
  5. qasight/cli/cleanup.py +68 -0
  6. qasight/cli/commands/__init__.py +6 -0
  7. qasight/cli/commands/affected.py +114 -0
  8. qasight/cli/commands/ai.py +123 -0
  9. qasight/cli/commands/backup.py +86 -0
  10. qasight/cli/commands/badge.py +47 -0
  11. qasight/cli/commands/clean.py +264 -0
  12. qasight/cli/commands/config.py +147 -0
  13. qasight/cli/commands/config_db.py +137 -0
  14. qasight/cli/commands/demo.py +209 -0
  15. qasight/cli/commands/diff_cli.py +112 -0
  16. qasight/cli/commands/doctor.py +221 -0
  17. qasight/cli/commands/export_cmd.py +65 -0
  18. qasight/cli/commands/export_site.py +83 -0
  19. qasight/cli/commands/flaky.py +165 -0
  20. qasight/cli/commands/ingest.py +169 -0
  21. qasight/cli/commands/init_cmd.py +499 -0
  22. qasight/cli/commands/issues.py +76 -0
  23. qasight/cli/commands/lease.py +436 -0
  24. qasight/cli/commands/mcp_cmd.py +133 -0
  25. qasight/cli/commands/notify.py +244 -0
  26. qasight/cli/commands/otel.py +50 -0
  27. qasight/cli/commands/plans.py +92 -0
  28. qasight/cli/commands/plugins.py +112 -0
  29. qasight/cli/commands/pool.py +353 -0
  30. qasight/cli/commands/quarantine.py +212 -0
  31. qasight/cli/commands/report.py +410 -0
  32. qasight/cli/commands/report_archive.py +172 -0
  33. qasight/cli/commands/run.py +160 -0
  34. qasight/cli/commands/server_cmd.py +58 -0
  35. qasight/cli/commands/split.py +158 -0
  36. qasight/cli/commands/try_cmd.py +239 -0
  37. qasight/cli/commands/ui_cmd.py +156 -0
  38. qasight/cli/commands/users.py +150 -0
  39. qasight/cli/commands/version.py +18 -0
  40. qasight/cli/commands/watch.py +135 -0
  41. qasight/cli/cycle_hint.py +30 -0
  42. qasight/cli/missing_module.py +60 -0
  43. qasight/cli/runner.py +462 -0
  44. qasight/cli/server_url.py +23 -0
  45. qasight/cli/ui/__init__.py +5 -0
  46. qasight/cli/ui/ci_renderer.py +19 -0
  47. qasight/cli/ui/console.py +65 -0
  48. qasight/cli/ui/panels.py +152 -0
  49. qasight/cli/ui/progress.py +29 -0
  50. qasight/cli/ui/renderers.py +187 -0
  51. qasight/cli/ui/summary_dashboard.py +345 -0
  52. qasight/config/__init__.py +29 -0
  53. qasight/config/affected.py +122 -0
  54. qasight/config/database_section.py +161 -0
  55. qasight/config/errors.py +38 -0
  56. qasight/config/history_section.py +157 -0
  57. qasight/config/if_expr.py +69 -0
  58. qasight/config/json_schema.py +462 -0
  59. qasight/config/loader.py +269 -0
  60. qasight/config/resolver.py +89 -0
  61. qasight/config/schema.py +178 -0
  62. qasight/config/sections.py +463 -0
  63. qasight/config/triggers.py +294 -0
  64. qasight/config/unknown_keys.py +97 -0
  65. qasight/engine/__init__.py +30 -0
  66. qasight/engine/backends/__init__.py +128 -0
  67. qasight/engine/backends/container.py +194 -0
  68. qasight/engine/backends/runner_service.py +502 -0
  69. qasight/engine/crash_signatures.py +230 -0
  70. qasight/engine/cycle_lock.py +197 -0
  71. qasight/engine/events.py +46 -0
  72. qasight/engine/executor.py +638 -0
  73. qasight/engine/exit_codes.py +48 -0
  74. qasight/engine/interrupt.py +188 -0
  75. qasight/engine/log_buffer.py +151 -0
  76. qasight/engine/orchestrator.py +459 -0
  77. qasight/engine/quarantine.py +81 -0
  78. qasight/engine/result.py +61 -0
  79. qasight/engine/stage_resources.py +136 -0
  80. qasight/frameworks/__init__.py +21 -0
  81. qasight/frameworks/base.py +95 -0
  82. qasight/frameworks/behave_adapter.py +49 -0
  83. qasight/frameworks/behavex_adapter.py +74 -0
  84. qasight/frameworks/command_adapter.py +41 -0
  85. qasight/frameworks/cypress_adapter.py +55 -0
  86. qasight/frameworks/jest_adapter.py +97 -0
  87. qasight/frameworks/junit_adapter.py +40 -0
  88. qasight/frameworks/playwright_adapter.py +74 -0
  89. qasight/frameworks/pytest_adapter.py +152 -0
  90. qasight/history/__init__.py +16 -0
  91. qasight/history/backup.py +291 -0
  92. qasight/history/cases.py +36 -0
  93. qasight/history/maintenance.py +118 -0
  94. qasight/history/read_model.py +95 -0
  95. qasight/history/report_links.py +45 -0
  96. qasight/history/retention.py +228 -0
  97. qasight/history/snapshots.py +52 -0
  98. qasight/history/storage.py +183 -0
  99. qasight/history/views.py +164 -0
  100. qasight/mcp_server.py +485 -0
  101. qasight/paths.py +166 -0
  102. qasight/persistence/__init__.py +23 -0
  103. qasight/persistence/backend.py +21 -0
  104. qasight/persistence/composite.py +50 -0
  105. qasight/persistence/db_backend.py +216 -0
  106. qasight/persistence/failure_context.py +120 -0
  107. qasight/persistence/health.py +109 -0
  108. qasight/persistence/json_backend.py +68 -0
  109. qasight/plugins.py +157 -0
  110. qasight/pytest_plugin.py +716 -0
  111. qasight/reporting/__init__.py +11 -0
  112. qasight/reporting/allure.py +84 -0
  113. qasight/reporting/allure_cases.py +216 -0
  114. qasight/reporting/allure_cli.py +213 -0
  115. qasight/reporting/allure_passthrough.py +104 -0
  116. qasight/reporting/allure_results.py +235 -0
  117. qasight/reporting/behavex_allure.py +63 -0
  118. qasight/reporting/collector.py +144 -0
  119. qasight/reporting/coverage_import.py +168 -0
  120. qasight/reporting/entry.py +130 -0
  121. qasight/reporting/exporter.py +151 -0
  122. qasight/reporting/gotest_import.py +223 -0
  123. qasight/reporting/history_inject.py +76 -0
  124. qasight/reporting/imported_results.py +139 -0
  125. qasight/reporting/integrations.py +232 -0
  126. qasight/reporting/jest_import.py +219 -0
  127. qasight/reporting/junit_import.py +520 -0
  128. qasight/reporting/k6_import.py +134 -0
  129. qasight/reporting/metrics.py +191 -0
  130. qasight/reporting/metrics_extractor.py +187 -0
  131. qasight/reporting/native_reports.py +277 -0
  132. qasight/reporting/paths.py +78 -0
  133. qasight/reporting/playwright_import.py +406 -0
  134. qasight/reporting/pyramid_data.py +33 -0
  135. qasight/reporting/pyramid_viz.py +128 -0
  136. qasight/reporting/report_generator.py +332 -0
  137. qasight/reporting/reporters/__init__.py +14 -0
  138. qasight/reporting/reporters/alert_reporter.py +85 -0
  139. qasight/reporting/reporters/allure_reporter.py +137 -0
  140. qasight/reporting/reporters/base.py +63 -0
  141. qasight/reporting/reporters/extent_builder.py +106 -0
  142. qasight/reporting/reporters/extent_reporter.py +61 -0
  143. qasight/reporting/reporters/factory.py +151 -0
  144. qasight/reporting/reporters/orchestrate.py +95 -0
  145. qasight/reporting/reporters/otel_reporter.py +66 -0
  146. qasight/reporting/reporters/reportportal_client.py +367 -0
  147. qasight/reporting/reporters/reportportal_reporter.py +97 -0
  148. qasight/reporting/reporters/templates/extent_dashboard.html.j2 +244 -0
  149. qasight/reporting/reporters/testbeats_payloads.py +197 -0
  150. qasight/reporting/reporters/testbeats_reporter.py +115 -0
  151. qasight/reporting/robot_import.py +128 -0
  152. qasight/reporting/rspec_import.py +114 -0
  153. qasight/reporting/server.py +140 -0
  154. qasight/reporting/trx_import.py +170 -0
  155. qasight/reporting/xcresult_import.py +133 -0
  156. qasight/repository/__init__.py +15 -0
  157. qasight/repository/adapters.py +226 -0
  158. qasight/repository/base.py +94 -0
  159. qasight/repository/db.py +40 -0
  160. qasight/repository/db_config.py +139 -0
  161. qasight/repository/factory.py +115 -0
  162. qasight/repository/migrations/__init__.py +134 -0
  163. qasight/repository/migrations/env.py +41 -0
  164. qasight/repository/migrations/script.py.mako +26 -0
  165. qasight/repository/migrations/versions/0001_baseline.py +212 -0
  166. qasight/repository/migrations/versions/0002_promote_run_columns.py +96 -0
  167. qasight/repository/migrations/versions/0003_test_results.py +49 -0
  168. qasight/repository/migrations/versions/0004_test_result_crash.py +38 -0
  169. qasight/repository/migrations/versions/0005_quarantine_and_retried.py +53 -0
  170. qasight/repository/migrations/versions/0006_test_labels.py +45 -0
  171. qasight/repository/models.py +380 -0
  172. qasight/repository/report_archive_repository.py +119 -0
  173. qasight/repository/test_results.py +105 -0
  174. qasight/resources/__init__.py +6 -0
  175. qasight/resources/client.py +583 -0
  176. qasight/resources/demo.py +151 -0
  177. qasight/resources/pools.py +430 -0
  178. qasight/resources/provider.py +312 -0
  179. qasight/resources/store.py +557 -0
  180. qasight/security/__init__.py +15 -0
  181. qasight/security/redaction.py +189 -0
  182. qasight/services/__init__.py +86 -0
  183. qasight/services/affected_tests.py +390 -0
  184. qasight/services/ai/__init__.py +40 -0
  185. qasight/services/ai/cli_settings.py +121 -0
  186. qasight/services/ai/config.py +88 -0
  187. qasight/services/ai/factory.py +28 -0
  188. qasight/services/ai/integration_settings.py +147 -0
  189. qasight/services/ai/provider_base.py +72 -0
  190. qasight/services/ai/providers/__init__.py +5 -0
  191. qasight/services/ai/providers/anthropic_provider.py +77 -0
  192. qasight/services/ai/providers/claude_code_provider.py +175 -0
  193. qasight/services/ai/providers/openai_provider.py +123 -0
  194. qasight/services/ai_run_summary.py +60 -0
  195. qasight/services/ask_tests.py +177 -0
  196. qasight/services/attention.py +118 -0
  197. qasight/services/badge.py +108 -0
  198. qasight/services/ci_provenance.py +165 -0
  199. qasight/services/ci_templates.py +524 -0
  200. qasight/services/cycle_run.py +503 -0
  201. qasight/services/dashboard_service.py +357 -0
  202. qasight/services/delivery_metrics.py +434 -0
  203. qasight/services/delta_models.py +61 -0
  204. qasight/services/delta_service.py +330 -0
  205. qasight/services/failed_first.py +67 -0
  206. qasight/services/failure_analysis_service.py +222 -0
  207. qasight/services/failure_context_builder.py +139 -0
  208. qasight/services/failure_patterns.py +300 -0
  209. qasight/services/failure_signal.py +233 -0
  210. qasight/services/failure_source.py +347 -0
  211. qasight/services/flaky_issues.py +483 -0
  212. qasight/services/flaky_service.py +353 -0
  213. qasight/services/github_history.py +110 -0
  214. qasight/services/github_issues.py +129 -0
  215. qasight/services/ingest_client.py +138 -0
  216. qasight/services/junit_ingest.py +195 -0
  217. qasight/services/manager_report.py +433 -0
  218. qasight/services/mcp_install.py +111 -0
  219. qasight/services/metrics_service.py +41 -0
  220. qasight/services/otel_export.py +411 -0
  221. qasight/services/plugin_scaffold.py +75 -0
  222. qasight/services/project_health.py +354 -0
  223. qasight/services/project_review.py +556 -0
  224. qasight/services/project_setup.py +588 -0
  225. qasight/services/quarantine_store.py +428 -0
  226. qasight/services/releases.py +209 -0
  227. qasight/services/repo_scan.py +233 -0
  228. qasight/services/report_archive.py +168 -0
  229. qasight/services/report_archive_diff.py +181 -0
  230. qasight/services/report_email.py +196 -0
  231. qasight/services/report_export.py +240 -0
  232. qasight/services/report_office.py +480 -0
  233. qasight/services/report_service.py +121 -0
  234. qasight/services/rerun_commands.py +280 -0
  235. qasight/services/run_alerts.py +197 -0
  236. qasight/services/run_annotations.py +127 -0
  237. qasight/services/run_digest.py +329 -0
  238. qasight/services/run_environment.py +111 -0
  239. qasight/services/run_failures.py +248 -0
  240. qasight/services/run_release.py +54 -0
  241. qasight/services/run_snapshot_diff.py +37 -0
  242. qasight/services/run_summary_markdown.py +361 -0
  243. qasight/services/server_setup.py +182 -0
  244. qasight/services/setup_pr.py +166 -0
  245. qasight/services/stage_check.py +199 -0
  246. qasight/services/suite_hygiene.py +162 -0
  247. qasight/services/team_view.py +192 -0
  248. qasight/services/test_attachments.py +99 -0
  249. qasight/services/test_history.py +329 -0
  250. qasight/services/test_issues.py +119 -0
  251. qasight/services/test_labels.py +78 -0
  252. qasight/services/test_ownership.py +139 -0
  253. qasight/services/test_selection.py +173 -0
  254. qasight/services/test_split.py +210 -0
  255. qasight/services/triage.py +164 -0
  256. qasight/services/update_check.py +121 -0
  257. qasight/templates/plugin/README.md +16 -0
  258. qasight/templates/plugin/__MODULE__/__init__.py.tmpl +65 -0
  259. qasight/templates/plugin/__MODULE__/runner.py.tmpl +74 -0
  260. qasight/templates/plugin/gitignore +6 -0
  261. qasight/templates/plugin/pyproject.toml +21 -0
  262. qasight/templates/plugin/qasight.yaml +11 -0
  263. qasight/templates/plugin/tests/__init__.py.tmpl +0 -0
  264. qasight/templates/plugin/tests/test_example.py.tmpl +18 -0
  265. qasight/templates/server/docker-compose.yml +117 -0
  266. qasight/ui_bundle.py +30 -0
  267. qasight-1.6.0.dist-info/METADATA +475 -0
  268. qasight-1.6.0.dist-info/RECORD +305 -0
  269. qasight-1.6.0.dist-info/WHEEL +4 -0
  270. qasight-1.6.0.dist-info/entry_points.txt +10 -0
  271. qasight-1.6.0.dist-info/licenses/LICENSE +21 -0
  272. qasight_api/__init__.py +3 -0
  273. qasight_api/cycle_execution_manager.py +560 -0
  274. qasight_api/demo_examples.py +257 -0
  275. qasight_api/dependencies.py +46 -0
  276. qasight_api/execution_events.py +186 -0
  277. qasight_api/login_throttle.py +96 -0
  278. qasight_api/main.py +371 -0
  279. qasight_api/models.py +1425 -0
  280. qasight_api/report_origin.py +180 -0
  281. qasight_api/routes/__init__.py +1 -0
  282. qasight_api/routes/ai.py +107 -0
  283. qasight_api/routes/analytics.py +400 -0
  284. qasight_api/routes/ask.py +25 -0
  285. qasight_api/routes/auth.py +230 -0
  286. qasight_api/routes/badge.py +37 -0
  287. qasight_api/routes/cycles.py +313 -0
  288. qasight_api/routes/dashboard.py +152 -0
  289. qasight_api/routes/health.py +63 -0
  290. qasight_api/routes/history.py +268 -0
  291. qasight_api/routes/ingest.py +96 -0
  292. qasight_api/routes/project_health.py +63 -0
  293. qasight_api/routes/quarantine.py +72 -0
  294. qasight_api/routes/report.py +67 -0
  295. qasight_api/routes/resources.py +233 -0
  296. qasight_api/routes/storage.py +71 -0
  297. qasight_api/routes/triage.py +64 -0
  298. qasight_api/routes/version.py +28 -0
  299. qasight_api/run_queue.py +193 -0
  300. qasight_api/security.py +484 -0
  301. qasight_api/static_site.py +626 -0
  302. qasight_api/ui.py +35 -0
  303. qasight_api/ui_dist/assets/index-CcPZt3fO.js +50 -0
  304. qasight_api/ui_dist/assets/index-gUannA5U.css +2 -0
  305. qasight_api/ui_dist/index.html +21 -0
qasight/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """QASight — public API for the ``qasight`` distribution.
2
+
3
+ The new narrow surface is :class:`qasight.config.Plan` /
4
+ :class:`qasight.config.Stage` plus :func:`qasight.engine.run_plan`.
5
+ Every name is exposed via PEP 562 lazy loading, so ``import qasight`` (which
6
+ pytest does for every run through the ``pytest11`` plugin) loads no config,
7
+ engine or SQLAlchemy code until a consumer actually touches a name. Loading
8
+ them eagerly also hid those modules from pytest-cov, which starts after plugins.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from importlib.metadata import PackageNotFoundError
15
+ from importlib.metadata import version as pkg_version
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+
19
+ def _mirror_env_prefix() -> None:
20
+ """Let ``QASIGHT_*`` stand in for every ``TESTO_*`` setting.
21
+
22
+ Settings kept their ``TESTO_`` names through the rename to QASight (1.6), so
23
+ nothing that already sets them breaks. ``QASIGHT_X`` fills ``TESTO_X`` when that
24
+ isn't set; an explicit ``TESTO_X`` wins. Child processes inherit the result.
25
+ """
26
+ for key, value in list(os.environ.items()):
27
+ if key.startswith("QASIGHT_") and len(key) > len("QASIGHT_"):
28
+ os.environ.setdefault("TESTO_" + key[len("QASIGHT_") :], value)
29
+
30
+
31
+ _mirror_env_prefix()
32
+
33
+ if TYPE_CHECKING:
34
+ from qasight.config import Plan as Plan
35
+ from qasight.config import QASightConfig as QASightConfig
36
+ from qasight.config import Stage as Stage
37
+ from qasight.config import TestosteroneConfig as TestosteroneConfig
38
+ from qasight.engine.exit_codes import EngineExitCode as EngineExitCode
39
+
40
+ # Each entry maps the exported attribute to its real module path. PEP 562
41
+ # :func:`__getattr__` resolves them on first access, so ``import qasight``
42
+ # loads nothing else.
43
+ _LAZY_EXPORTS: dict[str, str] = {
44
+ "Plan": "qasight.config",
45
+ "Stage": "qasight.config",
46
+ "QASightConfig": "qasight.config",
47
+ "TestosteroneConfig": "qasight.config",
48
+ "EngineExitCode": "qasight.engine.exit_codes",
49
+ "get_repository": "qasight.repository.db",
50
+ "reset_repository_cache": "qasight.repository.db",
51
+ "create_db_and_tables": "qasight.repository.db_config",
52
+ "get_engine": "qasight.repository.db_config",
53
+ "reset_engine_cache": "qasight.repository.db_config",
54
+ "resolve_database_url": "qasight.repository.db_config",
55
+ "RunRecord": "qasight.repository.models",
56
+ "RunStatus": "qasight.repository.models",
57
+ }
58
+
59
+
60
+ __all__ = [*_LAZY_EXPORTS.keys()]
61
+
62
+
63
+ def __getattr__(name: str) -> Any:
64
+ """PEP 562 lazy attribute resolver for the names above."""
65
+ module_path = _LAZY_EXPORTS.get(name)
66
+ if module_path is None:
67
+ raise AttributeError(f"module 'qasight' has no attribute {name!r}")
68
+ import importlib
69
+
70
+ module = importlib.import_module(module_path)
71
+ value = getattr(module, name)
72
+ globals()[name] = value # cache for subsequent accesses
73
+ return value
74
+
75
+
76
+ def __dir__() -> list[str]:
77
+ return sorted(__all__)
78
+
79
+
80
+ try:
81
+ __version__ = pkg_version("qasight")
82
+ except PackageNotFoundError: # pragma: no cover - source tree import before install
83
+ __version__ = "0.1.0"
qasight/accounts.py ADDED
@@ -0,0 +1,272 @@
1
+ """Team server accounts and the audit log (used only when ``TESTO_AUTH=accounts``).
2
+
3
+ Accounts live in the run-history database, next to the runs. There is no sign-up:
4
+ an admin creates them with ``qasight users add``, which talks to the database
5
+ directly. Passwords are hashed with ``scrypt`` from the standard library.
6
+
7
+ Roles, from least to most:
8
+
9
+ - ``viewer``: reads runs, logs and reports.
10
+ - ``runner``: also starts runs and uploads results.
11
+ - ``admin``: also changes server settings (AI provider) and reads the audit log.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import hmac
18
+ import os
19
+ import re
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime
22
+ from typing import Any, Final
23
+
24
+ ROLES: Final[tuple[str, ...]] = ("viewer", "runner", "admin")
25
+ _USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$")
26
+ MIN_PASSWORD_LENGTH: Final[int] = 10
27
+
28
+ # scrypt cost: n=2**14, r=8 is the common interactive-login setting (~16 MB, tens of ms).
29
+ _SCRYPT_N, _SCRYPT_R, _SCRYPT_P = 2**14, 8, 1
30
+
31
+
32
+ class AccountError(ValueError):
33
+ """A bad username, password or role, or an account that doesn't exist."""
34
+
35
+
36
+ def role_at_least(role: str, needed: str) -> bool:
37
+ return role in ROLES and ROLES.index(role) >= ROLES.index(needed)
38
+
39
+
40
+ def hash_password(password: str) -> str:
41
+ salt = os.urandom(16)
42
+ digest = hashlib.scrypt(
43
+ password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32
44
+ )
45
+ return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${digest.hex()}"
46
+
47
+
48
+ def verify_password(password: str, stored: str) -> bool:
49
+ try:
50
+ scheme, n, r, p, salt_hex, digest_hex = stored.split("$")
51
+ if scheme != "scrypt":
52
+ return False
53
+ digest = hashlib.scrypt(
54
+ password.encode(),
55
+ salt=bytes.fromhex(salt_hex),
56
+ n=int(n),
57
+ r=int(r),
58
+ p=int(p),
59
+ dklen=len(digest_hex) // 2,
60
+ )
61
+ except (ValueError, TypeError):
62
+ return False
63
+ return hmac.compare_digest(digest.hex(), digest_hex)
64
+
65
+
66
+ def _check_username(username: str) -> str:
67
+ name = username.strip()
68
+ if not _USERNAME_RE.match(name):
69
+ raise AccountError("usernames are 1-64 characters: letters, digits and . _ @ - (not first)")
70
+ return name
71
+
72
+
73
+ def check_new_account(username: str, role: str) -> str:
74
+ """Validate the name and role of an account about to be added (before asking for a
75
+ password); returns the cleaned name. :class:`AccountError` if either is bad."""
76
+ name = _check_username(username)
77
+ _check_role(role)
78
+ return name
79
+
80
+
81
+ def _check_password(password: str) -> None:
82
+ if len(password) < MIN_PASSWORD_LENGTH:
83
+ raise AccountError(f"passwords need at least {MIN_PASSWORD_LENGTH} characters")
84
+
85
+
86
+ def _check_role(role: str) -> str:
87
+ if role not in ROLES:
88
+ raise AccountError(f"role must be one of {', '.join(ROLES)}")
89
+ return role
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class Account:
94
+ username: str
95
+ role: str
96
+ session_version: int
97
+ created_at: datetime
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class AuditEntry:
102
+ at: datetime
103
+ username: str
104
+ method: str
105
+ path: str
106
+ status: int
107
+
108
+
109
+ def _aware(value: datetime) -> datetime:
110
+ return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
111
+
112
+
113
+ class AccountStore:
114
+ """Accounts and audit events on a SQLAlchemy engine (the run-history DB by default)."""
115
+
116
+ def __init__(self, engine: Any = None) -> None:
117
+ from qasight.repository.db_config import create_db_and_tables, get_engine
118
+
119
+ self._engine = engine if engine is not None else get_engine()
120
+ create_db_and_tables(self._engine)
121
+
122
+ # --- accounts ---------------------------------------------------------
123
+
124
+ def add(self, username: str, password: str, role: str = "viewer") -> Account:
125
+ from sqlmodel import Session
126
+
127
+ from qasight.repository.models import UserAccount
128
+
129
+ name = _check_username(username)
130
+ _check_password(password)
131
+ _check_role(role)
132
+ with Session(self._engine) as session:
133
+ if session.get(UserAccount, name) is not None:
134
+ raise AccountError(f"user {name!r} already exists")
135
+ row = UserAccount(username=name, password_hash=hash_password(password), role=role)
136
+ session.add(row)
137
+ session.commit()
138
+ session.refresh(row)
139
+ return self._account(row)
140
+
141
+ def get(self, username: str) -> Account | None:
142
+ from sqlmodel import Session
143
+
144
+ from qasight.repository.models import UserAccount
145
+
146
+ with Session(self._engine) as session:
147
+ row = session.get(UserAccount, username)
148
+ return None if row is None else self._account(row)
149
+
150
+ def all(self) -> list[Account]:
151
+ from sqlmodel import Session, col, select
152
+
153
+ from qasight.repository.models import UserAccount
154
+
155
+ with Session(self._engine) as session:
156
+ rows = session.exec(select(UserAccount).order_by(col(UserAccount.username))).all()
157
+ return [self._account(row) for row in rows]
158
+
159
+ def authenticate(self, username: str, password: str) -> Account | None:
160
+ from sqlmodel import Session
161
+
162
+ from qasight.repository.models import UserAccount
163
+
164
+ with Session(self._engine) as session:
165
+ row = session.get(UserAccount, username.strip())
166
+ if row is None:
167
+ # Same work as a real check, so timing doesn't say which names exist.
168
+ verify_password(password, _DUMMY_HASH)
169
+ return None
170
+ return self._account(row) if verify_password(password, row.password_hash) else None
171
+
172
+ def set_role(self, username: str, role: str) -> Account:
173
+ _check_role(role)
174
+ return self._update(username, role=role)
175
+
176
+ def reset_password(self, username: str, password: str) -> Account:
177
+ _check_password(password)
178
+ return self._update(username, password_hash=hash_password(password))
179
+
180
+ def sign_out_everywhere(self, username: str) -> Account:
181
+ return self._update(username)
182
+
183
+ def remove(self, username: str) -> None:
184
+ from sqlmodel import Session
185
+
186
+ from qasight.repository.models import UserAccount
187
+
188
+ with Session(self._engine) as session:
189
+ row = session.get(UserAccount, username)
190
+ if row is None:
191
+ raise AccountError(f"no user {username!r}")
192
+ session.delete(row)
193
+ session.commit()
194
+
195
+ def _update(self, username: str, **fields: Any) -> Account:
196
+ """Change ``fields`` and bump the session version (every change signs the user out)."""
197
+ from sqlmodel import Session
198
+
199
+ from qasight.repository.models import UserAccount
200
+
201
+ with Session(self._engine) as session:
202
+ row = session.get(UserAccount, username)
203
+ if row is None:
204
+ raise AccountError(f"no user {username!r}")
205
+ for key, value in fields.items():
206
+ setattr(row, key, value)
207
+ row.session_version += 1
208
+ session.add(row)
209
+ session.commit()
210
+ session.refresh(row)
211
+ return self._account(row)
212
+
213
+ @staticmethod
214
+ def _account(row: Any) -> Account:
215
+ return Account(
216
+ username=row.username,
217
+ role=row.role,
218
+ session_version=row.session_version,
219
+ created_at=_aware(row.created_at),
220
+ )
221
+
222
+ # --- audit log --------------------------------------------------------
223
+
224
+ def record(self, *, username: str, method: str, path: str, status: int) -> None:
225
+ from sqlmodel import Session
226
+
227
+ from qasight.repository.models import AuditEvent
228
+
229
+ with Session(self._engine) as session:
230
+ session.add(
231
+ AuditEvent(username=username, method=method, path=path[:1024], status=status)
232
+ )
233
+ session.commit()
234
+
235
+ def recent(self, limit: int = 100) -> list[AuditEntry]:
236
+ from sqlmodel import Session, col, select
237
+
238
+ from qasight.repository.models import AuditEvent
239
+
240
+ with Session(self._engine) as session:
241
+ rows = session.exec(
242
+ select(AuditEvent).order_by(col(AuditEvent.at).desc()).limit(max(1, limit))
243
+ ).all()
244
+ return [AuditEntry(_aware(r.at), r.username, r.method, r.path, r.status) for r in rows]
245
+
246
+
247
+ _DUMMY_HASH: Final[str] = hash_password("not-a-real-password")
248
+
249
+
250
+ def ensure_first_admin(
251
+ env: dict[str, str] | None = None, *, store: AccountStore | None = None
252
+ ) -> str | None:
253
+ """Create ``TESTO_ADMIN_USER`` as admin when ``TESTO_AUTH=accounts`` and no account exists.
254
+
255
+ For a fresh team server (``qasight server init`` writes both variables to
256
+ ``.env``), so nobody has to ``docker compose exec`` into it to add the first
257
+ user. Does nothing once any account exists, so a changed password in ``.env``
258
+ never overwrites one. Returns the name it created.
259
+ """
260
+ import os
261
+
262
+ env = dict(os.environ if env is None else env)
263
+ if env.get("TESTO_AUTH", "").strip().lower() != "accounts":
264
+ return None
265
+ user = env.get("TESTO_ADMIN_USER", "").strip()
266
+ password = env.get("TESTO_ADMIN_PASSWORD", "")
267
+ if not user or not password:
268
+ return None
269
+ store = store or AccountStore()
270
+ if store.all():
271
+ return None
272
+ return store.add(user, password, role="admin").username
@@ -0,0 +1,5 @@
1
+ """``qasight`` CLI package.
2
+
3
+ The Typer app lives in :mod:`qasight.cli.app`; ``qasight run`` delegates to
4
+ :class:`qasight.services.cycle_run.CycleRunService` via :mod:`qasight.cli.runner`.
5
+ """