wenyi-cli 1.4.2__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.
- wenyi_cli/__init__.py +9 -0
- wenyi_cli/__main__.py +3 -0
- wenyi_cli/cli.py +45 -0
- wenyi_cli/commands/__init__.py +1 -0
- wenyi_cli/commands/bootstrap.py +108 -0
- wenyi_cli/commands/context.py +50 -0
- wenyi_cli/commands/glossary.py +104 -0
- wenyi_cli/commands/inspection.py +179 -0
- wenyi_cli/commands/machine_output.py +102 -0
- wenyi_cli/commands/presentation.py +139 -0
- wenyi_cli/commands/progress.py +92 -0
- wenyi_cli/commands/run_report.py +103 -0
- wenyi_cli/commands/validation.py +97 -0
- wenyi_cli/commands/workflows.py +393 -0
- wenyi_cli/main.py +5 -0
- wenyi_cli/model_commands.py +165 -0
- wenyi_cli-1.4.2.dist-info/METADATA +38 -0
- wenyi_cli-1.4.2.dist-info/RECORD +20 -0
- wenyi_cli-1.4.2.dist-info/WHEEL +4 -0
- wenyi_cli-1.4.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Display workflow summaries from supplied data without accessing run state."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from wenyi_core.timing import format_duration
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def print_timing(console: Console, timing: dict[str, Any] | None) -> None:
|
|
10
|
+
"""Show the last invocation and accumulated execution time, including failed runs."""
|
|
11
|
+
if timing is None:
|
|
12
|
+
return
|
|
13
|
+
last = timing["runs"][-1]
|
|
14
|
+
count = len(timing["runs"])
|
|
15
|
+
console.print(
|
|
16
|
+
f"Time: last run {format_duration(last['elapsed_seconds'])} ({last['status']}), "
|
|
17
|
+
f"cumulative {format_duration(timing['total_seconds'])} across {count} "
|
|
18
|
+
f"{'run' if count == 1 else 'runs'}.",
|
|
19
|
+
highlight=False,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cache_description(slot: dict[str, Any]) -> str:
|
|
24
|
+
"""Describe cache coverage without reinterpreting legacy misses as ordinary input."""
|
|
25
|
+
hit = slot.get("cache_hit_tokens", 0)
|
|
26
|
+
prompt = slot.get("prompt_tokens", 0)
|
|
27
|
+
known_read = hit or slot.get("cache_hit_reported_calls")
|
|
28
|
+
rate = f"{hit / prompt:.1%}" if prompt and known_read else "unknown"
|
|
29
|
+
write = slot.get("cache_write_tokens", 0)
|
|
30
|
+
ordinary = slot.get("uncached_tokens", 0)
|
|
31
|
+
unknown = slot.get("cache_unknown_tokens", max(0, prompt - hit - write - ordinary))
|
|
32
|
+
|
|
33
|
+
def counter(field: str, presence: str) -> str:
|
|
34
|
+
value = slot.get(field, 0)
|
|
35
|
+
return f"{value:,}" if value or slot.get(presence) else "unknown"
|
|
36
|
+
|
|
37
|
+
description = (
|
|
38
|
+
f"cache read / all input {rate}"
|
|
39
|
+
f" (read {f'{hit:,}' if known_read else 'unknown'}"
|
|
40
|
+
f" / write {counter('cache_write_tokens', 'cache_write_reported_calls')}"
|
|
41
|
+
f" / ordinary {counter('uncached_tokens', 'uncached_reported_calls')}"
|
|
42
|
+
f" / unknown {unknown:,} tok)"
|
|
43
|
+
)
|
|
44
|
+
if slot.get("cache_write_5m_reported_calls") or slot.get("cache_write_1h_reported_calls"):
|
|
45
|
+
description += (
|
|
46
|
+
f" [write TTL: 5m {counter('cache_write_5m_tokens', 'cache_write_5m_reported_calls')}"
|
|
47
|
+
f" / 1h {counter('cache_write_1h_tokens', 'cache_write_1h_reported_calls')} tok]"
|
|
48
|
+
)
|
|
49
|
+
return description
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_usage(console: Console, usage: dict[str, Any] | None) -> None:
|
|
53
|
+
"""Print cumulative usage with an all-input denominator and unknown coverage."""
|
|
54
|
+
usage = usage or {}
|
|
55
|
+
totals = usage.get("totals") or {}
|
|
56
|
+
if not totals.get("total_tokens"):
|
|
57
|
+
return
|
|
58
|
+
console.print(
|
|
59
|
+
f"Usage (book total): {totals['total_tokens']:,} tok"
|
|
60
|
+
f" (prompt {totals['prompt_tokens']:,} / completion {totals['completion_tokens']:,}), "
|
|
61
|
+
f"{_cache_description(totals)}",
|
|
62
|
+
markup=False,
|
|
63
|
+
)
|
|
64
|
+
for tier, v in sorted(usage.get("by_tier", {}).items()):
|
|
65
|
+
console.print(
|
|
66
|
+
f" · {tier}: {v['total_tokens']:,} tok, {v['calls']} calls, {_cache_description(v)}",
|
|
67
|
+
markup=False,
|
|
68
|
+
)
|
|
69
|
+
for stage, v in sorted(
|
|
70
|
+
(usage.get("by_stage") or {}).items(),
|
|
71
|
+
key=lambda item: -item[1]["total_tokens"],
|
|
72
|
+
):
|
|
73
|
+
console.print(
|
|
74
|
+
f" · Stage {stage}: {v['total_tokens']:,} tok"
|
|
75
|
+
f" (prompt {v['prompt_tokens']:,} / completion {v['completion_tokens']:,}), "
|
|
76
|
+
f"{v['calls']} calls, {_cache_description(v)}",
|
|
77
|
+
markup=False,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
for identity, value in sorted((usage.get("by_model") or {}).items()):
|
|
81
|
+
label = (usage.get("labels") or {}).get(identity, identity)
|
|
82
|
+
console.print(f" · Model {label}: {value['total_tokens']:,} tok, {value['calls']} calls")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def print_review_summary(console: Console, review_result: dict[str, Any], run_dir: str) -> None:
|
|
86
|
+
"""Show standalone Review completion and publication results."""
|
|
87
|
+
summary = review_result["summary"]
|
|
88
|
+
console.print(
|
|
89
|
+
f"[bold green]Whole-book agent review complete[/]: {review_result['termination']}, "
|
|
90
|
+
f"Remaining issues: {summary['issue_count']}, "
|
|
91
|
+
f"suggested changes: {summary['change_count']}."
|
|
92
|
+
)
|
|
93
|
+
autofix_result = review_result.get("autofix") or {}
|
|
94
|
+
if autofix_result.get("enabled"):
|
|
95
|
+
console.print(
|
|
96
|
+
f"Autofix: published {autofix_result.get('applied_segment_count', 0)} paragraphs, "
|
|
97
|
+
f"failed issues: {autofix_result.get('failed_issue_count', 0)}."
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
console.print(
|
|
101
|
+
"Review produced recommendations only; formal chapter translations are unchanged."
|
|
102
|
+
)
|
|
103
|
+
console.print(f"Review directory: {run_dir}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def print_review_details(
|
|
107
|
+
console: Console, review_result: dict[str, Any] | None, run_dir: str
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Show review details after the full translation workflow."""
|
|
110
|
+
review_result = review_result or {}
|
|
111
|
+
review_summary = review_result.get("summary") or {}
|
|
112
|
+
console.print(
|
|
113
|
+
f"Review result: {review_result.get('termination', 'unknown')}, "
|
|
114
|
+
f"issues: {review_summary.get('issue_count', 0)}, "
|
|
115
|
+
f"suggested changes: {review_summary.get('change_count', 0)}."
|
|
116
|
+
)
|
|
117
|
+
console.print(f"Review directory: {run_dir}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def print_preparation_summary(
|
|
121
|
+
console: Console, *, chapter_count: int, digest_count: int, has_synopsis: bool, run_dir: str
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Show saved preparation coverage and the next workflow command."""
|
|
124
|
+
console.print(
|
|
125
|
+
f"[bold green]Preparation complete[/]: Parsed {chapter_count} chapters, "
|
|
126
|
+
f"prescanned {digest_count}/{chapter_count} chapters, "
|
|
127
|
+
f"Whole-book synopsis{' generated' if has_synopsis else ' unavailable'}."
|
|
128
|
+
)
|
|
129
|
+
console.print(f"State directory: [bold]{run_dir}[/]")
|
|
130
|
+
console.print("Run translate with the same source file to continue the full translation.")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def print_translation_summary(
|
|
134
|
+
console: Console, *, chapters_done: int, chapters_total: int, terms: int
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Show complete book chapter and terminology counts."""
|
|
137
|
+
console.print(
|
|
138
|
+
f"[bold green]Complete[/]: {chapters_done}/{chapters_total} chapters, terms: {terms}."
|
|
139
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Render workflow stages without resetting the overall invocation clock."""
|
|
2
|
+
|
|
3
|
+
from rich.cells import cell_len, set_cell_size
|
|
4
|
+
from rich.progress import (
|
|
5
|
+
BarColumn,
|
|
6
|
+
MofNCompleteColumn,
|
|
7
|
+
Progress,
|
|
8
|
+
ProgressColumn,
|
|
9
|
+
SpinnerColumn,
|
|
10
|
+
Task,
|
|
11
|
+
TextColumn,
|
|
12
|
+
TimeElapsedColumn,
|
|
13
|
+
)
|
|
14
|
+
from rich.table import Column
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
from wenyi_core.timing import format_duration
|
|
17
|
+
|
|
18
|
+
_PROGRESS_DESCRIPTION_WIDTH = 28
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _short_progress_description(label: str) -> str:
|
|
22
|
+
"""Truncate long titles by terminal cell width and keep an ellipsis when clipped."""
|
|
23
|
+
if cell_len(label) <= _PROGRESS_DESCRIPTION_WIDTH:
|
|
24
|
+
return label
|
|
25
|
+
prefix = set_cell_size(label, _PROGRESS_DESCRIPTION_WIDTH - 1).rstrip()
|
|
26
|
+
return f"{prefix}…"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WorkflowElapsedColumn(TimeElapsedColumn):
|
|
30
|
+
"""Keep elapsed time advancing across stages, including completed-stage waits."""
|
|
31
|
+
|
|
32
|
+
def render(self, task: Task) -> Text:
|
|
33
|
+
started = task.fields.get("workflow_started", task.start_time)
|
|
34
|
+
elapsed = 0.0 if started is None else task.get_time() - started
|
|
35
|
+
return Text(format_duration(elapsed), style="progress.elapsed")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def progress_columns() -> tuple[ProgressColumn, ...]:
|
|
39
|
+
"""Build Rich columns that keep long stage names from hiding the bar."""
|
|
40
|
+
description_column = Column(
|
|
41
|
+
max_width=_PROGRESS_DESCRIPTION_WIDTH,
|
|
42
|
+
no_wrap=True,
|
|
43
|
+
overflow="ellipsis",
|
|
44
|
+
)
|
|
45
|
+
return (
|
|
46
|
+
SpinnerColumn(),
|
|
47
|
+
TextColumn(
|
|
48
|
+
"[progress.description]{task.description}",
|
|
49
|
+
table_column=description_column,
|
|
50
|
+
),
|
|
51
|
+
BarColumn(),
|
|
52
|
+
MofNCompleteColumn(),
|
|
53
|
+
WorkflowElapsedColumn(),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RichProgressBridge:
|
|
58
|
+
"""Map pipeline stage progress onto one Rich task."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, progress: Progress, initial_description: str) -> None:
|
|
61
|
+
self.progress = progress
|
|
62
|
+
self._started = progress.get_time()
|
|
63
|
+
self.task = progress.add_task(
|
|
64
|
+
_short_progress_description(initial_description),
|
|
65
|
+
total=None,
|
|
66
|
+
workflow_started=self._started,
|
|
67
|
+
)
|
|
68
|
+
self._stage: tuple[str, int | None] = (initial_description, None)
|
|
69
|
+
|
|
70
|
+
def __call__(self, done: int, total: int, label: str) -> None:
|
|
71
|
+
"""Refresh the current stage and counts without accumulating progress bars."""
|
|
72
|
+
stage = (label, total if total > 0 else None)
|
|
73
|
+
short = _short_progress_description(label)
|
|
74
|
+
if total > 0:
|
|
75
|
+
if stage != self._stage:
|
|
76
|
+
# A completed Rich task retains its finished time until reset.
|
|
77
|
+
self.progress.reset(self.task, total=total, completed=done, description=short)
|
|
78
|
+
self._stage = stage
|
|
79
|
+
self.progress.update(
|
|
80
|
+
self.task,
|
|
81
|
+
completed=done,
|
|
82
|
+
total=total,
|
|
83
|
+
description=short,
|
|
84
|
+
)
|
|
85
|
+
return
|
|
86
|
+
if stage == self._stage:
|
|
87
|
+
return
|
|
88
|
+
# Rich update(total=None) leaves the total unchanged. Recreate the task
|
|
89
|
+
# to restore indeterminate progress and clear the previous stage’s counts.
|
|
90
|
+
self.progress.remove_task(self.task)
|
|
91
|
+
self.task = self.progress.add_task(short, total=None, workflow_started=self._started)
|
|
92
|
+
self._stage = stage
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Aggregate one run's timing, usage and failure events into a single report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from wenyi_core.timing import load_timing
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_events(path: str) -> list[dict[str, Any]]:
|
|
13
|
+
"""Read the append-only log, skipping a torn final line.
|
|
14
|
+
|
|
15
|
+
Records are appended with ``ensure_ascii=False``, so an interrupted write can tear a
|
|
16
|
+
multi-byte character in half; strict text-mode decoding would then raise before any
|
|
17
|
+
record is read (audit Z-2/H-2). Read bytes and drop undecodable lines instead.
|
|
18
|
+
"""
|
|
19
|
+
if not os.path.isfile(path):
|
|
20
|
+
return []
|
|
21
|
+
events: list[dict[str, Any]] = []
|
|
22
|
+
with open(path, "rb") as handle:
|
|
23
|
+
raw = handle.read()
|
|
24
|
+
for line in raw.split(b"\n"):
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line:
|
|
27
|
+
continue
|
|
28
|
+
try:
|
|
29
|
+
text = line.decode("utf-8")
|
|
30
|
+
except UnicodeDecodeError:
|
|
31
|
+
# Torn multi-byte append: the earlier records are still valid.
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
events.append(json.loads(text))
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
continue
|
|
37
|
+
return events
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _usage_totals(usage: dict[str, Any] | None) -> dict[str, Any]:
|
|
41
|
+
"""Return the ledger totals; the ledger already sums them by tier."""
|
|
42
|
+
if not usage or not isinstance(usage, dict):
|
|
43
|
+
return {}
|
|
44
|
+
totals = usage.get("totals")
|
|
45
|
+
return dict(totals) if isinstance(totals, dict) else {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _crashed_step_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
49
|
+
"""Return ``run_steps_started`` events whose steps never finished.
|
|
50
|
+
|
|
51
|
+
CLI/Core pipelines never emit the backend runner's ``pipeline_error`` events; on the
|
|
52
|
+
CLI a failed run is a step that started but has no matching ``run_steps_finished``
|
|
53
|
+
(audit Z-2/H-1). Resume invocations restart the step list, so finished steps from
|
|
54
|
+
any invocation count as done.
|
|
55
|
+
"""
|
|
56
|
+
finished_steps: set[str] = set()
|
|
57
|
+
started_events: list[dict[str, Any]] = []
|
|
58
|
+
for event in events:
|
|
59
|
+
name = event.get("event")
|
|
60
|
+
steps = event.get("steps")
|
|
61
|
+
if not isinstance(steps, list):
|
|
62
|
+
continue
|
|
63
|
+
if name == "run_steps_started":
|
|
64
|
+
started_events.append(event)
|
|
65
|
+
elif name == "run_steps_finished":
|
|
66
|
+
finished_steps.update(step for step in steps if isinstance(step, str))
|
|
67
|
+
return [
|
|
68
|
+
event
|
|
69
|
+
for event in started_events
|
|
70
|
+
if not {step for step in event.get("steps") or [] if isinstance(step, str)}
|
|
71
|
+
<= finished_steps
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_report(store) -> dict[str, Any]:
|
|
76
|
+
"""Collect what a failed or slow run would need to explain itself."""
|
|
77
|
+
events = _read_events(store.event_log_path)
|
|
78
|
+
failures = [event for event in events if event.get("event") == "pipeline_error"]
|
|
79
|
+
failures.extend(_crashed_step_events(events))
|
|
80
|
+
retries = [event for event in events if str(event.get("event", "")).startswith("llm_retry")]
|
|
81
|
+
exhausted = [event for event in events if event.get("event") == "llm_retry_exhausted"]
|
|
82
|
+
paused = [event for event in events if event.get("event") == "task_paused"]
|
|
83
|
+
timing = load_timing(store.run_dir)
|
|
84
|
+
usage = store.load_usage()
|
|
85
|
+
|
|
86
|
+
last_failure = exhausted[-1] if exhausted else (failures[-1] if failures else None)
|
|
87
|
+
return {
|
|
88
|
+
"run_dir": store.run_dir,
|
|
89
|
+
"events": len(events),
|
|
90
|
+
"failures": len(failures),
|
|
91
|
+
"retries": len(retries),
|
|
92
|
+
"pauses": len(paused),
|
|
93
|
+
"last_failure": {
|
|
94
|
+
"error_type": last_failure.get("error_type"),
|
|
95
|
+
"error": last_failure.get("error"),
|
|
96
|
+
"run_id": last_failure.get("run_id"),
|
|
97
|
+
"traceback": last_failure.get("traceback"),
|
|
98
|
+
}
|
|
99
|
+
if last_failure
|
|
100
|
+
else None,
|
|
101
|
+
"timing": timing,
|
|
102
|
+
"usage": _usage_totals(usage),
|
|
103
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Shared input, backend, format and existing-state validation for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from wenyi_core.i18n.languages import validate_run_languages
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from wenyi_core.config import Config
|
|
14
|
+
from wenyi_core.storage.run_store import RunStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_document(*args, **kwargs):
|
|
18
|
+
"""Defer the ingest stack while keeping a patchable module attribute.
|
|
19
|
+
|
|
20
|
+
Callers and tests have always reached this name through the module, so the
|
|
21
|
+
import stays behind the call rather than moving out of the namespace.
|
|
22
|
+
"""
|
|
23
|
+
from wenyi_core.ingest.segmenter import load_document as parse
|
|
24
|
+
|
|
25
|
+
return parse(*args, **kwargs)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def require_input_file(input_path: str, *, console: Console | None = None) -> None:
|
|
29
|
+
"""Require an input file, otherwise report the error and exit with status 1."""
|
|
30
|
+
console = console or Console()
|
|
31
|
+
if not os.path.isfile(input_path):
|
|
32
|
+
console.print(f"[red]Input file does not exist: {input_path}[/]")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_output_format(fmt: str, *, console: Console | None = None) -> str:
|
|
37
|
+
"""Normalize and validate the requested output format."""
|
|
38
|
+
console = console or Console()
|
|
39
|
+
normalized = fmt.strip().lower()
|
|
40
|
+
allowed = {"epub", "txt", "html", "markdown", "pdf", "docx"}
|
|
41
|
+
if normalized not in allowed:
|
|
42
|
+
console.print(
|
|
43
|
+
f"[red]Unsupported output format: {fmt} (choose epub / txt / html / markdown / pdf / docx)[/]"
|
|
44
|
+
)
|
|
45
|
+
raise typer.Exit(2)
|
|
46
|
+
return normalized
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_output_format(
|
|
50
|
+
input_path: str, fmt: str | None, *, console: Console | None = None
|
|
51
|
+
) -> str | None:
|
|
52
|
+
"""Defer PDF defaults to saved backend metadata; keep DOCX and EPUB defaults."""
|
|
53
|
+
console = console or Console()
|
|
54
|
+
if fmt is not None and str(fmt).strip():
|
|
55
|
+
return validate_output_format(str(fmt), console=console)
|
|
56
|
+
if os.path.splitext(input_path)[1].lower() == ".pdf":
|
|
57
|
+
return None
|
|
58
|
+
if os.path.splitext(input_path)[1].lower() == ".docx":
|
|
59
|
+
return "docx"
|
|
60
|
+
return "epub"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def runstore_for(config: Config, input_path: str, *, console: Console | None = None) -> RunStore:
|
|
64
|
+
"""Resolve the title and locate existing state without creating a directory."""
|
|
65
|
+
# Deferred: reading a title pulls the ingest stack (bs4/lxml) in; --help
|
|
66
|
+
# must not pay for that.
|
|
67
|
+
from wenyi_core.storage.run_store import RunStore, translation_run_dir
|
|
68
|
+
|
|
69
|
+
console = console or Console()
|
|
70
|
+
require_input_file(input_path, console=console)
|
|
71
|
+
if os.path.splitext(input_path)[1].lower() == ".pdf":
|
|
72
|
+
title = os.path.splitext(os.path.basename(input_path))[0]
|
|
73
|
+
run_dir = translation_run_dir(config.state_dir, title, config.target_lang)
|
|
74
|
+
store = RunStore(run_dir, create=False)
|
|
75
|
+
else:
|
|
76
|
+
doc = load_document(input_path, config.source_lang, config.target_lang)
|
|
77
|
+
run_dir = translation_run_dir(config.state_dir, doc.title, config.target_lang)
|
|
78
|
+
store = RunStore(run_dir, create=False)
|
|
79
|
+
if store.exists():
|
|
80
|
+
with store.lock():
|
|
81
|
+
validate_run_languages(store.load_manifest(), config.source_lang, config.target_lang)
|
|
82
|
+
store.ensure_source_identity(input_path)
|
|
83
|
+
return store
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def runstore_for_cli(
|
|
87
|
+
config: Config, input_path: str, *, console: Console | None = None
|
|
88
|
+
) -> RunStore:
|
|
89
|
+
# Deferred with the ingest stack; the base error class drags it in.
|
|
90
|
+
from wenyi_core.models.errors import IngestError
|
|
91
|
+
|
|
92
|
+
console = console or Console()
|
|
93
|
+
try:
|
|
94
|
+
return runstore_for(config, input_path, console=console)
|
|
95
|
+
except (IngestError, OSError, ValueError) as error:
|
|
96
|
+
console.print(f"[red]Error: {error}[/]")
|
|
97
|
+
raise typer.Exit(1) from None
|