pytest-allure-host 0.1.1__py3-none-any.whl → 2.0.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_allure_host/__init__.py +8 -0
- pytest_allure_host/cli.py +207 -20
- pytest_allure_host/config.py +29 -0
- pytest_allure_host/plugin.py +3 -0
- pytest_allure_host/publisher.py +1368 -248
- pytest_allure_host/templates.py +158 -0
- pytest_allure_host/utils.py +29 -0
- {pytest_allure_host-0.1.1.dist-info → pytest_allure_host-2.0.0.dist-info}/METADATA +99 -3
- pytest_allure_host-2.0.0.dist-info/RECORD +13 -0
- pytest_allure_host-0.1.1.dist-info/RECORD +0 -12
- {pytest_allure_host-0.1.1.dist-info → pytest_allure_host-2.0.0.dist-info}/WHEEL +0 -0
- {pytest_allure_host-0.1.1.dist-info → pytest_allure_host-2.0.0.dist-info}/entry_points.txt +0 -0
- {pytest_allure_host-0.1.1.dist-info → pytest_allure_host-2.0.0.dist-info}/licenses/LICENSE +0 -0
pytest_allure_host/__init__.py
CHANGED
@@ -1,6 +1,14 @@
|
|
1
|
+
from importlib import metadata as _md
|
2
|
+
|
1
3
|
from .utils import PublishConfig, default_run_id # re-export key types
|
2
4
|
|
5
|
+
try: # runtime version (works inside installed env)
|
6
|
+
__version__ = _md.version("pytest-allure-host")
|
7
|
+
except Exception: # pragma: no cover
|
8
|
+
__version__ = "0.0.0+unknown"
|
9
|
+
|
3
10
|
__all__ = [
|
4
11
|
"PublishConfig",
|
5
12
|
"default_run_id",
|
13
|
+
"__version__",
|
6
14
|
]
|
pytest_allure_host/cli.py
CHANGED
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import argparse
|
4
4
|
import os
|
5
|
+
from pathlib import Path
|
5
6
|
|
7
|
+
from . import __version__
|
6
8
|
from .config import load_effective_config
|
7
9
|
from .publisher import plan_dry_run, preflight, publish
|
8
10
|
from .utils import PublishConfig, default_run_id
|
@@ -10,6 +12,11 @@ from .utils import PublishConfig, default_run_id
|
|
10
12
|
|
11
13
|
def parse_args() -> argparse.Namespace:
|
12
14
|
p = argparse.ArgumentParser("publish-allure")
|
15
|
+
p.add_argument(
|
16
|
+
"--version",
|
17
|
+
action="store_true",
|
18
|
+
help="Print version and exit",
|
19
|
+
)
|
13
20
|
p.add_argument("--config", help="Path to YAML config (optional)")
|
14
21
|
p.add_argument("--bucket")
|
15
22
|
p.add_argument("--prefix", default=None)
|
@@ -20,14 +27,34 @@ def parse_args() -> argparse.Namespace:
|
|
20
27
|
default=os.getenv("ALLURE_RUN_ID", default_run_id()),
|
21
28
|
)
|
22
29
|
p.add_argument("--cloudfront", default=os.getenv("ALLURE_CLOUDFRONT"))
|
23
|
-
p.add_argument(
|
24
|
-
|
30
|
+
p.add_argument(
|
31
|
+
"--results",
|
32
|
+
"--results-dir",
|
33
|
+
dest="results",
|
34
|
+
default=os.getenv("ALLURE_RESULTS_DIR", "allure-results"),
|
35
|
+
help="Path to allure-results directory (alias: --results-dir)",
|
36
|
+
)
|
37
|
+
p.add_argument(
|
38
|
+
"--report",
|
39
|
+
default=os.getenv("ALLURE_REPORT_DIR", "allure-report"),
|
40
|
+
help="Output directory for generated Allure static report",
|
41
|
+
)
|
25
42
|
p.add_argument("--ttl-days", type=int, default=None)
|
26
43
|
p.add_argument("--max-keep-runs", type=int, default=None)
|
44
|
+
p.add_argument(
|
45
|
+
"--sse",
|
46
|
+
default=os.getenv("ALLURE_S3_SSE"),
|
47
|
+
help="Server-side encryption algorithm (AES256 or aws:kms)",
|
48
|
+
)
|
49
|
+
p.add_argument(
|
50
|
+
"--sse-kms-key-id",
|
51
|
+
default=os.getenv("ALLURE_S3_SSE_KMS_KEY_ID"),
|
52
|
+
help="KMS Key ID / ARN when --sse=aws:kms",
|
53
|
+
)
|
27
54
|
p.add_argument(
|
28
55
|
"--s3-endpoint",
|
29
56
|
default=os.getenv("ALLURE_S3_ENDPOINT"),
|
30
|
-
help=("Custom S3 endpoint URL (e.g. http://localhost:4566
|
57
|
+
help=("Custom S3 endpoint URL (e.g. http://localhost:4566)"),
|
31
58
|
)
|
32
59
|
p.add_argument("--summary-json", default=None)
|
33
60
|
p.add_argument(
|
@@ -35,18 +62,82 @@ def parse_args() -> argparse.Namespace:
|
|
35
62
|
default=os.getenv("ALLURE_CONTEXT_URL"),
|
36
63
|
help="Optional hyperlink giving change context (e.g. Jira ticket)",
|
37
64
|
)
|
65
|
+
p.add_argument(
|
66
|
+
"--meta",
|
67
|
+
action="append",
|
68
|
+
default=[],
|
69
|
+
metavar="KEY=VAL",
|
70
|
+
help=(
|
71
|
+
"Attach arbitrary metadata (repeatable). Example: --meta "
|
72
|
+
"jira=PROJ-123 --meta env=staging. Adds dynamic columns to "
|
73
|
+
"runs index & manifest."
|
74
|
+
),
|
75
|
+
)
|
38
76
|
p.add_argument("--dry-run", action="store_true", help="Plan only")
|
39
77
|
p.add_argument(
|
40
78
|
"--check",
|
41
79
|
action="store_true",
|
42
80
|
help="Run preflight checks (AWS, allure, inputs)",
|
43
81
|
)
|
82
|
+
p.add_argument(
|
83
|
+
"--verbose-summary",
|
84
|
+
action="store_true",
|
85
|
+
help="Print extended summary (CDN prefixes, manifest path, metadata)",
|
86
|
+
)
|
87
|
+
p.add_argument(
|
88
|
+
"--allow-duplicate-prefix-project",
|
89
|
+
action="store_true",
|
90
|
+
help=(
|
91
|
+
"Bypass guard preventing prefix==project duplication. "
|
92
|
+
"Only use if you intentionally want that folder layout."
|
93
|
+
),
|
94
|
+
)
|
95
|
+
p.add_argument(
|
96
|
+
"--upload-workers",
|
97
|
+
type=int,
|
98
|
+
default=None,
|
99
|
+
help="Parallel upload worker threads (auto if unset)",
|
100
|
+
)
|
101
|
+
p.add_argument(
|
102
|
+
"--copy-workers",
|
103
|
+
type=int,
|
104
|
+
default=None,
|
105
|
+
help="Parallel copy worker threads for latest promotion",
|
106
|
+
)
|
107
|
+
p.add_argument(
|
108
|
+
"--archive-run",
|
109
|
+
action="store_true",
|
110
|
+
help="Also produce a compressed archive of the run (tar.gz)",
|
111
|
+
)
|
112
|
+
p.add_argument(
|
113
|
+
"--archive-format",
|
114
|
+
choices=["tar.gz", "zip"],
|
115
|
+
default="tar.gz",
|
116
|
+
help="Archive format when --archive-run is set",
|
117
|
+
)
|
44
118
|
return p.parse_args()
|
45
119
|
|
46
120
|
|
47
|
-
def
|
48
|
-
|
49
|
-
|
121
|
+
def _parse_metadata(pairs: list[str]) -> dict | None:
|
122
|
+
if not pairs:
|
123
|
+
return None
|
124
|
+
meta: dict[str, str] = {}
|
125
|
+
for raw in pairs:
|
126
|
+
if "=" not in raw:
|
127
|
+
continue
|
128
|
+
k, v = raw.split("=", 1)
|
129
|
+
k = k.strip()
|
130
|
+
v = v.strip()
|
131
|
+
if not k:
|
132
|
+
continue
|
133
|
+
safe_k = k.lower().replace("-", "_")
|
134
|
+
if safe_k and v:
|
135
|
+
meta[safe_k] = v
|
136
|
+
return meta or None
|
137
|
+
|
138
|
+
|
139
|
+
def _build_cli_overrides(args: argparse.Namespace) -> dict:
|
140
|
+
return {
|
50
141
|
"bucket": args.bucket,
|
51
142
|
"prefix": args.prefix,
|
52
143
|
"project": args.project,
|
@@ -57,12 +148,22 @@ def main() -> int:
|
|
57
148
|
"max_keep_runs": args.max_keep_runs,
|
58
149
|
"s3_endpoint": args.s3_endpoint,
|
59
150
|
"context_url": args.context_url,
|
151
|
+
"sse": args.sse,
|
152
|
+
"sse_kms_key_id": args.sse_kms_key_id,
|
60
153
|
}
|
61
|
-
|
154
|
+
|
155
|
+
|
156
|
+
def _effective_config(args: argparse.Namespace) -> tuple[dict, PublishConfig]:
|
157
|
+
overrides = _build_cli_overrides(args)
|
158
|
+
effective = load_effective_config(overrides, args.config)
|
159
|
+
cfg_source = effective.get("_config_file")
|
160
|
+
if cfg_source:
|
161
|
+
print(f"[config] loaded settings from {cfg_source}")
|
62
162
|
missing = [k for k in ("bucket", "project") if not effective.get(k)]
|
63
163
|
if missing:
|
164
|
+
missing_list = ", ".join(missing)
|
64
165
|
raise SystemExit(
|
65
|
-
f"Missing required config values: {
|
166
|
+
f"Missing required config values: {missing_list}. Provide via CLI, env, or YAML."
|
66
167
|
)
|
67
168
|
cfg = PublishConfig(
|
68
169
|
bucket=effective["bucket"],
|
@@ -75,28 +176,114 @@ def main() -> int:
|
|
75
176
|
max_keep_runs=effective.get("max_keep_runs"),
|
76
177
|
s3_endpoint=effective.get("s3_endpoint"),
|
77
178
|
context_url=effective.get("context_url"),
|
179
|
+
sse=effective.get("sse"),
|
180
|
+
sse_kms_key_id=effective.get("sse_kms_key_id"),
|
181
|
+
metadata=_parse_metadata(args.meta),
|
182
|
+
upload_workers=args.upload_workers,
|
183
|
+
copy_workers=args.copy_workers,
|
184
|
+
archive_run=args.archive_run,
|
185
|
+
archive_format=args.archive_format if args.archive_run else None,
|
186
|
+
)
|
187
|
+
# Guard against accidental duplication like prefix==project producing
|
188
|
+
# 'reports/reports/<branch>/...' paths. This is usually unintentional
|
189
|
+
# and makes report URLs longer / redundant. Fail fast so users can
|
190
|
+
# correct config explicitly (they can still deliberately choose this
|
191
|
+
# by changing either value slightly, e.g. prefix='reports',
|
192
|
+
# project='team-reports').
|
193
|
+
if cfg.prefix == cfg.project and not getattr(args, "allow_duplicate_prefix_project", False):
|
194
|
+
parts = [
|
195
|
+
"Invalid config: prefix and project are identical (",
|
196
|
+
f"'{cfg.project}'). ",
|
197
|
+
"This yields duplicated S3 paths (",
|
198
|
+
f"{cfg.prefix}/{cfg.project}/<branch>/...). ",
|
199
|
+
"Set distinct values (e.g. prefix='reports', project='payments').",
|
200
|
+
]
|
201
|
+
raise SystemExit("".join(parts))
|
202
|
+
return effective, cfg
|
203
|
+
|
204
|
+
|
205
|
+
def _write_json(path: str, payload: dict) -> None:
|
206
|
+
import json
|
207
|
+
|
208
|
+
with open(path, "w", encoding="utf-8") as f:
|
209
|
+
json.dump(payload, f, indent=2)
|
210
|
+
|
211
|
+
|
212
|
+
def _print_publish_summary(
|
213
|
+
cfg: PublishConfig,
|
214
|
+
out: dict,
|
215
|
+
verbose: bool = False,
|
216
|
+
) -> None:
|
217
|
+
print("Publish complete")
|
218
|
+
if out.get("run_url"):
|
219
|
+
print(f"Run URL: {out['run_url']}")
|
220
|
+
if out.get("latest_url"):
|
221
|
+
print(f"Latest URL: {out['latest_url']}")
|
222
|
+
# Main aggregated runs index (HTML) at branch root if CDN configured
|
223
|
+
if cfg.cloudfront_domain:
|
224
|
+
branch_root = f"{cfg.prefix}/{cfg.project}/{cfg.branch}"
|
225
|
+
cdn_root = cfg.cloudfront_domain.rstrip("/")
|
226
|
+
runs_index_url = f"{cdn_root}/{branch_root}/runs/index.html"
|
227
|
+
print(f"Runs Index URL: {runs_index_url}")
|
228
|
+
run_prefix = out.get("run_prefix") or cfg.s3_run_prefix
|
229
|
+
latest_prefix = out.get("latest_prefix") or cfg.s3_latest_prefix
|
230
|
+
print(f"S3 run prefix: s3://{cfg.bucket}/{run_prefix}")
|
231
|
+
print(f"S3 latest prefix: s3://{cfg.bucket}/{latest_prefix}")
|
232
|
+
print(
|
233
|
+
"Report files: "
|
234
|
+
f"{out.get('report_files', '?')} Size: "
|
235
|
+
f"{out.get('report_size_bytes', '?')} bytes"
|
78
236
|
)
|
237
|
+
if verbose and cfg.cloudfront_domain:
|
238
|
+
# Duplicate earlier lines but clarify this is the CDN-root mapping
|
239
|
+
print("CDN run prefix (index root):", cfg.url_run())
|
240
|
+
print("CDN latest prefix (index root):", cfg.url_latest())
|
241
|
+
if verbose:
|
242
|
+
# Manifest stored at branch root under runs/index.json
|
243
|
+
branch_root = f"{cfg.prefix}/{cfg.project}/{cfg.branch}"
|
244
|
+
manifest_key = f"{branch_root}/runs/index.json"
|
245
|
+
print("Manifest object:", f"s3://{cfg.bucket}/{manifest_key}")
|
246
|
+
if cfg.metadata:
|
247
|
+
print("Metadata keys:", ", ".join(sorted(cfg.metadata.keys())))
|
248
|
+
if cfg.sse:
|
249
|
+
print("Encryption:", cfg.sse, cfg.sse_kms_key_id or "")
|
250
|
+
|
251
|
+
|
252
|
+
def main() -> int: # noqa: C901 (reduced but keep guard just in case)
|
253
|
+
args = parse_args()
|
254
|
+
if args.version:
|
255
|
+
print(__version__)
|
256
|
+
return 0
|
257
|
+
effective, cfg = _effective_config(args)
|
258
|
+
# Construct explicit Paths honoring custom results/report dirs
|
259
|
+
paths = None
|
260
|
+
try:
|
261
|
+
mod = __import__("pytest_allure_host.publisher", fromlist=["Paths"])
|
262
|
+
paths = mod.publisher.Paths(
|
263
|
+
results=Path(args.results),
|
264
|
+
report=Path(args.report),
|
265
|
+
)
|
266
|
+
except Exception: # pragma: no cover - defensive fallback
|
267
|
+
from .publisher import Paths # type: ignore
|
268
|
+
|
269
|
+
paths = Paths(results=Path(args.results), report=Path(args.report))
|
270
|
+
|
79
271
|
if args.check:
|
80
|
-
checks = preflight(cfg)
|
272
|
+
checks = preflight(cfg, paths=paths)
|
81
273
|
print(checks)
|
82
274
|
if not all(checks.values()):
|
83
275
|
return 2
|
84
276
|
if args.dry_run:
|
85
|
-
plan = plan_dry_run(cfg)
|
277
|
+
plan = plan_dry_run(cfg, paths=paths)
|
86
278
|
print(plan)
|
87
279
|
if args.summary_json:
|
88
|
-
|
89
|
-
|
90
|
-
with open(args.summary_json, "w", encoding="utf-8") as f:
|
91
|
-
json.dump(plan, f, indent=2)
|
280
|
+
_write_json(args.summary_json, plan)
|
92
281
|
return 0
|
93
|
-
out = publish(cfg)
|
94
|
-
print(out)
|
282
|
+
out = publish(cfg, paths=paths)
|
283
|
+
print(out) # raw dict for backward compatibility
|
284
|
+
_print_publish_summary(cfg, out, verbose=args.verbose_summary)
|
95
285
|
if args.summary_json:
|
96
|
-
|
97
|
-
|
98
|
-
with open(args.summary_json, "w", encoding="utf-8") as f:
|
99
|
-
json.dump(out, f, indent=2)
|
286
|
+
_write_json(args.summary_json, out)
|
100
287
|
return 0
|
101
288
|
|
102
289
|
|
pytest_allure_host/config.py
CHANGED
@@ -38,10 +38,14 @@ from typing import Any
|
|
38
38
|
import yaml
|
39
39
|
|
40
40
|
CONFIG_FILENAMES = [
|
41
|
+
# YAML (legacy / original)
|
41
42
|
"allure-host.yml",
|
42
43
|
"allure-host.yaml",
|
43
44
|
".allure-host.yml",
|
44
45
|
".allure-host.yaml",
|
46
|
+
# TOML (new preferred simple format)
|
47
|
+
"allurehost.toml",
|
48
|
+
".allurehost.toml",
|
45
49
|
# Additional generic app config names people often use:
|
46
50
|
"application.yml",
|
47
51
|
"application.yaml",
|
@@ -81,9 +85,32 @@ def _read_yaml(path: Path) -> dict[str, Any]:
|
|
81
85
|
return {}
|
82
86
|
|
83
87
|
|
88
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
89
|
+
try:
|
90
|
+
import sys
|
91
|
+
|
92
|
+
if sys.version_info >= (3, 11): # stdlib tomllib
|
93
|
+
import tomllib # type: ignore
|
94
|
+
else: # fallback to optional dependency
|
95
|
+
import tomli as tomllib # type: ignore
|
96
|
+
except Exception: # pragma: no cover - toml not available
|
97
|
+
return {}
|
98
|
+
try:
|
99
|
+
with path.open("rb") as f:
|
100
|
+
data = tomllib.load(f)
|
101
|
+
return data if isinstance(data, dict) else {}
|
102
|
+
except Exception: # pragma: no cover - malformed
|
103
|
+
return {}
|
104
|
+
|
105
|
+
|
84
106
|
def discover_yaml_config(explicit: str | None = None) -> LoadedConfig:
|
85
107
|
if explicit:
|
86
108
|
p = Path(explicit)
|
109
|
+
if p.suffix.lower() == ".toml":
|
110
|
+
return LoadedConfig(
|
111
|
+
source_file=p if p.exists() else None,
|
112
|
+
data=_read_toml(p),
|
113
|
+
)
|
87
114
|
return LoadedConfig(
|
88
115
|
source_file=p if p.exists() else None,
|
89
116
|
data=_read_yaml(p),
|
@@ -91,6 +118,8 @@ def discover_yaml_config(explicit: str | None = None) -> LoadedConfig:
|
|
91
118
|
for name in CONFIG_FILENAMES:
|
92
119
|
p = Path(name)
|
93
120
|
if p.exists():
|
121
|
+
if p.suffix.lower() == ".toml":
|
122
|
+
return LoadedConfig(source_file=p, data=_read_toml(p))
|
94
123
|
return LoadedConfig(source_file=p, data=_read_yaml(p))
|
95
124
|
return LoadedConfig(source_file=None, data={})
|
96
125
|
|
pytest_allure_host/plugin.py
CHANGED
@@ -79,6 +79,9 @@ def pytest_terminal_summary( # noqa: C901 - central orchestration, readable
|
|
79
79
|
"context_url": context_url,
|
80
80
|
}
|
81
81
|
effective = load_effective_config(cli_overrides, config.getoption("allure_config"))
|
82
|
+
cfg_source = effective.get("_config_file")
|
83
|
+
if cfg_source:
|
84
|
+
terminalreporter.write_line(f"[allure-host] config file: {cfg_source}")
|
82
85
|
# Minimal required
|
83
86
|
if not effective.get("bucket") or not effective.get("project"):
|
84
87
|
return
|