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.
- pytest_test_observer/__init__.py +1 -0
- pytest_test_observer/allure_compat.py +66 -0
- pytest_test_observer/allure_compat_helpers.py +52 -0
- pytest_test_observer/buffer.py +51 -0
- pytest_test_observer/constants.py +57 -0
- pytest_test_observer/context.py +63 -0
- pytest_test_observer/helper.py +25 -0
- pytest_test_observer/models.py +30 -0
- pytest_test_observer/options.py +68 -0
- pytest_test_observer/plugin.py +179 -0
- pytest_test_observer/py.typed +0 -0
- pytest_test_observer/replay.py +201 -0
- pytest_test_observer/reporter.py +134 -0
- pytest_test_observer/schema.py +90 -0
- pytest_test_observer-0.1.0.dist-info/METADATA +226 -0
- pytest_test_observer-0.1.0.dist-info/RECORD +18 -0
- pytest_test_observer-0.1.0.dist-info/WHEEL +4 -0
- pytest_test_observer-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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,,
|