145 lines
6.4 KiB
Python
145 lines
6.4 KiB
Python
|
|
"""Bounded log following and result snapshots; never modify original outputs."""
|
||
|
|
import hashlib
|
||
|
|
import codecs
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import time
|
||
|
|
from .catalog import safe_path, SECRET
|
||
|
|
|
||
|
|
|
||
|
|
def files_under(paths, limit=256):
|
||
|
|
items = []
|
||
|
|
deadline = time.monotonic() + 2
|
||
|
|
for root in map(Path, paths):
|
||
|
|
if root.is_symlink():
|
||
|
|
continue
|
||
|
|
if root.is_file():
|
||
|
|
items.append(root)
|
||
|
|
elif root.is_dir():
|
||
|
|
for directory, dirs, files in os.walk(root, followlinks=False):
|
||
|
|
dirs[:] = sorted(d for d in dirs if not (Path(directory) / d).is_symlink())
|
||
|
|
for name in sorted(files):
|
||
|
|
path = Path(directory) / name
|
||
|
|
if not path.is_symlink() and path.is_file():
|
||
|
|
items.append(path)
|
||
|
|
if len(items) >= limit or time.monotonic() > deadline:
|
||
|
|
return items[:limit]
|
||
|
|
return items[:limit]
|
||
|
|
|
||
|
|
|
||
|
|
def signature(path):
|
||
|
|
try:
|
||
|
|
stat = path.stat()
|
||
|
|
return (stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
||
|
|
except OSError:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
class Collector:
|
||
|
|
def __init__(self, settings, directory, output_paths, redactor=None, stream_filter_factory=None, file_limit=256):
|
||
|
|
self.settings, self.directory = settings, directory
|
||
|
|
self.file_limit = file_limit
|
||
|
|
self.output_provider = output_paths if callable(output_paths) else lambda: [Path(p) for p in output_paths]
|
||
|
|
self.redactor = redactor
|
||
|
|
self.stream_filter_factory = stream_filter_factory
|
||
|
|
self.log_filters = {}
|
||
|
|
self.before = {str(p): signature(p) for p in files_under(self.outputs, self.file_limit)}
|
||
|
|
self.positions = {str(p): p.stat().st_size for p in files_under(self.outputs, self.file_limit) if p.suffix == ".log"}
|
||
|
|
self.notices = []
|
||
|
|
self.decoders = {}
|
||
|
|
self.last_file = None
|
||
|
|
|
||
|
|
@property
|
||
|
|
def outputs(self):
|
||
|
|
return self.output_provider()
|
||
|
|
|
||
|
|
def follow(self, emit):
|
||
|
|
total = 0
|
||
|
|
for path in files_under([*self.outputs, self.directory / "work"], self.file_limit):
|
||
|
|
if path.suffix != ".log":
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
start = self.positions.get(str(path), 0)
|
||
|
|
if path.stat().st_size < start:
|
||
|
|
start = 0
|
||
|
|
self.decoders.pop(str(path), None)
|
||
|
|
with path.open("rb") as handle:
|
||
|
|
handle.seek(start)
|
||
|
|
data = handle.read(32768)
|
||
|
|
self.positions[str(path)] = handle.tell()
|
||
|
|
if data:
|
||
|
|
total += len(data)
|
||
|
|
if self.stream_filter_factory:
|
||
|
|
log_filter = self.log_filters.setdefault(str(path), self.stream_filter_factory())
|
||
|
|
data = log_filter.feed(data)
|
||
|
|
decoder = self.decoders.setdefault(str(path), codecs.getincrementaldecoder("utf-8")("replace"))
|
||
|
|
text = decoder.decode(data)
|
||
|
|
prefix = f"\n[文件日志 {path.name}]\n" if self.last_file != str(path) else ""
|
||
|
|
emit((prefix + text).encode("utf-8"))
|
||
|
|
self.last_file = str(path)
|
||
|
|
except OSError:
|
||
|
|
self.notices.append(f"日志读取失败:{path.name}")
|
||
|
|
return total
|
||
|
|
|
||
|
|
def finish_follow(self, emit):
|
||
|
|
total = 0
|
||
|
|
while total < self.settings.max_log:
|
||
|
|
count = self.follow(emit)
|
||
|
|
if count == 0:
|
||
|
|
break
|
||
|
|
total += count
|
||
|
|
for name, decoder in self.decoders.items():
|
||
|
|
extra = self.log_filters[name].finish() if name in self.log_filters else b""
|
||
|
|
tail = decoder.decode(extra, final=True)
|
||
|
|
if tail:
|
||
|
|
emit(tail.encode("utf-8"))
|
||
|
|
self.decoders.clear()
|
||
|
|
self.log_filters.clear()
|
||
|
|
|
||
|
|
def external_growth(self):
|
||
|
|
growth = 0
|
||
|
|
for path in files_under(self.outputs, limit=1025):
|
||
|
|
stat = signature(path)
|
||
|
|
before = self.before.get(str(path))
|
||
|
|
if stat:
|
||
|
|
growth += max(0, stat[1] - (before[1] if before and before[0] == stat[0] else 0))
|
||
|
|
return growth
|
||
|
|
|
||
|
|
def collect(self):
|
||
|
|
destination = self.directory / "artifacts"
|
||
|
|
destination.mkdir(exist_ok=True)
|
||
|
|
local = files_under([self.directory / "work"])
|
||
|
|
changed = [p for p in files_under(self.outputs, self.file_limit) if signature(p) != self.before.get(str(p))]
|
||
|
|
sources = [self.directory / "source" / "script.txt", *local, *changed]
|
||
|
|
items, total = [], 0
|
||
|
|
for path in sources[:self.settings.max_run_files - 1]:
|
||
|
|
try:
|
||
|
|
if not path.is_file() or path.is_symlink():
|
||
|
|
continue
|
||
|
|
if path.stat().st_size > self.settings.max_artifact:
|
||
|
|
retention = "临时文件将在收尾时释放" if path in local else "原位置文件保留"
|
||
|
|
self.notices.append(f"未归档 {path.name}:超过单文件 32 MiB 上限,{retention}")
|
||
|
|
continue
|
||
|
|
data = path.read_bytes()
|
||
|
|
if len(data) > self.settings.max_artifact or total + len(data) > 64 * 1024 * 1024:
|
||
|
|
self.notices.append("结果超过归档容量限制:原目录文件保留,未归档的临时结果将在收尾时释放")
|
||
|
|
continue
|
||
|
|
suffix = path.suffix.lower()
|
||
|
|
kind = "json" if suffix == ".json" else ("text" if suffix in {".log", ".txt", ".py", ".sh", ".csv", ".md", ".jsonl"} else "file")
|
||
|
|
if kind != "file":
|
||
|
|
text = data.decode("utf-8", errors="replace")
|
||
|
|
data = SECRET.sub("[已隐藏疑似凭据]", text).encode("utf-8")
|
||
|
|
if self.redactor:
|
||
|
|
data = self.redactor(data)
|
||
|
|
identity = hashlib.sha256(str(path).encode()).hexdigest()[:24]
|
||
|
|
target = destination / identity
|
||
|
|
with target.open("xb") as handle:
|
||
|
|
handle.write(data)
|
||
|
|
items.append({"id": identity, "name": path.name, "size": len(data), "kind": kind})
|
||
|
|
total += len(data)
|
||
|
|
except OSError:
|
||
|
|
self.notices.append(f"未能归档:{path.name}")
|
||
|
|
if len(sources) > self.settings.max_run_files - 1:
|
||
|
|
self.notices.append("结果超过文件数上限:保留 63 个文件及运行摘要,未归档临时结果将释放")
|
||
|
|
return items
|