modao-prd-cli 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.
- modao_prd_cli/__init__.py +1 -0
- modao_prd_cli/modao_prd/__init__.py +4 -0
- modao_prd_cli/modao_prd/__main__.py +7 -0
- modao_prd_cli/modao_prd/browser.py +578 -0
- modao_prd_cli/modao_prd/capture_evidence.py +547 -0
- modao_prd_cli/modao_prd/classifier.py +96 -0
- modao_prd_cli/modao_prd/cli.py +205 -0
- modao_prd_cli/modao_prd/errors.py +31 -0
- modao_prd_cli/modao_prd/evidence.py +207 -0
- modao_prd_cli/modao_prd/explorer.py +300 -0
- modao_prd_cli/modao_prd/extractor.py +465 -0
- modao_prd_cli/modao_prd/models.py +56 -0
- modao_prd_cli/modao_prd/normalizer.py +135 -0
- modao_prd_cli/modao_prd/schemas/coverage-1.0.json +15 -0
- modao_prd_cli/modao_prd/schemas/document-2.0.json +21 -0
- modao_prd_cli/modao_prd/schemas/document-2.1.json +31 -0
- modao_prd_cli/modao_prd/schemas/manifest-1.0.json +28 -0
- modao_prd_cli/modao_prd/tests/__init__.py +1 -0
- modao_prd_cli/modao_prd/tests/fixtures/modao_sample.html +20 -0
- modao_prd_cli/modao_prd/tests/test_browser.py +54 -0
- modao_prd_cli/modao_prd/tests/test_classifier.py +32 -0
- modao_prd_cli/modao_prd/tests/test_cli.py +88 -0
- modao_prd_cli/modao_prd/tests/test_extractor.py +57 -0
- modao_prd_cli/modao_prd/tests/test_full_e2e.py +23 -0
- modao_prd_cli/modao_prd/tests/test_writers.py +79 -0
- modao_prd_cli/modao_prd/writers.py +468 -0
- modao_prd_cli-0.1.0.dist-info/METADATA +108 -0
- modao_prd_cli-0.1.0.dist-info/RECORD +32 -0
- modao_prd_cli-0.1.0.dist-info/WHEEL +5 -0
- modao_prd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- modao_prd_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
- modao_prd_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Command-line interface for extracting public Modao prototype shares."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .browser import (
|
|
14
|
+
capture_page,
|
|
15
|
+
default_output_root,
|
|
16
|
+
doctor,
|
|
17
|
+
ensure_default_output_ignored,
|
|
18
|
+
validate_share_url,
|
|
19
|
+
)
|
|
20
|
+
from .evidence import EvidenceBundleWriter
|
|
21
|
+
from .errors import ModaoPrdError
|
|
22
|
+
from .extractor import build_document, summarize_document
|
|
23
|
+
from .writers import write_outputs
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _json_mode(ctx: click.Context, command_json: bool) -> bool:
|
|
27
|
+
return bool(command_json or (ctx.find_root().obj or {}).get("json", False))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _emit(data: Any, *, as_json: bool, message: str | None = None) -> None:
|
|
31
|
+
if as_json:
|
|
32
|
+
click.echo(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
|
33
|
+
return
|
|
34
|
+
if message:
|
|
35
|
+
click.echo(message)
|
|
36
|
+
if isinstance(data, dict):
|
|
37
|
+
_print_dict(data)
|
|
38
|
+
elif isinstance(data, list):
|
|
39
|
+
for item in data:
|
|
40
|
+
click.echo(f"- {item}")
|
|
41
|
+
else:
|
|
42
|
+
click.echo(str(data))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_dict(data: dict[str, Any], indent: int = 0) -> None:
|
|
46
|
+
prefix = " " * indent
|
|
47
|
+
for key, value in data.items():
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
click.echo(f"{prefix}{key}:")
|
|
50
|
+
_print_dict(value, indent + 1)
|
|
51
|
+
elif isinstance(value, list):
|
|
52
|
+
click.echo(f"{prefix}{key}:")
|
|
53
|
+
for item in value:
|
|
54
|
+
if isinstance(item, dict):
|
|
55
|
+
click.echo(f"{prefix} -")
|
|
56
|
+
_print_dict(item, indent + 2)
|
|
57
|
+
else:
|
|
58
|
+
click.echo(f"{prefix} - {item}")
|
|
59
|
+
else:
|
|
60
|
+
click.echo(f"{prefix}{key}: {value}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fail(exc: Exception, *, as_json: bool, url: str | None = None) -> None:
|
|
64
|
+
if isinstance(exc, ModaoPrdError):
|
|
65
|
+
payload = exc.as_dict()
|
|
66
|
+
elif isinstance(exc, FileExistsError):
|
|
67
|
+
payload = ModaoPrdError("output_exists", str(exc), url=url).as_dict()
|
|
68
|
+
elif isinstance(exc, ValueError):
|
|
69
|
+
payload = ModaoPrdError("invalid_input", str(exc), url=url).as_dict()
|
|
70
|
+
else:
|
|
71
|
+
payload = ModaoPrdError("runtime_error", str(exc), url=url).as_dict()
|
|
72
|
+
if as_json:
|
|
73
|
+
click.echo(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
74
|
+
else:
|
|
75
|
+
click.echo(f"Error: {payload['error']['message']}", err=True)
|
|
76
|
+
raise click.exceptions.Exit(1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@click.group(invoke_without_command=True)
|
|
80
|
+
@click.option("--json", "json_output", is_flag=True, help="输出机器可读 JSON。")
|
|
81
|
+
@click.version_option(version=__version__, prog_name="modao-prd-cli")
|
|
82
|
+
@click.pass_context
|
|
83
|
+
def cli(ctx: click.Context, json_output: bool) -> None:
|
|
84
|
+
"""将墨刀公开分享页提取为 Agent 易读取的结构化文档。"""
|
|
85
|
+
|
|
86
|
+
ctx.ensure_object(dict)
|
|
87
|
+
ctx.obj["json"] = json_output
|
|
88
|
+
if ctx.invoked_subcommand is None:
|
|
89
|
+
click.echo(ctx.get_help())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@cli.command("doctor")
|
|
93
|
+
@click.option("--json", "json_output", is_flag=True, help="输出机器可读 JSON。")
|
|
94
|
+
@click.pass_context
|
|
95
|
+
def doctor_cmd(ctx: click.Context, json_output: bool) -> None:
|
|
96
|
+
"""检查 Python、Playwright 和浏览器运行环境。"""
|
|
97
|
+
|
|
98
|
+
_emit(doctor(), as_json=_json_mode(ctx, json_output))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@cli.command("inspect")
|
|
102
|
+
@click.argument("url")
|
|
103
|
+
@click.option("--json", "json_output", is_flag=True, help="输出机器可读 JSON。")
|
|
104
|
+
@click.option("--headed", is_flag=True, help="显示浏览器窗口,用于调试页面渲染。")
|
|
105
|
+
@click.option("--timeout", type=click.IntRange(min=1), default=30, show_default=True, help="页面渲染超时时间(秒)。")
|
|
106
|
+
@click.pass_context
|
|
107
|
+
def inspect_cmd(ctx: click.Context, url: str, json_output: bool, headed: bool, timeout: int) -> None:
|
|
108
|
+
"""读取 URL 并输出页面提取摘要,不生成文件。"""
|
|
109
|
+
|
|
110
|
+
as_json = _json_mode(ctx, json_output)
|
|
111
|
+
try:
|
|
112
|
+
raw = capture_page(url, headed=headed, timeout_seconds=timeout)
|
|
113
|
+
document = build_document(raw)
|
|
114
|
+
_emit(summarize_document(document), as_json=as_json, message="墨刀页面检查完成。")
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
_fail(exc, as_json=as_json, url=url)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@cli.command("export")
|
|
120
|
+
@click.argument("url")
|
|
121
|
+
@click.option("--format", "output_format", type=click.Choice(["json", "markdown", "ndjson", "all"]), default="all", show_default=True, help="导出格式。")
|
|
122
|
+
@click.option("--output", "output_root", type=click.Path(file_okay=False, dir_okay=True, path_type=str), default=None, help="输出根目录;默认是当前项目根目录的 .modao-prd,项目 ID 会作为子目录。")
|
|
123
|
+
@click.option("--headed", is_flag=True, help="显示浏览器窗口,用于调试页面渲染。")
|
|
124
|
+
@click.option("--timeout", type=click.IntRange(min=1), default=30, show_default=True, help="页面渲染超时时间(秒)。")
|
|
125
|
+
@click.option("--force", is_flag=True, help="覆盖已有 document 输出文件。")
|
|
126
|
+
@click.option("--evidence", type=click.Choice(["essential", "full", "none"]), default="essential", show_default=True, help="证据采集级别;essential 适合需求分析,full 适合排障。")
|
|
127
|
+
@click.option("--explore", type=click.Choice(["safe", "none"]), default="safe", show_default=True, help="安全状态探索策略。")
|
|
128
|
+
@click.option("--max-states", type=click.IntRange(min=1), default=50, show_default=True, help="最多采集状态数。")
|
|
129
|
+
@click.option("--max-depth", type=click.IntRange(min=0), default=3, show_default=True, help="安全探索最大深度。")
|
|
130
|
+
@click.option("--max-actions", type=click.IntRange(min=0), default=200, show_default=True, help="最多尝试动作数。")
|
|
131
|
+
@click.option("--max-duration", type=click.IntRange(min=1), default=120, show_default=True, help="整体采集上限(秒)。")
|
|
132
|
+
@click.option("--max-item-mb", type=click.IntRange(min=0), default=10, show_default=True, help="单个证据文件大小上限(MiB)。")
|
|
133
|
+
@click.option("--max-total-mb", type=click.IntRange(min=0), default=200, show_default=True, help="证据包大小上限(MiB)。")
|
|
134
|
+
@click.option("--json", "json_output", is_flag=True, help="输出机器可读 JSON。")
|
|
135
|
+
@click.pass_context
|
|
136
|
+
def export_cmd(
|
|
137
|
+
ctx: click.Context,
|
|
138
|
+
url: str,
|
|
139
|
+
output_format: str,
|
|
140
|
+
output_root: str | None,
|
|
141
|
+
headed: bool,
|
|
142
|
+
timeout: int,
|
|
143
|
+
force: bool,
|
|
144
|
+
evidence: str,
|
|
145
|
+
explore: str,
|
|
146
|
+
max_states: int,
|
|
147
|
+
max_depth: int,
|
|
148
|
+
max_actions: int,
|
|
149
|
+
max_duration: int,
|
|
150
|
+
max_item_mb: int,
|
|
151
|
+
max_total_mb: int,
|
|
152
|
+
json_output: bool,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""读取墨刀公开分享页并导出 JSON、Markdown 或 NDJSON。"""
|
|
155
|
+
|
|
156
|
+
as_json = _json_mode(ctx, json_output)
|
|
157
|
+
writer: EvidenceBundleWriter | None = None
|
|
158
|
+
try:
|
|
159
|
+
share = validate_share_url(url)
|
|
160
|
+
if output_root is None:
|
|
161
|
+
root_path = default_output_root()
|
|
162
|
+
ensure_default_output_ignored(root_path.parent)
|
|
163
|
+
root = str(root_path)
|
|
164
|
+
else:
|
|
165
|
+
root = output_root
|
|
166
|
+
output_dir = Path(root).expanduser().resolve() / share.project_id
|
|
167
|
+
if evidence != "none":
|
|
168
|
+
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
writer = EvidenceBundleWriter(
|
|
170
|
+
output_dir,
|
|
171
|
+
max_item=max_item_mb * 1024 * 1024,
|
|
172
|
+
max_total=max_total_mb * 1024 * 1024,
|
|
173
|
+
force=force,
|
|
174
|
+
)
|
|
175
|
+
raw = capture_page(
|
|
176
|
+
url,
|
|
177
|
+
headed=headed,
|
|
178
|
+
timeout_seconds=timeout,
|
|
179
|
+
evidence_writer=writer,
|
|
180
|
+
evidence_mode=evidence,
|
|
181
|
+
explore=explore,
|
|
182
|
+
max_states=max_states,
|
|
183
|
+
max_depth=max_depth,
|
|
184
|
+
max_actions=max_actions,
|
|
185
|
+
max_duration_seconds=max_duration,
|
|
186
|
+
max_item=max_item_mb * 1024 * 1024,
|
|
187
|
+
max_total=max_total_mb * 1024 * 1024,
|
|
188
|
+
)
|
|
189
|
+
document = build_document(raw)
|
|
190
|
+
result = write_outputs(document, root, output_format, force=force, evidence_writer=writer)
|
|
191
|
+
writer = None
|
|
192
|
+
result["summary"] = summarize_document(document)
|
|
193
|
+
_emit(result, as_json=as_json, message="墨刀文档导出完成。")
|
|
194
|
+
if document.get("stats", {}).get("quality") == "partial":
|
|
195
|
+
raise click.exceptions.Exit(2)
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
if writer is not None:
|
|
198
|
+
writer.close()
|
|
199
|
+
if isinstance(exc, click.exceptions.Exit):
|
|
200
|
+
raise
|
|
201
|
+
_fail(exc, as_json=as_json, url=url)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main() -> None:
|
|
205
|
+
cli(prog_name="modao-prd-cli")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Stable, JSON-safe error types used by the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ModaoPrdError(Exception):
|
|
11
|
+
"""An expected failure with a stable machine-readable type."""
|
|
12
|
+
|
|
13
|
+
error_type: str
|
|
14
|
+
message: str
|
|
15
|
+
url: str | None = None
|
|
16
|
+
diagnostics: dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
super().__init__(self.message)
|
|
20
|
+
|
|
21
|
+
def as_dict(self) -> dict[str, Any]:
|
|
22
|
+
error: dict[str, Any] = {
|
|
23
|
+
"type": self.error_type,
|
|
24
|
+
"message": self.message,
|
|
25
|
+
}
|
|
26
|
+
if self.url:
|
|
27
|
+
error["url"] = self.url
|
|
28
|
+
if self.diagnostics:
|
|
29
|
+
error["diagnostics"] = self.diagnostics
|
|
30
|
+
return {"error": error}
|
|
31
|
+
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Safe, bounded and atomic evidence bundle writing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import asdict
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, BinaryIO
|
|
14
|
+
|
|
15
|
+
from .models import EvidenceItem
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EvidenceBundleWriter:
|
|
19
|
+
"""Stage evidence files and atomically publish a manifest-backed bundle.
|
|
20
|
+
|
|
21
|
+
``output_dir`` is the bundle directory. Files are accepted only as relative
|
|
22
|
+
paths, and an existing file may be replaced with ``force`` only when the
|
|
23
|
+
previous manifest identifies it as a generated evidence file.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
output_dir: str | Path,
|
|
29
|
+
*,
|
|
30
|
+
max_item: int = 10 * 1024 * 1024,
|
|
31
|
+
max_total: int = 200 * 1024 * 1024,
|
|
32
|
+
force: bool = False,
|
|
33
|
+
generated_by: str = "modao-prd-cli",
|
|
34
|
+
) -> None:
|
|
35
|
+
if max_item < 0 or max_total < 0:
|
|
36
|
+
raise ValueError("size limits must be non-negative")
|
|
37
|
+
self.output_dir = Path(output_dir).expanduser().resolve()
|
|
38
|
+
self.max_item = max_item
|
|
39
|
+
self.max_total = max_total
|
|
40
|
+
self.force = force
|
|
41
|
+
self.generated_by = generated_by
|
|
42
|
+
self._items: list[EvidenceItem] = []
|
|
43
|
+
self._total = 0
|
|
44
|
+
self._old_generated = self._read_old_generated()
|
|
45
|
+
self._temp = Path(tempfile.mkdtemp(prefix=f".{self.output_dir.name}.", dir=str(self.output_dir.parent)))
|
|
46
|
+
self._closed = False
|
|
47
|
+
|
|
48
|
+
def add(
|
|
49
|
+
self,
|
|
50
|
+
relative_path: str | Path,
|
|
51
|
+
content: bytes | bytearray | memoryview | str | Path | BinaryIO,
|
|
52
|
+
*,
|
|
53
|
+
media_type: str | None = None,
|
|
54
|
+
enforce_item_limit: bool = True,
|
|
55
|
+
) -> EvidenceItem:
|
|
56
|
+
"""Stage one item and return its manifest record."""
|
|
57
|
+
if self._closed:
|
|
58
|
+
raise RuntimeError("bundle writer is already finalized")
|
|
59
|
+
try:
|
|
60
|
+
relative = self._validate_relative_path(relative_path)
|
|
61
|
+
path_key = self._path_key(relative)
|
|
62
|
+
if path_key == "manifest.json":
|
|
63
|
+
raise ValueError("manifest.json is reserved")
|
|
64
|
+
if any(self._path_key(item.path) == path_key for item in self._items):
|
|
65
|
+
raise ValueError(f"duplicate evidence path: {relative.as_posix()}")
|
|
66
|
+
destination = self._temp / relative
|
|
67
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
size = 0
|
|
69
|
+
digest = hashlib.sha256()
|
|
70
|
+
with destination.open("wb") as target:
|
|
71
|
+
for chunk in self._chunks(content):
|
|
72
|
+
size += len(chunk)
|
|
73
|
+
if (enforce_item_limit and size > self.max_item) or self._total + size > self.max_total:
|
|
74
|
+
raise ValueError("evidence size limit exceeded")
|
|
75
|
+
digest.update(chunk)
|
|
76
|
+
target.write(chunk)
|
|
77
|
+
self._total += size
|
|
78
|
+
item = EvidenceItem(relative.as_posix(), digest.hexdigest(), size, media_type or self._media_type(relative))
|
|
79
|
+
self._items.append(item)
|
|
80
|
+
return item
|
|
81
|
+
except Exception:
|
|
82
|
+
# Keep the staged bundle alive so the caller can record a partial
|
|
83
|
+
# capture and still publish other evidence files. Finalization (or
|
|
84
|
+
# the caller's explicit close) performs the cleanup.
|
|
85
|
+
try:
|
|
86
|
+
if destination.exists():
|
|
87
|
+
destination.unlink()
|
|
88
|
+
except UnboundLocalError:
|
|
89
|
+
pass
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
def finalize(self, *, extra: dict[str, Any] | None = None) -> Path:
|
|
93
|
+
"""Publish staged files and an atomically replaced ``manifest.json``."""
|
|
94
|
+
if self._closed:
|
|
95
|
+
raise RuntimeError("bundle writer is already finalized")
|
|
96
|
+
try:
|
|
97
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
existing_manifest = self.output_dir / "manifest.json"
|
|
99
|
+
self._assert_inside_output(existing_manifest)
|
|
100
|
+
existing_files = {self._path_key(path) for path in self._old_generated}
|
|
101
|
+
targets = []
|
|
102
|
+
for item in self._items:
|
|
103
|
+
target = self.output_dir / item.path
|
|
104
|
+
self._assert_inside_output(target)
|
|
105
|
+
if target.exists() and not (self.force and self._path_key(item.path) in existing_files):
|
|
106
|
+
raise FileExistsError(f"evidence file already exists: {target}")
|
|
107
|
+
targets.append(target)
|
|
108
|
+
|
|
109
|
+
manifest: dict[str, Any] = {
|
|
110
|
+
"schema_version": "1.0",
|
|
111
|
+
"generated_by": self.generated_by,
|
|
112
|
+
"files": [asdict(item) for item in self._items],
|
|
113
|
+
"total_size": self._total,
|
|
114
|
+
}
|
|
115
|
+
if extra:
|
|
116
|
+
manifest["metadata"] = dict(extra)
|
|
117
|
+
for item in self._items:
|
|
118
|
+
target = targets.pop(0)
|
|
119
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
os.replace(self._temp / item.path, target)
|
|
121
|
+
manifest_tmp = self._temp / "manifest.json"
|
|
122
|
+
manifest_tmp.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
123
|
+
os.replace(manifest_tmp, existing_manifest)
|
|
124
|
+
self._remove_stale_generated()
|
|
125
|
+
return existing_manifest
|
|
126
|
+
finally:
|
|
127
|
+
self.close()
|
|
128
|
+
|
|
129
|
+
def _read_old_generated(self) -> set[str]:
|
|
130
|
+
manifest = Path(self.output_dir) / "manifest.json"
|
|
131
|
+
try:
|
|
132
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
133
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
134
|
+
return set()
|
|
135
|
+
if not isinstance(data, dict) or not isinstance(data.get("files"), list):
|
|
136
|
+
return set()
|
|
137
|
+
return {
|
|
138
|
+
str(entry["path"])
|
|
139
|
+
for entry in data.get("files", [])
|
|
140
|
+
if isinstance(entry, dict) and isinstance(entry.get("path"), str)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def _remove_stale_generated(self) -> None:
|
|
144
|
+
"""Remove obsolete evidence files previously generated by this tool."""
|
|
145
|
+
|
|
146
|
+
if not self.force:
|
|
147
|
+
return
|
|
148
|
+
current = {self._path_key(item.path) for item in self._items}
|
|
149
|
+
for old_path in self._old_generated:
|
|
150
|
+
if self._path_key(old_path) in current or not old_path.casefold().startswith("evidence/"):
|
|
151
|
+
continue
|
|
152
|
+
target = self.output_dir / old_path
|
|
153
|
+
try:
|
|
154
|
+
self._assert_inside_output(target)
|
|
155
|
+
if target.is_file() or target.is_symlink():
|
|
156
|
+
target.unlink()
|
|
157
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
158
|
+
# A stale file is cleanup-only; never turn a successful bundle
|
|
159
|
+
# into a failed export because an old artifact disappeared.
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _path_key(path: str | Path) -> str:
|
|
164
|
+
return Path(path).as_posix().casefold()
|
|
165
|
+
|
|
166
|
+
def _assert_inside_output(self, path: Path) -> None:
|
|
167
|
+
try:
|
|
168
|
+
path.resolve(strict=False).relative_to(self.output_dir)
|
|
169
|
+
except ValueError as exc:
|
|
170
|
+
raise ValueError(f"evidence target escapes output directory: {path}") from exc
|
|
171
|
+
|
|
172
|
+
def close(self) -> None:
|
|
173
|
+
"""Discard staged files and make the writer unusable."""
|
|
174
|
+
if not self._closed:
|
|
175
|
+
self._closed = True
|
|
176
|
+
shutil.rmtree(self._temp, ignore_errors=True)
|
|
177
|
+
|
|
178
|
+
def __enter__(self) -> "EvidenceBundleWriter":
|
|
179
|
+
if self._closed:
|
|
180
|
+
raise RuntimeError("bundle writer is already finalized")
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
184
|
+
self.close()
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _validate_relative_path(path: str | Path) -> Path:
|
|
188
|
+
candidate = Path(path)
|
|
189
|
+
if candidate.is_absolute() or not candidate.parts or ".." in candidate.parts:
|
|
190
|
+
raise ValueError("evidence path must be relative and cannot contain '..'")
|
|
191
|
+
return candidate
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _chunks(content: bytes | bytearray | memoryview | str | Path | BinaryIO):
|
|
195
|
+
if isinstance(content, Path):
|
|
196
|
+
with Path(content).open("rb") as source:
|
|
197
|
+
yield from iter(lambda: source.read(1024 * 1024), b"")
|
|
198
|
+
elif isinstance(content, str):
|
|
199
|
+
yield content.encode("utf-8")
|
|
200
|
+
elif isinstance(content, (bytes, bytearray, memoryview)):
|
|
201
|
+
yield bytes(content)
|
|
202
|
+
else:
|
|
203
|
+
yield from iter(lambda: content.read(1024 * 1024), b"")
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _media_type(path: Path) -> str:
|
|
207
|
+
return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|