pytest-test-observer 0.1.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,66 @@
1
+ """Compatibility layer for allure-pytest — the public API surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from pytest_test_observer.allure_compat_helpers import (
8
+ extract_labels,
9
+ extract_links,
10
+ first_label_value,
11
+ read_allure_title,
12
+ )
13
+ from pytest_test_observer.constants import ID_LABEL, SEVERITY_LABEL, TestStatus
14
+ from pytest_test_observer.models import AllureMeta
15
+
16
+ if TYPE_CHECKING:
17
+ import pytest
18
+
19
+ __all__ = ("AllureMeta", "detect_allure", "empty_allure_meta", "extract_allure_meta", "map_status")
20
+
21
+
22
+ def detect_allure(pluginmanager: Any) -> bool:
23
+ return bool(pluginmanager.hasplugin("allure_pytest"))
24
+
25
+
26
+ def map_status(report: pytest.TestReport) -> TestStatus:
27
+ """Map a pytest TestReport to the allure-style status vocabulary.
28
+
29
+ Rules:
30
+ - skipped → SKIPPED
31
+ - passed → PASSED
32
+ - failed (call phase) → FAILED
33
+ - failed (setup/teardown phase) → BROKEN (allure convention)
34
+ - outcome == 'rerun' (pytest-rerunfailures) → FAILED/BROKEN by phase
35
+ - anything else → UNKNOWN
36
+ """
37
+ if report.skipped:
38
+ return TestStatus.SKIPPED
39
+ if report.passed:
40
+ return TestStatus.PASSED
41
+ if report.failed or report.outcome == TestStatus.RERUN:
42
+ if report.when == "call":
43
+ return TestStatus.FAILED
44
+ if report.when in ("setup", "teardown"):
45
+ return TestStatus.BROKEN
46
+ return TestStatus.UNKNOWN
47
+
48
+
49
+ def extract_allure_meta(item: pytest.Item) -> AllureMeta:
50
+ meta = empty_allure_meta()
51
+ meta["labels"] = extract_labels(item)
52
+ meta["links"] = extract_links(item)
53
+ meta["title"] = read_allure_title(item)
54
+ meta["severity"] = first_label_value(meta["labels"], SEVERITY_LABEL)
55
+ meta["allure_id"] = first_label_value(meta["labels"], ID_LABEL)
56
+ return meta
57
+
58
+
59
+ def empty_allure_meta() -> AllureMeta:
60
+ return {
61
+ "labels": {},
62
+ "links": [],
63
+ "title": "",
64
+ "severity": "",
65
+ "allure_id": "",
66
+ }
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from pytest_test_observer.constants import (
6
+ DISPLAY_NAME_ATTR,
7
+ LABEL_MARK,
8
+ LABEL_TYPE_KEY,
9
+ LINK_MARK,
10
+ LINK_NAME_KEY,
11
+ LINK_TYPE_KEY,
12
+ )
13
+ from pytest_test_observer.helper import as_str
14
+
15
+ if TYPE_CHECKING:
16
+ import pytest
17
+
18
+
19
+ def extract_labels(item: pytest.Item) -> dict[str, list[str]]:
20
+ labels: dict[str, list[str]] = {}
21
+ for mark in item.iter_markers(LABEL_MARK):
22
+ label_type = mark.kwargs.get(LABEL_TYPE_KEY)
23
+ if label_type is None:
24
+ continue
25
+ type_str = as_str(label_type)
26
+ for value in mark.args:
27
+ labels.setdefault(type_str, []).append(as_str(value))
28
+ return labels
29
+
30
+
31
+ def extract_links(item: pytest.Item) -> list[tuple[str, str, str]]:
32
+ links: list[tuple[str, str, str]] = []
33
+ for mark in item.iter_markers(LINK_MARK):
34
+ if not mark.args:
35
+ continue
36
+ url = as_str(mark.args[0])
37
+ link_type = as_str(mark.kwargs.get(LINK_TYPE_KEY, ""))
38
+ link_name = as_str(mark.kwargs.get(LINK_NAME_KEY, ""))
39
+ links.append((link_type, url, link_name))
40
+ return links
41
+
42
+
43
+ def read_allure_title(item: pytest.Item) -> str:
44
+ func = getattr(item, "function", None) or getattr(item, "obj", None)
45
+ if func is None:
46
+ return ""
47
+ return str(getattr(func, DISPLAY_NAME_ATTR, "") or "")
48
+
49
+
50
+ def first_label_value(labels: dict[str, list[str]], key: str) -> str:
51
+ values = labels.get(key, ())
52
+ return values[0] if values else ""
@@ -0,0 +1,51 @@
1
+ """On-disk JSONL fallback for batches we couldn't push to ClickHouse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ _BUFFER_SUBDIR = "pytest-test-observer"
13
+ _EXTENSION = ".jsonl"
14
+
15
+ # Whitelist of characters allowed verbatim in run_id-derived filenames.
16
+ # Anything else (including path separators and `..`) is mapped to '_'.
17
+ _UNSAFE_CHARS = re.compile(r"[^A-Za-z0-9._-]")
18
+ # OS filename limits hover around 255 bytes; leave headroom for the
19
+ # extension and any pathological multi-byte inputs.
20
+ _MAX_RUN_ID = 128
21
+
22
+
23
+ def buffer_dir() -> Path:
24
+ base = os.environ.get("XDG_CACHE_HOME")
25
+ root = Path(base) if base else Path.home() / ".cache"
26
+ return root / _BUFFER_SUBDIR
27
+
28
+
29
+ def write_jsonl(rows: list[dict], run_id: str) -> Path:
30
+ safe_id = _sanitize_run_id(run_id)
31
+ path = buffer_dir() / f"{safe_id}{_EXTENSION}"
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ if not rows:
34
+ return path
35
+ content = "\n".join(json.dumps(row, default=_json_default) for row in rows) + "\n"
36
+ with path.open("a", encoding="utf-8") as f:
37
+ f.write(content)
38
+ return path
39
+
40
+
41
+ def _sanitize_run_id(run_id: str) -> str:
42
+ cleaned = _UNSAFE_CHARS.sub("_", run_id or "")[:_MAX_RUN_ID]
43
+ return cleaned or "unknown"
44
+
45
+
46
+ def _json_default(obj: Any) -> Any:
47
+ if isinstance(obj, datetime):
48
+ return obj.isoformat(timespec="milliseconds")
49
+ if isinstance(obj, tuple):
50
+ return list(obj)
51
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
@@ -0,0 +1,57 @@
1
+ from enum import StrEnum
2
+
3
+ from pytest_test_observer.models import _CIProvider
4
+
5
+
6
+ class TestStatus(StrEnum):
7
+ PASSED = "passed"
8
+ FAILED = "failed"
9
+ BROKEN = "broken"
10
+ SKIPPED = "skipped"
11
+ RERUN = "rerun"
12
+ UNKNOWN = "unknown"
13
+
14
+
15
+ _CI_PROVIDERS: tuple[_CIProvider, ...] = (
16
+ _CIProvider(
17
+ name="github",
18
+ sentinel="GITHUB_ACTIONS",
19
+ run_id_vars=("GITHUB_RUN_ID",),
20
+ commit_vars=("GITHUB_SHA",),
21
+ branch_vars=("GITHUB_HEAD_REF", "GITHUB_REF_NAME"),
22
+ ),
23
+ _CIProvider(
24
+ name="gitlab",
25
+ sentinel="GITLAB_CI",
26
+ run_id_vars=("CI_PIPELINE_ID",),
27
+ commit_vars=("CI_COMMIT_SHA",),
28
+ branch_vars=("CI_COMMIT_REF_NAME",),
29
+ ),
30
+ _CIProvider(
31
+ name="circle",
32
+ sentinel="CIRCLECI",
33
+ run_id_vars=("CIRCLE_BUILD_NUM",),
34
+ commit_vars=("CIRCLE_SHA1",),
35
+ branch_vars=("CIRCLE_BRANCH",),
36
+ ),
37
+ _CIProvider(
38
+ name="jenkins",
39
+ sentinel="JENKINS_URL",
40
+ run_id_vars=("BUILD_ID", "BUILD_NUMBER"),
41
+ commit_vars=("GIT_COMMIT",),
42
+ branch_vars=("GIT_BRANCH",),
43
+ ),
44
+ )
45
+
46
+
47
+ LABEL_MARK = "allure_label"
48
+ LINK_MARK = "allure_link"
49
+
50
+ LABEL_TYPE_KEY = "label_type"
51
+ LINK_TYPE_KEY = "link_type"
52
+ LINK_NAME_KEY = "name"
53
+
54
+ SEVERITY_LABEL = "severity"
55
+ ID_LABEL = "as_id" # LabelType.ID resolves to "as_id" in allure-python-commons
56
+
57
+ DISPLAY_NAME_ATTR = "__allure_display_name__"
@@ -0,0 +1,63 @@
1
+ """Detect CI provider context and read git commit/branch for the current run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from collections.abc import Mapping
8
+
9
+ from pytest_test_observer.constants import _CI_PROVIDERS
10
+ from pytest_test_observer.models import CIContext
11
+
12
+
13
+ def is_ci() -> bool:
14
+ env = os.environ
15
+ return any(env.get(p.sentinel) for p in _CI_PROVIDERS)
16
+
17
+
18
+ def detect_ci_context() -> CIContext:
19
+ env = os.environ
20
+ for provider in _CI_PROVIDERS:
21
+ if env.get(provider.sentinel):
22
+ return {
23
+ "ci_provider": provider.name,
24
+ "ci_run_id": _first_env(env, provider.run_id_vars),
25
+ "git_commit": _first_env(env, provider.commit_vars),
26
+ "git_branch": _first_env(env, provider.branch_vars),
27
+ }
28
+ commit, branch = _git_local()
29
+ return {
30
+ "ci_provider": "local",
31
+ "ci_run_id": "",
32
+ "git_commit": commit,
33
+ "git_branch": branch,
34
+ }
35
+
36
+
37
+ def _first_env(env: Mapping[str, str], names: tuple[str, ...]) -> str:
38
+ for name in names:
39
+ value = env.get(name, "")
40
+ if value:
41
+ return value
42
+ return ""
43
+
44
+
45
+ def _git_local() -> tuple[str, str]:
46
+ return (
47
+ _run_git(["rev-parse", "HEAD"]),
48
+ _run_git(["rev-parse", "--abbrev-ref", "HEAD"]),
49
+ )
50
+
51
+
52
+ def _run_git(args: list[str]) -> str:
53
+ try:
54
+ result = subprocess.run(
55
+ ["git", *args],
56
+ capture_output=True,
57
+ text=True,
58
+ timeout=2,
59
+ check=True,
60
+ )
61
+ return result.stdout.strip()
62
+ except Exception:
63
+ return ""
@@ -0,0 +1,25 @@
1
+ """Small, plugin-agnostic value-normalisation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from typing import Any
7
+
8
+
9
+ def as_str(value: Any) -> str:
10
+ if isinstance(value, enum.Enum):
11
+ return str(value.value)
12
+ return str(value)
13
+
14
+
15
+ def as_bool(value: Any, default: bool = False) -> bool:
16
+ if isinstance(value, bool):
17
+ return value
18
+ if value is None:
19
+ return default
20
+ s = str(value).strip().lower()
21
+ if s in ("true", "1", "yes", "on"):
22
+ return True
23
+ if s in ("false", "0", "no", "off", ""):
24
+ return False
25
+ return default
@@ -0,0 +1,30 @@
1
+ """Shared data shapes used across the plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TypedDict
7
+
8
+
9
+ class AllureMeta(TypedDict):
10
+ labels: dict[str, list[str]]
11
+ links: list[tuple[str, str, str]]
12
+ title: str
13
+ severity: str
14
+ allure_id: str
15
+
16
+
17
+ class CIContext(TypedDict):
18
+ ci_provider: str
19
+ ci_run_id: str
20
+ git_commit: str
21
+ git_branch: str
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class _CIProvider:
26
+ name: str
27
+ sentinel: str
28
+ run_id_vars: tuple[str, ...]
29
+ commit_vars: tuple[str, ...]
30
+ branch_vars: tuple[str, ...]
@@ -0,0 +1,68 @@
1
+ """CLI / env-var / pyproject.toml-ini option registration and resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ import pytest
10
+
11
+
12
+ # (cli_name, ini_name, env_var, default)
13
+ OPTION_SPECS: tuple[tuple[str, str, str, str | None], ...] = (
14
+ ("--ch-url", "ch_url", "PYTEST_OBSERVER_CH_URL", None),
15
+ ("--ch-user", "ch_user", "PYTEST_OBSERVER_CH_USER", "default"),
16
+ ("--ch-password", "ch_password", "PYTEST_OBSERVER_CH_PASSWORD", ""),
17
+ ("--ch-db", "ch_db", "PYTEST_OBSERVER_CH_DB", "default"),
18
+ ("--ch-table", "ch_table", "PYTEST_OBSERVER_CH_TABLE", "pytest_results"),
19
+ ("--ch-send-from", "ch_send_from", "PYTEST_OBSERVER_CH_SEND_FROM", "any"),
20
+ ("--ch-auto-migrate", "ch_auto_migrate", "PYTEST_OBSERVER_CH_AUTO_MIGRATE", "true"),
21
+ )
22
+
23
+ _HELP: dict[str, str] = {
24
+ "ch_url": "ClickHouse URL (host, host:port, or http(s)://host:port).",
25
+ "ch_user": "ClickHouse username.",
26
+ "ch_password": "ClickHouse password.",
27
+ "ch_db": "ClickHouse database name.",
28
+ "ch_table": "ClickHouse table name.",
29
+ "ch_send_from": "When to send: 'any' (default: local + CI) or 'ci' (skip when no CI env detected).",
30
+ "ch_auto_migrate": "Auto-add missing columns via ALTER TABLE when the schema drifts forward. true/false (default true).",
31
+ }
32
+
33
+
34
+ def add_options(parser: pytest.Parser) -> None:
35
+ group = parser.getgroup("test-observer")
36
+ for cli, ini, env, _default in OPTION_SPECS:
37
+ group.addoption(
38
+ cli,
39
+ action="store",
40
+ default=None,
41
+ help=f"{_HELP[ini]} Also: env {env} or `{ini}` in pyproject.toml [tool.pytest.ini_options].",
42
+ )
43
+ parser.addini(ini, _HELP[ini], default=None)
44
+
45
+
46
+ def resolve_options(config: pytest.Config) -> dict[str, str | None]:
47
+ return {
48
+ ini: _resolve_one(config, cli, ini, env, default) for cli, ini, env, default in OPTION_SPECS
49
+ }
50
+
51
+
52
+ def _resolve_one(
53
+ config: pytest.Config,
54
+ cli: str,
55
+ ini: str,
56
+ env: str,
57
+ default: str | None,
58
+ ) -> str | None:
59
+ cli_value = config.getoption(cli)
60
+ if cli_value is not None:
61
+ return cli_value
62
+ env_value = os.environ.get(env)
63
+ if env_value is not None:
64
+ return env_value
65
+ ini_value = config.getini(ini)
66
+ if ini_value:
67
+ return ini_value
68
+ return default
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import threading
6
+ import uuid
7
+ import warnings
8
+ from datetime import datetime, timezone
9
+
10
+ import pytest
11
+
12
+ from pytest_test_observer import buffer
13
+ from pytest_test_observer.allure_compat import (
14
+ detect_allure,
15
+ empty_allure_meta,
16
+ extract_allure_meta,
17
+ map_status,
18
+ )
19
+ from pytest_test_observer.context import detect_ci_context, is_ci
20
+ from pytest_test_observer.helper import as_bool
21
+ from pytest_test_observer.options import add_options, resolve_options
22
+ from pytest_test_observer.reporter import ClickHouseReporter
23
+
24
+ _USER_PROP_MARKERS = "_test_observer_markers"
25
+ _USER_PROP_ALLURE = "_test_observer_allure"
26
+ _USER_PROP_TIMING = "_test_observer_timing"
27
+ _FLUSH_TIMEOUT = 10.0
28
+
29
+
30
+ def pytest_addoption(parser: pytest.Parser) -> None:
31
+ add_options(parser)
32
+
33
+
34
+ def pytest_configure(config: pytest.Config) -> None:
35
+ opts = resolve_options(config)
36
+ if not opts["ch_url"]:
37
+ return
38
+ if str(opts.get("ch_send_from", "any")).lower() == "ci" and not is_ci():
39
+ return
40
+ plugin = ObserverPlugin(config, opts)
41
+ config._test_observer = plugin
42
+ config.pluginmanager.register(plugin, "test_observer_plugin")
43
+
44
+
45
+ def pytest_unconfigure(config: pytest.Config) -> None:
46
+ plugin = getattr(config, "_test_observer", None)
47
+ if plugin is not None and config.pluginmanager.is_registered(plugin):
48
+ config.pluginmanager.unregister(plugin)
49
+ config._test_observer = None
50
+
51
+
52
+ class ObserverPlugin:
53
+ def __init__(self, config: pytest.Config, opts: dict) -> None:
54
+ self.config = config
55
+ self.opts = opts
56
+ self.is_worker = hasattr(config, "workerinput")
57
+ self.run_id = os.environ.get("PYTEST_OBSERVER_RUN_ID") or str(uuid.uuid4())
58
+ self.context = detect_ci_context() if not self.is_worker else {}
59
+ self.results: list = []
60
+ self.allure_active = detect_allure(config.pluginmanager)
61
+
62
+ @pytest.hookimpl(hookwrapper=True, trylast=True)
63
+ def pytest_runtest_makereport(self, item: pytest.Item, call: pytest.CallInfo):
64
+ outcome = yield
65
+ report = outcome.get_result()
66
+ markers = sorted({m.name for m in item.iter_markers() if not m.name.startswith("allure_")})
67
+ report.user_properties.append((_USER_PROP_MARKERS, markers))
68
+ report.user_properties.append((_USER_PROP_ALLURE, extract_allure_meta(item)))
69
+ report.user_properties.append(
70
+ (_USER_PROP_TIMING, (getattr(call, "start", None), getattr(call, "stop", None)))
71
+ )
72
+
73
+ def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
74
+ if self.is_worker or not _should_record(report):
75
+ return
76
+ markers, allure_meta, timing = _read_user_properties(report)
77
+ self.results.append(
78
+ _build_row(
79
+ report=report,
80
+ run_id=self.run_id,
81
+ markers=markers,
82
+ allure_meta=allure_meta,
83
+ timing=timing,
84
+ context=self.context,
85
+ )
86
+ )
87
+
88
+ def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
89
+ if self.is_worker or not self.results:
90
+ return
91
+
92
+ reporter = ClickHouseReporter(
93
+ url=self.opts["ch_url"],
94
+ user=self.opts["ch_user"],
95
+ password=self.opts["ch_password"],
96
+ db=self.opts["ch_db"],
97
+ table=self.opts["ch_table"],
98
+ auto_migrate=as_bool(self.opts["ch_auto_migrate"], default=True),
99
+ )
100
+ rows = list(self.results)
101
+ flush_done = threading.Event()
102
+
103
+ def _run_flush() -> None:
104
+ try:
105
+ reporter.flush(rows, self.run_id)
106
+ finally:
107
+ flush_done.set()
108
+
109
+ thread = threading.Thread(
110
+ target=_run_flush,
111
+ daemon=True,
112
+ name="test-observer-flush",
113
+ )
114
+ thread.start()
115
+
116
+ if not flush_done.wait(timeout=_FLUSH_TIMEOUT) and not flush_done.is_set():
117
+ warnings.warn(
118
+ "[pytest-test-observer] flush thread timed out; buffering to disk",
119
+ stacklevel=2,
120
+ )
121
+ with contextlib.suppress(Exception):
122
+ buffer.write_jsonl(rows, self.run_id)
123
+
124
+
125
+ def _should_record(report: pytest.TestReport) -> bool:
126
+ if report.when == "call":
127
+ return True
128
+ if report.when == "setup":
129
+ return bool(report.failed or report.skipped)
130
+ if report.when == "teardown":
131
+ return bool(report.failed)
132
+ return False
133
+
134
+
135
+ def _build_row(*, report, run_id, markers, allure_meta, timing, context) -> dict:
136
+ started, finished = timing
137
+ return {
138
+ "run_id": run_id,
139
+ "timestamp": datetime.now(timezone.utc),
140
+ "started_at": int(started * 1000) if started else 0,
141
+ "finished_at": int(finished * 1000) if finished else 0,
142
+ "nodeid": report.nodeid,
143
+ "status": map_status(report),
144
+ "when_phase": report.when,
145
+ "duration": float(report.duration),
146
+ "markers": list(markers),
147
+ "worker_id": _worker_id(report),
148
+ "ci_provider": context.get("ci_provider", ""),
149
+ "ci_run_id": context.get("ci_run_id", ""),
150
+ "git_commit": context.get("git_commit", ""),
151
+ "git_branch": context.get("git_branch", ""),
152
+ "allure_id": allure_meta.get("allure_id", ""),
153
+ "allure_title": allure_meta.get("title", ""),
154
+ "allure_severity": allure_meta.get("severity", ""),
155
+ "allure_labels": dict(allure_meta.get("labels", {})),
156
+ "allure_links": [tuple(link) for link in allure_meta.get("links", [])],
157
+ }
158
+
159
+
160
+ def _read_user_properties(report: pytest.TestReport) -> tuple:
161
+ markers: list = []
162
+ allure_meta: dict = empty_allure_meta()
163
+ timing: tuple = (None, None)
164
+ for key, value in getattr(report, "user_properties", []):
165
+ if key == _USER_PROP_MARKERS:
166
+ markers = value
167
+ elif key == _USER_PROP_ALLURE:
168
+ allure_meta = value
169
+ elif key == _USER_PROP_TIMING:
170
+ timing = value
171
+ return markers, allure_meta, timing
172
+
173
+
174
+ def _worker_id(report: pytest.TestReport) -> str:
175
+ try:
176
+ gw_id = report.node.gateway.id
177
+ except AttributeError:
178
+ return "master"
179
+ return str(gw_id) if gw_id else "master"
File without changes
@@ -0,0 +1,201 @@
1
+ """Replay buffered JSONL files back into ClickHouse.
2
+
3
+ When a session-end flush fails (network down, CH unreachable, timeout, etc.),
4
+ each session's rows are written to
5
+ ``$XDG_CACHE_HOME/pytest-test-observer/<run_id>.jsonl``. This tool reads those
6
+ files, parses each line, and pushes the rows to ClickHouse. On success the
7
+ buffer file is deleted; on failure it's recreated by the reporter's fallback
8
+ path so a later replay can pick up where this one left off.
9
+
10
+ Invocation:
11
+
12
+ python -m pytest_test_observer.replay --ch-url=localhost:8123
13
+ python -m pytest_test_observer.replay /path/to/specific.jsonl
14
+ python -m pytest_test_observer.replay --dry-run
15
+
16
+ Options mirror the plugin (``--ch-url``, ``--ch-user``, ``--ch-password``,
17
+ ``--ch-db``, ``--ch-table``, ``--ch-auto-migrate``) and pick up the same
18
+ ``PYTEST_OBSERVER_CH_*`` env vars when set.
19
+
20
+ Forward-compatibility with older schemas: rows missing any current column
21
+ get a sensible default (empty string / 0 / empty array), so a JSONL file
22
+ written by an older plugin version replays cleanly into the latest table.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import sys
31
+ import warnings
32
+ from pathlib import Path
33
+
34
+ from pytest_test_observer.buffer import buffer_dir
35
+ from pytest_test_observer.helper import as_bool
36
+ from pytest_test_observer.reporter import ClickHouseReporter
37
+ from pytest_test_observer.schema import SCHEMA
38
+
39
+ _DEFAULT_FOR_TYPE: dict = {
40
+ "String": "",
41
+ "DateTime64(3)": "", # ISO string accepted by clickhouse-connect
42
+ "UInt64": 0,
43
+ "Float64": 0.0,
44
+ "LowCardinality(String)": "",
45
+ "Array(String)": [],
46
+ "Map(String, Array(String))": {},
47
+ "Array(Tuple(String, String, String))": [],
48
+ }
49
+
50
+ # Pre-compute per-column defaults so the inner loop is cheap.
51
+ _COLUMN_DEFAULTS: dict = {name: _DEFAULT_FOR_TYPE.get(type_, "") for name, type_ in SCHEMA}
52
+
53
+
54
+ def parse_jsonl_text(text: str) -> tuple[list, int]:
55
+ rows: list = []
56
+ skipped = 0
57
+ for line_num, line in enumerate(text.splitlines(), 1):
58
+ stripped = line.strip()
59
+ if not stripped:
60
+ continue
61
+ try:
62
+ row = json.loads(stripped)
63
+ except json.JSONDecodeError as exc:
64
+ warnings.warn(
65
+ f"[replay] line {line_num}: not valid JSON ({exc}); skipping",
66
+ stacklevel=2,
67
+ )
68
+ skipped += 1
69
+ continue
70
+ if not isinstance(row, dict):
71
+ warnings.warn(
72
+ f"[replay] line {line_num}: not a JSON object; skipping",
73
+ stacklevel=2,
74
+ )
75
+ skipped += 1
76
+ continue
77
+ for col, default in _COLUMN_DEFAULTS.items():
78
+ row.setdefault(col, default)
79
+ rows.append(row)
80
+ return rows, skipped
81
+
82
+
83
+ def replay_file(reporter: ClickHouseReporter, path: Path, *, keep: bool) -> tuple[int, int, bool]:
84
+ text = path.read_text(encoding="utf-8")
85
+ rows, skipped = parse_jsonl_text(text)
86
+ if not rows:
87
+ return 0, skipped, True
88
+ if not keep:
89
+ path.unlink()
90
+ ok = reporter.flush(rows, path.stem)
91
+ return len(rows), skipped, ok
92
+
93
+
94
+ def find_buffer_files(paths: list) -> list:
95
+ if not paths:
96
+ return sorted(buffer_dir().glob("*.jsonl"))
97
+ files: list = []
98
+ for p in paths:
99
+ if p.is_dir():
100
+ files.extend(sorted(p.glob("*.jsonl")))
101
+ elif p.exists():
102
+ files.append(p)
103
+ else:
104
+ warnings.warn(f"[replay] no such file or directory: {p}", stacklevel=2)
105
+ return files
106
+
107
+
108
+ def _build_parser() -> argparse.ArgumentParser:
109
+ p = argparse.ArgumentParser(
110
+ prog="python -m pytest_test_observer.replay",
111
+ description="Replay buffered JSONL files into ClickHouse.",
112
+ )
113
+ p.add_argument(
114
+ "paths",
115
+ nargs="*",
116
+ type=Path,
117
+ help="JSONL files or directories to replay (default: scan buffer dir)",
118
+ )
119
+ p.add_argument(
120
+ "--ch-url",
121
+ default=os.environ.get("PYTEST_OBSERVER_CH_URL"),
122
+ help="ClickHouse URL (host, host:port, or http(s)://...) [required]",
123
+ )
124
+ p.add_argument("--ch-user", default=os.environ.get("PYTEST_OBSERVER_CH_USER", "default"))
125
+ p.add_argument("--ch-password", default=os.environ.get("PYTEST_OBSERVER_CH_PASSWORD", ""))
126
+ p.add_argument("--ch-db", default=os.environ.get("PYTEST_OBSERVER_CH_DB", "default"))
127
+ p.add_argument(
128
+ "--ch-table",
129
+ default=os.environ.get("PYTEST_OBSERVER_CH_TABLE", "pytest_results"),
130
+ )
131
+ p.add_argument(
132
+ "--ch-auto-migrate",
133
+ default=os.environ.get("PYTEST_OBSERVER_CH_AUTO_MIGRATE", "true"),
134
+ help="true/false; auto-add missing columns via ALTER TABLE",
135
+ )
136
+ p.add_argument(
137
+ "--keep",
138
+ action="store_true",
139
+ help="don't delete buffer files after successful replay",
140
+ )
141
+ p.add_argument(
142
+ "--dry-run",
143
+ action="store_true",
144
+ help="list files that would be replayed; don't touch ClickHouse",
145
+ )
146
+ return p
147
+
148
+
149
+ def main(argv: list | None = None) -> int:
150
+ args = _build_parser().parse_args(argv)
151
+ files = find_buffer_files(args.paths)
152
+
153
+ if not files:
154
+ print("no buffer files found", file=sys.stderr)
155
+ return 0
156
+
157
+ if args.dry_run:
158
+ for f in files:
159
+ line_count = sum(
160
+ 1 for line in f.read_text(encoding="utf-8").splitlines() if line.strip()
161
+ )
162
+ print(f"would replay {f} ({line_count} lines)")
163
+ return 0
164
+
165
+ if not args.ch_url:
166
+ print(
167
+ "error: --ch-url is required (or set PYTEST_OBSERVER_CH_URL)",
168
+ file=sys.stderr,
169
+ )
170
+ return 2
171
+
172
+ reporter = ClickHouseReporter(
173
+ url=args.ch_url,
174
+ user=args.ch_user,
175
+ password=args.ch_password,
176
+ db=args.ch_db,
177
+ table=args.ch_table,
178
+ auto_migrate=as_bool(args.ch_auto_migrate, default=True),
179
+ )
180
+
181
+ rows_total = 0
182
+ skipped_total = 0
183
+ failures: list = []
184
+ for f in files:
185
+ rows, skipped, ok = replay_file(reporter, f, keep=args.keep)
186
+ rows_total += rows
187
+ skipped_total += skipped
188
+ status = "ok" if ok else "FAILED (re-buffered)"
189
+ print(f" {f}: {rows} rows, {skipped} skipped — {status}")
190
+ if not ok:
191
+ failures.append(f)
192
+
193
+ print(
194
+ f"\ndone: {rows_total} rows replayed across {len(files)} files; "
195
+ f"{skipped_total} malformed lines skipped; {len(failures)} files failed."
196
+ )
197
+ return 1 if failures else 0
198
+
199
+
200
+ if __name__ == "__main__":
201
+ sys.exit(main())
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import warnings
7
+ from urllib.parse import urlparse
8
+
9
+ import clickhouse_connect
10
+
11
+ from pytest_test_observer import buffer
12
+ from pytest_test_observer.schema import COLUMNS, column_defs_sql, ensure_schema
13
+
14
+ _METRICS_ENV = "PYTEST_OBSERVER_METRICS_FILE"
15
+
16
+ # Re-export COLUMNS for callers that imported it from reporter (back-compat).
17
+ __all__ = ("COLUMNS", "CREATE_TABLE_SQL", "ClickHouseReporter", "parse_url")
18
+
19
+ CREATE_TABLE_SQL = (
20
+ "CREATE TABLE IF NOT EXISTS {table} (\n "
21
+ + column_defs_sql()
22
+ + "\n) ENGINE = MergeTree\nORDER BY (nodeid, timestamp)\nPARTITION BY toYYYYMM(timestamp)"
23
+ )
24
+
25
+
26
+ class ClickHouseReporter:
27
+ def __init__(
28
+ self,
29
+ *,
30
+ url: str,
31
+ user: str,
32
+ password: str,
33
+ db: str,
34
+ table: str,
35
+ auto_migrate: bool = True,
36
+ ):
37
+ self.url = url
38
+ self.user = user
39
+ self.password = password
40
+ self.db = db
41
+ self.table = table
42
+ self.auto_migrate = auto_migrate
43
+
44
+ def flush(self, rows: list, run_id: str) -> bool:
45
+ if not rows:
46
+ return True
47
+ metrics = {
48
+ "rows": len(rows),
49
+ "bytes_written": 0,
50
+ "flush_seconds": 0.0,
51
+ "ok": False,
52
+ "migrations_applied": [],
53
+ }
54
+ start = time.perf_counter()
55
+ try:
56
+ host, port, secure = parse_url(self.url)
57
+ client = clickhouse_connect.get_client(
58
+ host=host,
59
+ port=port,
60
+ username=self.user,
61
+ password=self.password,
62
+ database=self.db,
63
+ secure=secure,
64
+ connect_timeout=5,
65
+ send_receive_timeout=10,
66
+ )
67
+ client.command(CREATE_TABLE_SQL.format(table=self.table))
68
+ added = ensure_schema(client, self.table, auto_migrate=self.auto_migrate)
69
+ if added:
70
+ metrics["migrations_applied"] = added
71
+ warnings.warn(
72
+ f"[pytest-test-observer] auto-migrated {self.table!r}: added columns {added}",
73
+ stacklevel=2,
74
+ )
75
+ data = [[row[c] for c in COLUMNS] for row in rows]
76
+ summary = client.insert(self.table, data, column_names=list(COLUMNS))
77
+ metrics["bytes_written"] = _summary_bytes(summary)
78
+ metrics["ok"] = True
79
+ except Exception as exc:
80
+ warnings.warn(
81
+ f"[pytest-test-observer] flush to ClickHouse failed: {exc!r}; "
82
+ f"writing {len(rows)} rows to disk buffer",
83
+ stacklevel=2,
84
+ )
85
+ try:
86
+ path = buffer.write_jsonl(rows, run_id)
87
+ warnings.warn(
88
+ f"[pytest-test-observer] buffered to {path}",
89
+ stacklevel=2,
90
+ )
91
+ except Exception as exc2:
92
+ warnings.warn(
93
+ f"[pytest-test-observer] disk buffer also failed: {exc2!r}",
94
+ stacklevel=2,
95
+ )
96
+ finally:
97
+ metrics["flush_seconds"] = time.perf_counter() - start
98
+ _maybe_write_metrics(metrics)
99
+ return metrics["ok"]
100
+
101
+
102
+ def _summary_bytes(summary) -> int:
103
+ if summary is None:
104
+ return 0
105
+ payload = getattr(summary, "summary", None) or {}
106
+ raw = payload.get("written_bytes") or payload.get("read_bytes") or 0
107
+ try:
108
+ return int(raw)
109
+ except (TypeError, ValueError):
110
+ return 0
111
+
112
+
113
+ def _maybe_write_metrics(metrics: dict) -> None:
114
+ path = os.environ.get(_METRICS_ENV)
115
+ if not path:
116
+ return
117
+ try:
118
+ with open(path, "w", encoding="utf-8") as f:
119
+ json.dump(metrics, f)
120
+ except Exception:
121
+ pass
122
+
123
+
124
+ def parse_url(url: str) -> tuple:
125
+ if "://" in url:
126
+ parsed = urlparse(url)
127
+ host = parsed.hostname or ""
128
+ secure = parsed.scheme == "https"
129
+ port = parsed.port or (8443 if secure else 8123)
130
+ return host, port, secure
131
+ if ":" in url:
132
+ host, _, port_s = url.partition(":")
133
+ return host, int(port_s), False
134
+ return url, 8123, False
@@ -0,0 +1,90 @@
1
+ """ClickHouse schema definition + diff/migrate helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ SCHEMA: tuple = (
8
+ ("run_id", "String"),
9
+ ("timestamp", "DateTime64(3)"),
10
+ ("started_at", "UInt64"),
11
+ ("finished_at", "UInt64"),
12
+ ("nodeid", "String"),
13
+ ("status", "LowCardinality(String)"),
14
+ ("when_phase", "LowCardinality(String)"),
15
+ ("duration", "Float64"),
16
+ ("markers", "Array(String)"),
17
+ ("worker_id", "LowCardinality(String)"),
18
+ ("ci_provider", "LowCardinality(String)"),
19
+ ("ci_run_id", "String"),
20
+ ("git_commit", "String"),
21
+ ("git_branch", "String"),
22
+ ("allure_id", "String"),
23
+ ("allure_title", "String"),
24
+ ("allure_severity", "LowCardinality(String)"),
25
+ ("allure_labels", "Map(String, Array(String))"),
26
+ ("allure_links", "Array(Tuple(String, String, String))"),
27
+ )
28
+
29
+ EXPECTED_SCHEMA: dict = dict(SCHEMA)
30
+ COLUMNS: tuple = tuple(name for name, _ in SCHEMA)
31
+
32
+
33
+ def column_defs_sql() -> str:
34
+ return ",\n ".join(f"{name} {type_}" for name, type_ in SCHEMA)
35
+
36
+
37
+ class SchemaError(Exception):
38
+ """Raised when the ClickHouse table can't be safely migrated."""
39
+
40
+
41
+ def ensure_schema(client, table: str, *, auto_migrate: bool) -> list:
42
+ actual = _read_actual_columns(client, table)
43
+
44
+ type_mismatches = [
45
+ (name, actual[name], expected_type)
46
+ for name, expected_type in EXPECTED_SCHEMA.items()
47
+ if name in actual and not _types_compatible(actual[name], expected_type)
48
+ ]
49
+ if type_mismatches:
50
+ details = "; ".join(f"{n} has {a!r}, expected {e!r}" for n, a, e in type_mismatches)
51
+ raise SchemaError(
52
+ f"ClickHouse table {table!r} has incompatible column types ({details}). "
53
+ "This requires a manual migration — either DROP and recreate the table "
54
+ "(loses history) or ALTER TABLE MODIFY COLUMN with appropriate care."
55
+ )
56
+
57
+ missing = [(name, type_) for name, type_ in EXPECTED_SCHEMA.items() if name not in actual]
58
+ if not missing:
59
+ return []
60
+
61
+ if not auto_migrate:
62
+ sql = "; ".join(f"ALTER TABLE {table} ADD COLUMN {name} {type_}" for name, type_ in missing)
63
+ raise SchemaError(
64
+ f"ClickHouse table {table!r} is missing {len(missing)} column(s) "
65
+ f"and ch_auto_migrate is disabled. Run manually:\n {sql}"
66
+ )
67
+
68
+ for name, type_ in missing:
69
+ client.command(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS {name} {type_}")
70
+ return [name for name, _ in missing]
71
+
72
+
73
+ def _read_actual_columns(client, table: str) -> dict:
74
+ result = client.query(
75
+ "SELECT name, type FROM system.columns "
76
+ "WHERE database = currentDatabase() AND table = {table:String}",
77
+ parameters={"table": table},
78
+ )
79
+ return {row[0]: row[1] for row in result.result_rows}
80
+
81
+
82
+ def _types_compatible(actual: str, expected: str) -> bool:
83
+ return _normalize(actual) == _normalize(expected)
84
+
85
+
86
+ _WS_RE = re.compile(r"\s+")
87
+
88
+
89
+ def _normalize(t: str) -> str:
90
+ return _WS_RE.sub("", t)
@@ -0,0 +1,226 @@
1
+ Metadata-Version: 2.3
2
+ Name: pytest-test-observer
3
+ Version: 0.1.0
4
+ Summary: A pytest plugin for observing test execution events.
5
+ Author: Dmitrii Shakhov
6
+ Author-email: Dmitrii Shakhov <sourx.rus@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: pytest>=7.0
9
+ Requires-Dist: clickhouse-connect>=0.6.0
10
+ Requires-Dist: allure-pytest>=2.13 ; extra == 'allure'
11
+ Requires-Python: >=3.9
12
+ Provides-Extra: allure
13
+ Description-Content-Type: text/markdown
14
+
15
+ # pytest-test-observer
16
+
17
+ An easy-to-use pytest plugin to take your test observability to the next level. Compatible with `allure-pytest`.
18
+
19
+ ## Install
20
+
21
+ The plugin isn't published to PyPI yet - install from source:
22
+
23
+ ```bash
24
+ uv add --editable git+https://github.com/shakhov-dmitrii/pytest-test-observer
25
+ # or for development inside a clone:
26
+ uv sync
27
+ ```
28
+
29
+ For Allure support, install the extra:
30
+
31
+ ```bash
32
+ uv add "pytest-test-observer[allure]"
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ 1. Start a local ClickHouse **and Grafana**:
38
+
39
+ ```bash
40
+ docker compose up -d
41
+ ```
42
+
43
+ This brings up:
44
+ - ClickHouse on <http://localhost:8123> (HTTP) and `:9000` (native)
45
+ - Grafana on <http://localhost:3000> (login: `admin` / `admin`) with the ClickHouse datasource auto-provisioned and a starter dashboard pre-loaded.
46
+
47
+ 2. Run your tests with the ClickHouse URL:
48
+
49
+ ```bash
50
+ pytest --ch-url=localhost:8123 --ch-table=pytest_results
51
+ ```
52
+
53
+ 3. Inspect the rows - either via SQL:
54
+
55
+ ```bash
56
+ docker exec -it pytest-test-observer-clickhouse clickhouse-client \
57
+ -q "SELECT nodeid, status, duration, ci_provider FROM default.pytest_results ORDER BY timestamp DESC LIMIT 20 FORMAT PrettyCompact"
58
+ ```
59
+
60
+ or in the dashboard: <http://localhost:3000/d/pytest-test-observer-overview>
61
+
62
+ If `--ch-url` is not provided, it does nothing and adds no overhead.
63
+
64
+ ## Configuration
65
+
66
+ Every connection setting can be supplied three ways, resolved in this order (highest priority first):
67
+
68
+ 1. **CLI flag** - `pytest --ch-url=...`
69
+ 2. **Environment variable** - `PYTEST_OBSERVER_CH_URL=...`
70
+ 3. **pyproject.toml** - `ch_url = "..."` under `[tool.pytest.ini_options]` (or any other pytest config file)
71
+ 4. Built-in default
72
+
73
+ | CLI flag | Env var | Ini key (`pyproject.toml`) | Default |
74
+ | ------------------ | -------------------------------- | -------------------------- | ---------------- |
75
+ | `--ch-url` | `PYTEST_OBSERVER_CH_URL` | `ch_url` | none |
76
+ | `--ch-user` | `PYTEST_OBSERVER_CH_USER` | `ch_user` | `default` |
77
+ | `--ch-password` | `PYTEST_OBSERVER_CH_PASSWORD` | `ch_password` | `""` |
78
+ | `--ch-db` | `PYTEST_OBSERVER_CH_DB` | `ch_db` | `default` |
79
+ | `--ch-table` | `PYTEST_OBSERVER_CH_TABLE` | `ch_table` | `pytest_results` |
80
+ | `--ch-send-from` | `PYTEST_OBSERVER_CH_SEND_FROM` | `ch_send_from` | `any` |
81
+ | `--ch-auto-migrate`| `PYTEST_OBSERVER_CH_AUTO_MIGRATE`| `ch_auto_migrate` | `true` |
82
+
83
+ ### `--ch-send-from`: where rows come from
84
+
85
+ - `any` (default) - send for both local and CI runs.
86
+ - `ci` - only send when a provider-specific CI sentinel is set (`GITHUB_ACTIONS`, `GITLAB_CI`, `CIRCLECI`, or `JENKINS_URL`).
87
+
88
+ ### `--ch-auto-migrate`: schema migrations across plugin versions
89
+
90
+ When a new plugin version adds columns, the plugin auto-applies `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for each missing column. Existing rows get the column's default value. Old data doesn't change.
91
+
92
+ If your team's policy forbids the plugin running DDL, set `ch_auto_migrate = false` in `pyproject.toml` (or the env / CLI equivalent). The plugin will then refuse to migrate and surface the SQL you'd need to run yourself.
93
+
94
+ ### Example: defaults into `pyproject.toml`
95
+
96
+ ```toml
97
+ [tool.pytest.ini_options]
98
+ ch_url = "clickhouse.internal:8123"
99
+ ch_db = "ci_metrics"
100
+ ch_table = "pytest_results"
101
+ ```
102
+
103
+ Then a plain `pytest` picks them up - no flags, no env vars.
104
+
105
+ ### Example: CI secret via env var
106
+
107
+ ```yaml
108
+ # GitHub Actions
109
+ - run: pytest
110
+ env:
111
+ PYTEST_OBSERVER_CH_URL: ${{ secrets.CLICKHOUSE_URL }}
112
+ PYTEST_OBSERVER_CH_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
113
+ ```
114
+
115
+ ### Other environment variables
116
+
117
+ | Variable | Effect |
118
+ | --------------------------- | ---------------------------------------------------------------- |
119
+ | `PYTEST_OBSERVER_RUN_ID` | Override the auto-generated `run_id` (UUID) for the session |
120
+ | `XDG_CACHE_HOME` | Base directory for the disk-buffer fallback |
121
+
122
+ ## ClickHouse schema
123
+
124
+ The table is auto-created on first flush:
125
+
126
+ ```sql
127
+ CREATE TABLE IF NOT EXISTS pytest_results (
128
+ run_id String,
129
+ timestamp DateTime64(3),
130
+ started_at UInt64,
131
+ finished_at UInt64,
132
+ nodeid String,
133
+ status LowCardinality(String),
134
+ when_phase LowCardinality(String),
135
+ duration Float64,
136
+ markers Array(String),
137
+ worker_id LowCardinality(String),
138
+ ci_provider LowCardinality(String),
139
+ ci_run_id String,
140
+ git_commit String,
141
+ git_branch String,
142
+ allure_id String,
143
+ allure_title String,
144
+ allure_severity LowCardinality(String),
145
+ allure_labels Map(String, Array(String)),
146
+ allure_links Array(Tuple(String, String, String))
147
+ ) ENGINE = MergeTree
148
+ ORDER BY (nodeid, timestamp)
149
+ PARTITION BY toYYYYMM(timestamp);
150
+ ```
151
+
152
+ ## Allure compatibility
153
+
154
+ When `allure-pytest` is installed and tests use the standard Allure decorators, the plugin captures:
155
+
156
+ - Labels (`@allure.feature`, `@allure.story`, `@allure.tag`, `@allure.severity`, `@allure.id`, `@allure.epic`, `@allure.suite`, ...) -> `allure_labels`
157
+ - Links (`@allure.link`, `@allure.issue`, `@allure.testcase`) -> `allure_links`
158
+ - `@allure.title(...)` -> `allure_title`
159
+ - `@allure.severity(...)` -> `allure_severity` (also stored in `allure_labels`)
160
+ - `@allure.id(...)` -> `allure_id`
161
+
162
+ ## CI / git context detection
163
+
164
+ Detected automatically.
165
+
166
+ | Provider | Env vars used |
167
+ | -------------- | -------------------------------------------------------------------------------------|
168
+ | GitHub Actions | `GITHUB_ACTIONS`, `GITHUB_RUN_ID`, `GITHUB_SHA`, `GITHUB_HEAD_REF`/`GITHUB_REF_NAME` |
169
+ | GitLab CI | `GITLAB_CI`, `CI_PIPELINE_ID`, `CI_COMMIT_SHA`, `CI_COMMIT_REF_NAME` |
170
+ | CircleCI | `CIRCLECI`, `CIRCLE_BUILD_NUM`, `CIRCLE_SHA1`, `CIRCLE_BRANCH` |
171
+ | Jenkins | `JENKINS_URL`, `BUILD_ID`/`BUILD_NUMBER`, `GIT_COMMIT`, `GIT_BRANCH` |
172
+ | Local | `git rev-parse HEAD` and `git rev-parse --abbrev-ref HEAD` (`ci_provider="local"`) |
173
+
174
+ ## Disk buffer fallback
175
+
176
+ When ClickHouse is unreachable, slow, or rejects the insert, the batch is written to:
177
+
178
+ ```
179
+ $XDG_CACHE_HOME/pytest-test-observer/<run_id>.jsonl # or ~/.cache/... if unset
180
+ ```
181
+
182
+ One JSON object per line, keys identical to the ClickHouse columns. The plugin emits a `warnings.warn` with the path. **The pytest exit code is unaffected.**
183
+
184
+ ### Replaying buffered files back into ClickHouse
185
+
186
+ Once ClickHouse is reachable again, run:
187
+
188
+ ```bash
189
+ python -m pytest_test_observer.replay --ch-url=localhost:8123
190
+ # or pick specific files:
191
+ python -m pytest_test_observer.replay /path/to/run-abc.jsonl
192
+ # or just see what would happen:
193
+ python -m pytest_test_observer.replay --dry-run
194
+ ```
195
+
196
+ All the `PYTEST_OBSERVER_CH_*` env vars from the configuration table are honoured by the replay tool too.
197
+
198
+ ## Example queries
199
+
200
+ ```sql
201
+ -- Top 10 flakiest tests in the last 30 days
202
+ SELECT
203
+ nodeid,
204
+ countIf(status IN ('failed','broken')) AS non_passes,
205
+ count() AS total,
206
+ non_passes / total AS flakiness
207
+ FROM pytest_results
208
+ WHERE timestamp > now() - INTERVAL 30 DAY
209
+ GROUP BY nodeid
210
+ HAVING total >= 10 AND non_passes > 0
211
+ ORDER BY flakiness DESC
212
+ LIMIT 10;
213
+
214
+ -- Slowest 10 tests (median duration)
215
+ SELECT nodeid, quantileExact(0.5)(duration) AS p50_seconds, count() AS runs
216
+ FROM pytest_results
217
+ WHERE timestamp > now() - INTERVAL 7 DAY AND status = 'passed'
218
+ GROUP BY nodeid
219
+ ORDER BY p50_seconds DESC
220
+ LIMIT 10;
221
+ ```
222
+
223
+ ## License
224
+
225
+ This project is licensed under the MIT License.
226
+ See the LICENSE file for details.
@@ -0,0 +1,18 @@
1
+ pytest_test_observer/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ pytest_test_observer/allure_compat.py,sha256=hNm7Eq1HsxL9Hj5YPG7qJaOvYa-3DPNsGKM5ENlUsnc,1986
3
+ pytest_test_observer/allure_compat_helpers.py,sha256=Vyrwy58qDguKZnhd14VkavE8GrerbH_V5mt4VACLZqI,1522
4
+ pytest_test_observer/buffer.py,sha256=fo6hwkUluESsjXWcLMzAY8cxk4rY6Go2SUtSkP9_nrU,1595
5
+ pytest_test_observer/constants.py,sha256=UlL6FZE50lCybOrIFzQ8whmaBJIvPvp5k7P3sKehPUQ,1405
6
+ pytest_test_observer/context.py,sha256=6-vNjz92_XAkt_oIZpcIVOoq1V-B_pXUry-RRSnPh9c,1628
7
+ pytest_test_observer/helper.py,sha256=tvsvqmwHvblrz3w6f7MGfKLdsOk5w7d5ydStIsq4_JA,590
8
+ pytest_test_observer/models.py,sha256=AulyJDaI8Eho8ATdsHlxr1C5IDo0dAtG9Z4o627FFa8,590
9
+ pytest_test_observer/options.py,sha256=qSWBoScjfnh9XqesYCJaxXcbbhEJRzowaQ1ZTL_rTCw,2324
10
+ pytest_test_observer/plugin.py,sha256=dSVoKBtrg1Ueq8t4ZNZZZMT35qEUdR0Kl4-CF5n9Pmc,6253
11
+ pytest_test_observer/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ pytest_test_observer/replay.py,sha256=Ph4tVth0WawvTt_rEpHL2PFYvUK42nzyt8VvR37xdlA,6515
13
+ pytest_test_observer/reporter.py,sha256=__Xtb98dgLZNPQWj3L3G6SLZn4qBE3I8ndjeKudBaU0,4186
14
+ pytest_test_observer/schema.py,sha256=SX3Qih_jDR9KfexnzGMTalxJU7oGNcclzvtSBc5cxB8,2995
15
+ pytest_test_observer-0.1.0.dist-info/WHEEL,sha256=mydTeHxOpFHo-DnYhAd_3ATePms-g4rrYvM7wJK8P-U,80
16
+ pytest_test_observer-0.1.0.dist-info/entry_points.txt,sha256=5V4PR2th3XvrXC_oObaQN01AHLvSG2PojUYgSxrU6Ck,56
17
+ pytest_test_observer-0.1.0.dist-info/METADATA,sha256=CWQe642ERAr34hvu1wfUpxXhTnJrwh104D3cF3dLw4M,8483
18
+ pytest_test_observer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.10.9
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [pytest11]
2
+ test_observer = pytest_test_observer.plugin
3
+