pytest-allure-host 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ from .utils import PublishConfig, default_run_id # re-export key types
2
+
3
+ __all__ = [
4
+ "PublishConfig",
5
+ "default_run_id",
6
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ raise SystemExit(main())
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+
6
+ from .config import load_effective_config
7
+ from .publisher import plan_dry_run, preflight, publish
8
+ from .utils import PublishConfig, default_run_id
9
+
10
+
11
+ def parse_args() -> argparse.Namespace:
12
+ p = argparse.ArgumentParser("publish-allure")
13
+ p.add_argument("--config", help="Path to YAML config (optional)")
14
+ p.add_argument("--bucket")
15
+ p.add_argument("--prefix", default=None)
16
+ p.add_argument("--project")
17
+ p.add_argument("--branch", default=os.getenv("GIT_BRANCH", "main"))
18
+ p.add_argument(
19
+ "--run-id",
20
+ default=os.getenv("ALLURE_RUN_ID", default_run_id()),
21
+ )
22
+ p.add_argument("--cloudfront", default=os.getenv("ALLURE_CLOUDFRONT"))
23
+ p.add_argument("--results", default="allure-results")
24
+ p.add_argument("--report", default="allure-report")
25
+ p.add_argument("--ttl-days", type=int, default=None)
26
+ p.add_argument("--max-keep-runs", type=int, default=None)
27
+ p.add_argument(
28
+ "--s3-endpoint",
29
+ default=os.getenv("ALLURE_S3_ENDPOINT"),
30
+ help=("Custom S3 endpoint URL (e.g. http://localhost:4566 for LocalStack)"),
31
+ )
32
+ p.add_argument("--summary-json", default=None)
33
+ p.add_argument(
34
+ "--context-url",
35
+ default=os.getenv("ALLURE_CONTEXT_URL"),
36
+ help="Optional hyperlink giving change context (e.g. Jira ticket)",
37
+ )
38
+ p.add_argument("--dry-run", action="store_true", help="Plan only")
39
+ p.add_argument(
40
+ "--check",
41
+ action="store_true",
42
+ help="Run preflight checks (AWS, allure, inputs)",
43
+ )
44
+ return p.parse_args()
45
+
46
+
47
+ def main() -> int:
48
+ args = parse_args()
49
+ cli_overrides = {
50
+ "bucket": args.bucket,
51
+ "prefix": args.prefix,
52
+ "project": args.project,
53
+ "branch": args.branch,
54
+ "cloudfront": args.cloudfront,
55
+ "run_id": args.run_id,
56
+ "ttl_days": args.ttl_days,
57
+ "max_keep_runs": args.max_keep_runs,
58
+ "s3_endpoint": args.s3_endpoint,
59
+ "context_url": args.context_url,
60
+ }
61
+ effective = load_effective_config(cli_overrides, args.config)
62
+ missing = [k for k in ("bucket", "project") if not effective.get(k)]
63
+ if missing:
64
+ raise SystemExit(
65
+ f"Missing required config values: {', '.join(missing)}. Provide via CLI, env, or YAML." # noqa: E501
66
+ )
67
+ cfg = PublishConfig(
68
+ bucket=effective["bucket"],
69
+ prefix=effective.get("prefix") or "reports",
70
+ project=effective["project"],
71
+ branch=effective.get("branch") or args.branch,
72
+ run_id=effective.get("run_id") or args.run_id,
73
+ cloudfront_domain=effective.get("cloudfront"),
74
+ ttl_days=effective.get("ttl_days"),
75
+ max_keep_runs=effective.get("max_keep_runs"),
76
+ s3_endpoint=effective.get("s3_endpoint"),
77
+ context_url=effective.get("context_url"),
78
+ )
79
+ if args.check:
80
+ checks = preflight(cfg)
81
+ print(checks)
82
+ if not all(checks.values()):
83
+ return 2
84
+ if args.dry_run:
85
+ plan = plan_dry_run(cfg)
86
+ print(plan)
87
+ if args.summary_json:
88
+ import json
89
+
90
+ with open(args.summary_json, "w", encoding="utf-8") as f:
91
+ json.dump(plan, f, indent=2)
92
+ return 0
93
+ out = publish(cfg)
94
+ print(out)
95
+ if args.summary_json:
96
+ import json
97
+
98
+ with open(args.summary_json, "w", encoding="utf-8") as f:
99
+ json.dump(out, f, indent=2)
100
+ return 0
101
+
102
+
103
+ if __name__ == "__main__": # pragma: no cover
104
+ raise SystemExit(main())
@@ -0,0 +1,141 @@
1
+ """Configuration loading utilities (YAML + env) for Allure hosting publisher.
2
+
3
+ Precedence (highest first):
4
+ 1. Explicit CLI / pytest flag values
5
+ 2. Environment variables (ALLURE_BUCKET, ALLURE_PREFIX, ...)
6
+ 3. YAML file values (provided via --config or auto-discovered)
7
+ 4. Built-in defaults
8
+
9
+ Auto-discovery order if --config not supplied:
10
+ - ./allure-host.yml
11
+ - ./allure-host.yaml
12
+ - ./.allure-host.yml
13
+ - ./.allure-host.yaml
14
+
15
+ YAML schema (example):
16
+
17
+ bucket: my-reports-bucket
18
+ prefix: reports
19
+ project: payments
20
+ branch: main
21
+ ttl_days: 30
22
+ max_keep_runs: 20
23
+ cloudfront: https://reports.example.com
24
+ retention:
25
+ default_ttl_days: 30 # alias of ttl_days
26
+ max_keep_runs: 20 # duplicate path accepted
27
+
28
+ Unknown keys are ignored (forward compatible).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ import yaml
39
+
40
+ CONFIG_FILENAMES = [
41
+ "allure-host.yml",
42
+ "allure-host.yaml",
43
+ ".allure-host.yml",
44
+ ".allure-host.yaml",
45
+ # Additional generic app config names people often use:
46
+ "application.yml",
47
+ "application.yaml",
48
+ ]
49
+
50
+ ENV_MAP = {
51
+ "bucket": "ALLURE_BUCKET",
52
+ "prefix": "ALLURE_PREFIX",
53
+ "project": "ALLURE_PROJECT",
54
+ "branch": "ALLURE_BRANCH",
55
+ "cloudfront": "ALLURE_CLOUDFRONT",
56
+ "run_id": "ALLURE_RUN_ID",
57
+ "ttl_days": "ALLURE_TTL_DAYS",
58
+ "max_keep_runs": "ALLURE_MAX_KEEP_RUNS",
59
+ "s3_endpoint": "ALLURE_S3_ENDPOINT",
60
+ "context_url": "ALLURE_CONTEXT_URL",
61
+ }
62
+
63
+
64
+ @dataclass
65
+ class LoadedConfig:
66
+ source_file: Path | None
67
+ data: dict[str, Any]
68
+
69
+
70
+ def _read_yaml(path: Path) -> dict[str, Any]:
71
+ try:
72
+ with path.open("r", encoding="utf-8") as f:
73
+ content = yaml.safe_load(f) or {}
74
+ if not isinstance(content, dict):
75
+ return {}
76
+ return content
77
+ except FileNotFoundError:
78
+ return {}
79
+ except Exception:
80
+ # best effort - ignore malformed file
81
+ return {}
82
+
83
+
84
+ def discover_yaml_config(explicit: str | None = None) -> LoadedConfig:
85
+ if explicit:
86
+ p = Path(explicit)
87
+ return LoadedConfig(
88
+ source_file=p if p.exists() else None,
89
+ data=_read_yaml(p),
90
+ )
91
+ for name in CONFIG_FILENAMES:
92
+ p = Path(name)
93
+ if p.exists():
94
+ return LoadedConfig(source_file=p, data=_read_yaml(p))
95
+ return LoadedConfig(source_file=None, data={})
96
+
97
+
98
+ def merge_config(
99
+ yaml_cfg: dict[str, Any],
100
+ env: dict[str, str],
101
+ cli_overrides: dict[str, Any],
102
+ ) -> dict[str, Any]:
103
+ merged: dict[str, Any] = {}
104
+
105
+ # start with YAML
106
+ merged.update(yaml_cfg)
107
+
108
+ # retention nested block normalization
109
+ retention = yaml_cfg.get("retention")
110
+ if isinstance(retention, dict):
111
+ merged.setdefault("ttl_days", retention.get("default_ttl_days"))
112
+ merged.setdefault("max_keep_runs", retention.get("max_keep_runs"))
113
+
114
+ # env overrides
115
+ for key, env_var in ENV_MAP.items():
116
+ if env_var in env and env[env_var]:
117
+ merged[key] = env[env_var]
118
+
119
+ # explicit CLI overrides (ignore None only)
120
+ for k, v in cli_overrides.items():
121
+ if v is not None:
122
+ merged[k] = v
123
+
124
+ # type adjust
125
+ for int_field in ("ttl_days", "max_keep_runs"):
126
+ if int_field in merged and merged[int_field] not in (None, ""):
127
+ try:
128
+ merged[int_field] = int(merged[int_field])
129
+ except ValueError:
130
+ merged[int_field] = None
131
+
132
+ return merged
133
+
134
+
135
+ def load_effective_config(
136
+ cli_args: dict[str, Any], explicit_config: str | None = None
137
+ ) -> dict[str, Any]:
138
+ loaded = discover_yaml_config(explicit_config)
139
+ data = merge_config(loaded.data, os.environ, cli_args)
140
+ data["_config_file"] = str(loaded.source_file) if loaded.source_file else None
141
+ return data
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+
6
+ import pytest
7
+ from botocore.exceptions import BotoCoreError, ClientError
8
+
9
+ from .config import load_effective_config
10
+ from .publisher import plan_dry_run, preflight, publish
11
+ from .utils import PublishConfig, default_run_id
12
+
13
+
14
+ def pytest_addoption(parser: pytest.Parser) -> None:
15
+ group = parser.getgroup("allure-host")
16
+ group.addoption("--allure-bucket", action="store", help="S3 bucket")
17
+ group.addoption("--allure-prefix", action="store", default="reports")
18
+ group.addoption("--allure-project", action="store", help="Project name")
19
+ group.addoption(
20
+ "--allure-branch",
21
+ action="store",
22
+ default=os.getenv("GIT_BRANCH", "main"),
23
+ )
24
+ group.addoption(
25
+ "--allure-cloudfront",
26
+ action="store",
27
+ default=os.getenv("ALLURE_CLOUDFRONT"),
28
+ )
29
+ group.addoption(
30
+ "--allure-run-id",
31
+ action="store",
32
+ default=os.getenv("ALLURE_RUN_ID", default_run_id()),
33
+ )
34
+ group.addoption("--allure-ttl-days", action="store", type=int, default=None)
35
+ group.addoption("--allure-max-keep-runs", action="store", type=int, default=None)
36
+ group.addoption("--allure-summary-json", action="store", default=None)
37
+ group.addoption("--allure-dry-run", action="store_true")
38
+ group.addoption("--allure-check", action="store_true")
39
+ group.addoption(
40
+ "--allure-context-url",
41
+ action="store",
42
+ default=os.getenv("ALLURE_CONTEXT_URL"),
43
+ help="Optional context hyperlink (e.g. Jira ticket)",
44
+ )
45
+ group.addoption(
46
+ "--allure-config",
47
+ action="store",
48
+ default=None,
49
+ help="YAML config file path (optional)",
50
+ )
51
+
52
+
53
+ def pytest_terminal_summary( # noqa: C901 - central orchestration, readable
54
+ terminalreporter: pytest.TerminalReporter, exitstatus: int
55
+ ) -> None:
56
+ config = terminalreporter.config
57
+ bucket = config.getoption("allure_bucket")
58
+ project = config.getoption("allure_project")
59
+ if not bucket or not project:
60
+ return
61
+ prefix = config.getoption("allure_prefix")
62
+ branch = config.getoption("allure_branch")
63
+ cloudfront = config.getoption("allure_cloudfront")
64
+ run_id = config.getoption("allure_run_id")
65
+ ttl_days = config.getoption("allure_ttl_days")
66
+ max_keep_runs = config.getoption("allure_max_keep_runs")
67
+ summary_json = config.getoption("allure_summary_json")
68
+ context_url = config.getoption("allure_context_url")
69
+
70
+ cli_overrides = {
71
+ "bucket": bucket,
72
+ "prefix": prefix,
73
+ "project": project,
74
+ "branch": branch,
75
+ "cloudfront": cloudfront,
76
+ "run_id": run_id,
77
+ "ttl_days": ttl_days,
78
+ "max_keep_runs": max_keep_runs,
79
+ "context_url": context_url,
80
+ }
81
+ effective = load_effective_config(cli_overrides, config.getoption("allure_config"))
82
+ # Minimal required
83
+ if not effective.get("bucket") or not effective.get("project"):
84
+ return
85
+ pub_cfg = PublishConfig(
86
+ bucket=effective["bucket"],
87
+ prefix=effective.get("prefix") or "reports",
88
+ project=effective["project"],
89
+ branch=effective.get("branch") or branch,
90
+ run_id=effective.get("run_id") or run_id,
91
+ cloudfront_domain=effective.get("cloudfront"),
92
+ ttl_days=effective.get("ttl_days"),
93
+ max_keep_runs=effective.get("max_keep_runs"),
94
+ context_url=effective.get("context_url"),
95
+ )
96
+
97
+ try:
98
+ if config.getoption("allure_check"):
99
+ checks = preflight(pub_cfg)
100
+ terminalreporter.write_line(f"Allure preflight: {checks}")
101
+ if summary_json:
102
+ with open(summary_json, "w", encoding="utf-8") as f:
103
+ json.dump(checks, f, indent=2)
104
+ if not all(checks.values()):
105
+ return
106
+ if config.getoption("allure_dry_run"):
107
+ plan = plan_dry_run(pub_cfg)
108
+ terminalreporter.write_line(f"Allure plan: {plan}")
109
+ if summary_json:
110
+ with open(summary_json, "w", encoding="utf-8") as f:
111
+ json.dump(plan, f, indent=2)
112
+ return
113
+ out = publish(pub_cfg)
114
+ terminalreporter.write_line(
115
+ f"Allure report published to:\n"
116
+ f"- run: {out.get('run_url') or pub_cfg.s3_run_prefix}\n"
117
+ f"- latest: {out.get('latest_url') or pub_cfg.s3_latest_prefix}"
118
+ )
119
+ if summary_json:
120
+ with open(summary_json, "w", encoding="utf-8") as f:
121
+ json.dump(out, f, indent=2)
122
+ except (ClientError, BotoCoreError, OSError, ValueError) as e:
123
+ # Known error categories: AWS client, IO, JSON/value issues
124
+ terminalreporter.write_line(f"Allure publish failed: {e}")
125
+ except Exception as e: # noqa: BLE001
126
+ # Fallback catch-all to prevent pytest terminal crash; log generic
127
+ terminalreporter.write_line(f"Allure publish failed with unexpected error: {e}")