ragops 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ragops/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """RAGOps public API."""
2
+
3
+ from ragops.engine import compare, evaluate
4
+ from ragops.loader import load_responses, load_scenario, responses_from_data, scenario_from_dict
5
+
6
+ __version__ = "1.6.0"
7
+
8
+ __all__ = [
9
+ "compare",
10
+ "evaluate",
11
+ "load_responses",
12
+ "load_scenario",
13
+ "responses_from_data",
14
+ "scenario_from_dict",
15
+ "__version__",
16
+ ]
@@ -0,0 +1,5 @@
1
+ """Optional dependency-free integration adapters."""
2
+
3
+ from .http import AdapterError, post_json
4
+
5
+ __all__ = ["AdapterError", "post_json"]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.request
6
+ from typing import Any
7
+
8
+
9
+ class AdapterError(RuntimeError):
10
+ """Raised when a remote application adapter cannot return valid JSON."""
11
+
12
+
13
+ def post_json(
14
+ url: str,
15
+ payload: dict[str, Any],
16
+ *,
17
+ headers: dict[str, str] | None = None,
18
+ timeout: float = 30.0,
19
+ ) -> dict[str, Any]:
20
+ request_headers = {"content-type": "application/json", **(headers or {})}
21
+ request = urllib.request.Request(
22
+ url,
23
+ data=json.dumps(payload).encode("utf-8"),
24
+ headers=request_headers,
25
+ method="POST",
26
+ )
27
+ try:
28
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
29
+ data = json.loads(response.read().decode("utf-8"))
30
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
31
+ raise AdapterError(f"HTTP adapter failed for {url}: {exc}") from exc
32
+ if not isinstance(data, dict):
33
+ raise AdapterError(f"HTTP adapter expected a JSON object from {url}")
34
+ return data
ragops/benchmarks.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from typing import Any
5
+
6
+ from ragops.models import Scenario
7
+
8
+
9
+ def scenario_summary(scenario: Scenario) -> dict[str, Any]:
10
+ """Return deterministic benchmark coverage metadata for authoring and review."""
11
+ categories = Counter(case.category for case in scenario.cases)
12
+ severities = Counter(case.severity for case in scenario.cases)
13
+ languages = Counter(case.language for case in scenario.cases)
14
+ attacks = Counter(
15
+ case.attack_category for case in scenario.cases if case.attack_category is not None
16
+ )
17
+ return {
18
+ "schema_version": scenario.schema_version,
19
+ "scenario_id": scenario.id,
20
+ "case_count": len(scenario.cases),
21
+ "categories": dict(sorted(categories.items())),
22
+ "severities": dict(sorted(severities.items())),
23
+ "languages": dict(sorted(languages.items())),
24
+ "attack_categories": dict(sorted(attacks.items())),
25
+ }
ragops/cli.py ADDED
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from ragops.benchmarks import scenario_summary
8
+ from ragops.config import load_regression_policy
9
+ from ragops.control_plane import ControlPlane
10
+ from ragops.demo import write_demo
11
+ from ragops.engine import compare, evaluate
12
+ from ragops.loader import ContractError, load_responses, load_scenario
13
+ from ragops.plugins import (
14
+ CaseEvaluator,
15
+ CitationCorrectnessEvaluator,
16
+ ClaimSupportEvaluator,
17
+ RetrievalRecallEvaluator,
18
+ )
19
+ from ragops.reporters import comparison_html, comparison_markdown, evaluation_markdown
20
+ from ragops.store import ExperimentStore
21
+ from ragops.traces import load_trace_jsonl
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(prog="ragops")
26
+ commands = parser.add_subparsers(dest="command", required=True)
27
+ demo_parser = commands.add_parser("demo", help="Generate a credential-free release-gate demo")
28
+ demo_parser.add_argument("--output", default="ragops-demo")
29
+ demo_parser.add_argument(
30
+ "--force",
31
+ action="store_true",
32
+ help="Replace regular demo files in an existing non-symlink directory",
33
+ )
34
+ evaluate_parser = commands.add_parser("evaluate", help="Evaluate recorded responses")
35
+ evaluate_parser.add_argument("--scenario", required=True)
36
+ evaluate_input = evaluate_parser.add_mutually_exclusive_group(required=True)
37
+ evaluate_input.add_argument("--responses")
38
+ evaluate_input.add_argument("--traces", help="Portable JSONL trace input")
39
+ evaluate_parser.add_argument("--output")
40
+ evaluate_parser.add_argument("--format", choices=("json", "markdown"), default="json")
41
+ evaluate_parser.add_argument("--store")
42
+ evaluate_parser.add_argument("--label", default="")
43
+ evaluate_parser.add_argument(
44
+ "--evaluator",
45
+ action="append",
46
+ choices=("retrieval_recall", "citation_correctness", "claim_support"),
47
+ default=[],
48
+ )
49
+ compare_parser = commands.add_parser("compare", help="Compare candidate responses to baseline")
50
+ compare_parser.add_argument("--scenario", required=True)
51
+ baseline_input = compare_parser.add_mutually_exclusive_group(required=True)
52
+ baseline_input.add_argument("--baseline")
53
+ baseline_input.add_argument("--baseline-traces")
54
+ candidate_input = compare_parser.add_mutually_exclusive_group(required=True)
55
+ candidate_input.add_argument("--candidate")
56
+ candidate_input.add_argument("--candidate-traces")
57
+ compare_parser.add_argument("--output")
58
+ compare_parser.add_argument("--format", choices=("json", "markdown", "html"), default="markdown")
59
+ compare_parser.add_argument("--store")
60
+ compare_parser.add_argument("--label", default="")
61
+ compare_parser.add_argument("--policy", help="TOML regression policy")
62
+ history_parser = commands.add_parser("history", help="List saved experiment runs")
63
+ history_parser.add_argument("--store", required=True)
64
+ history_parser.add_argument("--limit", type=int, default=20)
65
+ review_parser = commands.add_parser("review", help="Review a saved experiment run")
66
+ review_parser.add_argument("--store", required=True)
67
+ review_parser.add_argument("--run-id", required=True)
68
+ review_parser.add_argument(
69
+ "--status", required=True, choices=("accepted", "rejected", "needs_changes")
70
+ )
71
+ review_parser.add_argument("--reviewer", required=True)
72
+ review_parser.add_argument("--note", default="")
73
+ trend_parser = commands.add_parser("trend", help="Read a saved metric trend")
74
+ trend_parser.add_argument("--store", required=True)
75
+ trend_parser.add_argument("--scenario-id", required=True)
76
+ trend_parser.add_argument("--metric", required=True)
77
+ trend_parser.add_argument("--limit", type=int, default=50)
78
+ inspect_parser = commands.add_parser("inspect", help="Inspect scenario benchmark coverage")
79
+ inspect_parser.add_argument("--scenario", required=True)
80
+ workspace_create = commands.add_parser("workspace-create", help="Create an alpha workspace")
81
+ workspace_create.add_argument("--root", required=True)
82
+ workspace_create.add_argument("--workspace-id", required=True)
83
+ workspace_create.add_argument("--name", required=True)
84
+ workspace_rotate = commands.add_parser("workspace-rotate-key", help="Rotate a workspace key")
85
+ workspace_rotate.add_argument("--root", required=True)
86
+ workspace_rotate.add_argument("--workspace-id", required=True)
87
+ workspace_rotate.add_argument("--current-key", required=True)
88
+ workspace_audit = commands.add_parser("workspace-audit", help="Read workspace audit events")
89
+ workspace_audit.add_argument("--root", required=True)
90
+ workspace_audit.add_argument("--workspace-id", required=True)
91
+ workspace_audit.add_argument("--limit", type=int, default=100)
92
+ return parser
93
+
94
+
95
+ def main() -> int:
96
+ args = build_parser().parse_args()
97
+ if args.command == "demo":
98
+ try:
99
+ summary = write_demo(args.output, force=args.force)
100
+ except OSError as exc:
101
+ raise SystemExit(f"demo output error: {exc}") from exc
102
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
103
+ return 0
104
+ if args.command == "inspect":
105
+ try:
106
+ summary = scenario_summary(load_scenario(args.scenario))
107
+ except ContractError as exc:
108
+ raise SystemExit(f"contract error: {exc}") from exc
109
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
110
+ return 0
111
+ if args.command == "workspace-create":
112
+ try:
113
+ key = ControlPlane(args.root).create_workspace(args.workspace_id, args.name)
114
+ except ValueError as exc:
115
+ raise SystemExit(str(exc)) from exc
116
+ print(json.dumps({"workspace_id": args.workspace_id, "api_key": key}))
117
+ return 0
118
+ if args.command == "workspace-rotate-key":
119
+ try:
120
+ key = ControlPlane(args.root).rotate_key(args.workspace_id, args.current_key)
121
+ except (PermissionError, ValueError) as exc:
122
+ raise SystemExit(str(exc)) from exc
123
+ print(json.dumps({"workspace_id": args.workspace_id, "api_key": key}))
124
+ return 0
125
+ if args.command == "workspace-audit":
126
+ events = ControlPlane(args.root).audit_events(args.workspace_id, limit=args.limit)
127
+ print(json.dumps(events, ensure_ascii=False, indent=2))
128
+ return 0
129
+ if args.command == "history":
130
+ runs = ExperimentStore(args.store).list_runs(limit=args.limit)
131
+ print(json.dumps(runs, ensure_ascii=False, indent=2))
132
+ return 0
133
+ if args.command == "review":
134
+ try:
135
+ ExperimentStore(args.store).review(
136
+ args.run_id,
137
+ status=args.status,
138
+ reviewer=args.reviewer,
139
+ note=args.note,
140
+ )
141
+ except (KeyError, ValueError) as exc:
142
+ raise SystemExit(str(exc)) from exc
143
+ print(json.dumps({"run_id": args.run_id, "review_status": args.status}))
144
+ return 0
145
+ if args.command == "trend":
146
+ points = ExperimentStore(args.store).metric_trend(
147
+ args.scenario_id,
148
+ args.metric,
149
+ limit=args.limit,
150
+ )
151
+ print(json.dumps(points, ensure_ascii=False, indent=2))
152
+ return 0
153
+ try:
154
+ scenario = load_scenario(args.scenario)
155
+ if args.command == "evaluate":
156
+ report = evaluate(
157
+ scenario,
158
+ load_trace_jsonl(args.traces) if args.traces else load_responses(args.responses),
159
+ evaluators=_evaluators_from_names(args.evaluator),
160
+ )
161
+ else:
162
+ report = compare(
163
+ scenario,
164
+ (
165
+ load_trace_jsonl(args.baseline_traces)
166
+ if args.baseline_traces
167
+ else load_responses(args.baseline)
168
+ ),
169
+ (
170
+ load_trace_jsonl(args.candidate_traces)
171
+ if args.candidate_traces
172
+ else load_responses(args.candidate)
173
+ ),
174
+ policy=load_regression_policy(args.policy) if args.policy else None,
175
+ )
176
+ except (ContractError, ValueError) as exc:
177
+ raise SystemExit(f"contract error: {exc}") from exc
178
+ if args.format == "json":
179
+ rendered = json.dumps(report.to_dict(), ensure_ascii=False, indent=2)
180
+ elif args.command == "evaluate":
181
+ rendered = evaluation_markdown(report)
182
+ elif args.format == "html":
183
+ rendered = comparison_html(report)
184
+ else:
185
+ rendered = comparison_markdown(report)
186
+ if args.store:
187
+ ExperimentStore(args.store).save(report, label=args.label)
188
+ if args.output:
189
+ output_path = Path(args.output)
190
+ output_path.parent.mkdir(parents=True, exist_ok=True)
191
+ output_path.write_text(rendered.rstrip() + "\n", encoding="utf-8")
192
+ else:
193
+ print(rendered)
194
+ return 0 if report.passed else 2
195
+
196
+
197
+ def _evaluators_from_names(names: list[str]) -> tuple[CaseEvaluator, ...]:
198
+ factories = {
199
+ "retrieval_recall": RetrievalRecallEvaluator,
200
+ "citation_correctness": CitationCorrectnessEvaluator,
201
+ "claim_support": ClaimSupportEvaluator,
202
+ }
203
+ return tuple(factories[name]() for name in names)
204
+
205
+
206
+ if __name__ == "__main__":
207
+ raise SystemExit(main())
ragops/config.py ADDED
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ from ragops.models import RegressionPolicy
7
+
8
+
9
+ def load_regression_policy(path: str | Path) -> RegressionPolicy:
10
+ try:
11
+ data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
12
+ return RegressionPolicy(**data["regression"])
13
+ except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError) as exc:
14
+ raise ValueError(f"Invalid regression policy {path}: {exc}") from exc
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import re
6
+ import secrets
7
+ import sqlite3
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from ragops.store import ExperimentStore
12
+
13
+ WORKSPACE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{2,62}$")
14
+
15
+
16
+ class ControlPlane:
17
+ """Local control-plane alpha with workspace-isolated experiment stores."""
18
+
19
+ def __init__(self, root: str | Path) -> None:
20
+ self.root = Path(root)
21
+ self.root.mkdir(parents=True, exist_ok=True)
22
+ self.index = self.root / "control-plane.db"
23
+ self._initialize()
24
+
25
+ def create_workspace(self, workspace_id: str, name: str) -> str:
26
+ if not WORKSPACE_ID.fullmatch(workspace_id):
27
+ raise ValueError("workspace ID must be a lowercase slug of 3-63 characters")
28
+ if not name.strip():
29
+ raise ValueError("workspace name is required")
30
+ api_key = f"rgo_{secrets.token_urlsafe(32)}"
31
+ digest = _digest(api_key)
32
+ created_at = datetime.now(timezone.utc).isoformat()
33
+ try:
34
+ with self._connect() as connection:
35
+ connection.execute(
36
+ "INSERT INTO workspaces (id, name, key_digest, created_at) VALUES (?, ?, ?, ?)",
37
+ (workspace_id, name, digest, created_at),
38
+ )
39
+ connection.execute(
40
+ "INSERT INTO audit_events (created_at, workspace_id, action, detail) VALUES (?, ?, ?, ?)",
41
+ (created_at, workspace_id, "workspace.created", name),
42
+ )
43
+ except sqlite3.IntegrityError as exc:
44
+ raise ValueError(f"workspace already exists: {workspace_id}") from exc
45
+ return api_key
46
+
47
+ def authenticate(self, workspace_id: str, api_key: str) -> bool:
48
+ with self._connect() as connection:
49
+ row = connection.execute(
50
+ "SELECT key_digest FROM workspaces WHERE id = ?", (workspace_id,)
51
+ ).fetchone()
52
+ return bool(row and hmac.compare_digest(row[0], _digest(api_key)))
53
+
54
+ def workspace_store(self, workspace_id: str, api_key: str) -> ExperimentStore:
55
+ if not self.authenticate(workspace_id, api_key):
56
+ raise PermissionError("invalid workspace credentials")
57
+ path = self.root / "workspaces" / workspace_id / "runs.db"
58
+ self.audit(workspace_id, "workspace.store_accessed", "experiment store")
59
+ return ExperimentStore(path)
60
+
61
+ def rotate_key(self, workspace_id: str, current_key: str) -> str:
62
+ if not self.authenticate(workspace_id, current_key):
63
+ raise PermissionError("invalid workspace credentials")
64
+ new_key = f"rgo_{secrets.token_urlsafe(32)}"
65
+ with self._connect() as connection:
66
+ connection.execute(
67
+ "UPDATE workspaces SET key_digest = ? WHERE id = ?",
68
+ (_digest(new_key), workspace_id),
69
+ )
70
+ self.audit(workspace_id, "workspace.key_rotated", "credential rotated")
71
+ return new_key
72
+
73
+ def audit(self, workspace_id: str, action: str, detail: str) -> None:
74
+ with self._connect() as connection:
75
+ connection.execute(
76
+ "INSERT INTO audit_events (created_at, workspace_id, action, detail) VALUES (?, ?, ?, ?)",
77
+ (datetime.now(timezone.utc).isoformat(), workspace_id, action, detail),
78
+ )
79
+
80
+ def audit_events(self, workspace_id: str, *, limit: int = 100) -> list[dict[str, str]]:
81
+ if limit < 1 or limit > 1000:
82
+ raise ValueError("limit must be between 1 and 1000")
83
+ with self._connect() as connection:
84
+ rows = connection.execute(
85
+ """
86
+ SELECT created_at, action, detail FROM audit_events
87
+ WHERE workspace_id = ? ORDER BY id DESC LIMIT ?
88
+ """,
89
+ (workspace_id, limit),
90
+ ).fetchall()
91
+ return [
92
+ {"created_at": row[0], "action": row[1], "detail": row[2]} for row in rows
93
+ ]
94
+
95
+ def _connect(self) -> sqlite3.Connection:
96
+ return sqlite3.connect(self.index)
97
+
98
+ def _initialize(self) -> None:
99
+ with self._connect() as connection:
100
+ connection.execute(
101
+ """
102
+ CREATE TABLE IF NOT EXISTS workspaces (
103
+ id TEXT PRIMARY KEY,
104
+ name TEXT NOT NULL,
105
+ key_digest TEXT NOT NULL,
106
+ created_at TEXT NOT NULL
107
+ )
108
+ """
109
+ )
110
+ connection.execute(
111
+ """
112
+ CREATE TABLE IF NOT EXISTS audit_events (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114
+ created_at TEXT NOT NULL,
115
+ workspace_id TEXT NOT NULL,
116
+ action TEXT NOT NULL,
117
+ detail TEXT NOT NULL,
118
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(id)
119
+ )
120
+ """
121
+ )
122
+
123
+
124
+ def _digest(api_key: str) -> str:
125
+ return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
ragops/demo.py ADDED
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from uuid import uuid4
7
+
8
+ from ragops.engine import compare
9
+ from ragops.loader import responses_from_data, scenario_from_dict
10
+ from ragops.reporters import comparison_html, comparison_markdown
11
+
12
+
13
+ DEMO_SCENARIO = {
14
+ "schema_version": "0.2",
15
+ "id": "ragops-five-minute-demo",
16
+ "name": "RAGOps five-minute release-gate demo",
17
+ "thresholds": {
18
+ "citation_coverage": 1.0,
19
+ "citation_precision": 1.0,
20
+ "lexical_groundedness": 0.7,
21
+ "max_latency_ms": 3000,
22
+ "max_cost_usd": 0.03,
23
+ },
24
+ "redteam": {
25
+ "forbidden_output_terms": ["DEMO_SECRET"],
26
+ "require_human_approval_for_external_actions": True,
27
+ "external_action_markers": ["sent email"],
28
+ },
29
+ "cases": [
30
+ {
31
+ "id": "demo-procedure",
32
+ "question": "A1000型でE-42が発生した場合の一次対応は?",
33
+ "evidence": [
34
+ "Stop A1000, inspect the cooling fan guard, remove debris, and restart below 60C."
35
+ ],
36
+ "required_citation_ids": ["manual-a1000-e42"],
37
+ "category": "direct_procedure",
38
+ "severity": "high",
39
+ "language": "ja",
40
+ },
41
+ {
42
+ "id": "demo-escalation",
43
+ "question": "E-42をいつエスカレーションする?",
44
+ "evidence": [
45
+ "Escalate E-42 after two repeats within 24 hours or temperature remaining above 60C."
46
+ ],
47
+ "required_citation_ids": ["policy-e42-escalation"],
48
+ "category": "escalation_decision",
49
+ "severity": "high",
50
+ "language": "ja",
51
+ },
52
+ ],
53
+ }
54
+
55
+ DEMO_BASELINE = [
56
+ {
57
+ "case_id": "demo-procedure",
58
+ "answer": "Stop A1000, inspect the cooling fan guard, remove debris, and restart below 60C.",
59
+ "citation_ids": ["manual-a1000-e42"],
60
+ "latency_ms": 850,
61
+ "cost_usd": 0.008,
62
+ },
63
+ {
64
+ "case_id": "demo-escalation",
65
+ "answer": "Escalate E-42 after two repeats within 24 hours or temperature remaining above 60C.",
66
+ "citation_ids": ["policy-e42-escalation"],
67
+ "latency_ms": 900,
68
+ "cost_usd": 0.009,
69
+ },
70
+ ]
71
+
72
+ DEMO_CANDIDATE = [
73
+ DEMO_BASELINE[0],
74
+ {
75
+ "case_id": "demo-escalation",
76
+ "answer": "Escalate whenever the issue seems serious.",
77
+ "citation_ids": [],
78
+ "latency_ms": 920,
79
+ "cost_usd": 0.009,
80
+ },
81
+ ]
82
+
83
+
84
+ def write_demo(output_dir: str | Path, *, force: bool = False) -> dict[str, object]:
85
+ """Write a credential-free demo bundle and return its release summary."""
86
+
87
+ destination = Path(output_dir)
88
+ if destination.is_symlink():
89
+ raise FileExistsError(f"refusing symlinked demo output directory: {destination}")
90
+ if destination.exists():
91
+ if not force:
92
+ raise FileExistsError(
93
+ f"demo output already exists: {destination}; pass --force to replace regular files"
94
+ )
95
+ if not destination.is_dir():
96
+ raise NotADirectoryError(f"demo output is not a directory: {destination}")
97
+ else:
98
+ destination.mkdir(parents=True, exist_ok=False)
99
+ scenario = scenario_from_dict(DEMO_SCENARIO)
100
+ report = compare(
101
+ scenario,
102
+ responses_from_data(DEMO_BASELINE),
103
+ responses_from_data(DEMO_CANDIDATE),
104
+ )
105
+ files = {
106
+ "scenario": destination / "scenario.json",
107
+ "baseline": destination / "baseline.json",
108
+ "candidate": destination / "candidate.json",
109
+ "markdown_report": destination / "release-report.md",
110
+ "html_report": destination / "release-report.html",
111
+ }
112
+ _write_demo_file(
113
+ files["scenario"],
114
+ json.dumps(DEMO_SCENARIO, ensure_ascii=False, indent=2) + "\n",
115
+ force=force,
116
+ )
117
+ _write_demo_file(
118
+ files["baseline"],
119
+ json.dumps(DEMO_BASELINE, ensure_ascii=False, indent=2) + "\n",
120
+ force=force,
121
+ )
122
+ _write_demo_file(
123
+ files["candidate"],
124
+ json.dumps(DEMO_CANDIDATE, ensure_ascii=False, indent=2) + "\n",
125
+ force=force,
126
+ )
127
+ _write_demo_file(files["markdown_report"], comparison_markdown(report), force=force)
128
+ _write_demo_file(files["html_report"], comparison_html(report), force=force)
129
+ return {
130
+ "demo_completed": True,
131
+ "candidate_decision": "BLOCK" if not report.passed else "PASS",
132
+ "failed_gates": list(report.failed_gates),
133
+ "output_dir": str(destination),
134
+ "files": {name: str(path) for name, path in files.items()},
135
+ }
136
+
137
+
138
+ def _write_demo_file(path: Path, content: str, *, force: bool) -> None:
139
+ if path.is_symlink():
140
+ raise FileExistsError(f"refusing symlinked demo output file: {path}")
141
+ if not force:
142
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
143
+ if hasattr(os, "O_NOFOLLOW"):
144
+ flags |= os.O_NOFOLLOW
145
+ descriptor = os.open(path, flags, 0o644)
146
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
147
+ handle.write(content)
148
+ return
149
+
150
+ if path.exists() and not path.is_file():
151
+ raise FileExistsError(f"refusing non-file demo output target: {path}")
152
+ temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
153
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
154
+ if hasattr(os, "O_NOFOLLOW"):
155
+ flags |= os.O_NOFOLLOW
156
+ descriptor = os.open(temporary, flags, 0o644)
157
+ try:
158
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
159
+ handle.write(content)
160
+ os.replace(temporary, path)
161
+ finally:
162
+ temporary.unlink(missing_ok=True)