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 ADDED
@@ -0,0 +1,9 @@
1
+ """Wenyi command-line package for local workflows.
2
+
3
+ ``wenyi_cli.cli`` assembles commands that use the shared core and file storage.
4
+ The package exposes the ``wenyi`` console script and a Python module entry point.
5
+ """
6
+
7
+ from .main import app, main
8
+
9
+ __all__ = ["app", "main"]
wenyi_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ main()
wenyi_cli/cli.py ADDED
@@ -0,0 +1,45 @@
1
+ """Construct the command-line application and expose the installed entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from wenyi_core.observability import configure_logging
8
+
9
+ from .commands.bootstrap import configure_windows_console, initializing_group, register_bootstrap
10
+ from .commands.context import current_context
11
+ from .commands.glossary import register_glossary_commands
12
+ from .commands.inspection import register_inspection_commands
13
+ from .commands.workflows import register_workflows_commands
14
+ from .model_commands import register_model_commands
15
+
16
+
17
+ def create_app(*, console: Console | None = None) -> typer.Typer:
18
+ """Build an independent application with fresh context on every invocation."""
19
+ configure_windows_console()
20
+ configure_logging(service="wenyi-cli")
21
+ console = console or Console()
22
+ application = typer.Typer(
23
+ cls=initializing_group(console),
24
+ add_completion=False,
25
+ no_args_is_help=True,
26
+ help="Multilingual translation workflows for long-form fiction.",
27
+ )
28
+ register_bootstrap(application)
29
+ register_model_commands(application, lambda: current_context().load_config(), console)
30
+ register_workflows_commands(application, current_context)
31
+ register_inspection_commands(application, current_context)
32
+ register_glossary_commands(application, current_context)
33
+ return application
34
+
35
+
36
+ app = create_app()
37
+
38
+
39
+ def main() -> None:
40
+ """Start the Typer command-line application."""
41
+ app()
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
@@ -0,0 +1 @@
1
+ """Command registration and terminal presentation helpers."""
@@ -0,0 +1,108 @@
1
+ """Early configuration creation, platform console setup and root CLI options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from importlib.metadata import version as package_version
9
+ from typing import Any
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from typer.core import TyperGroup
14
+ from wenyi_core.config import Config
15
+
16
+ from .context import CommandContext, current_context
17
+
18
+
19
+ def configure_windows_console(
20
+ streams: tuple[object, ...] | None = None,
21
+ *,
22
+ is_windows: bool | None = None,
23
+ ) -> None:
24
+ """Enable Unicode output on Windows, including PyInstaller one-file executables."""
25
+ if is_windows is None:
26
+ is_windows = os.name == "nt"
27
+ if not is_windows:
28
+ return
29
+ for stream in streams or (sys.stdout, sys.stderr):
30
+ reconfigure = getattr(stream, "reconfigure", None)
31
+ if callable(reconfigure):
32
+ try:
33
+ reconfigure(encoding="utf-8", errors="replace")
34
+ except (OSError, ValueError):
35
+ pass
36
+
37
+
38
+ def config_path_from_args(args: Sequence[str]) -> str:
39
+ """Locate the config before Click parses arguments, including help and early exits."""
40
+ for index, arg in enumerate(args):
41
+ if arg in {"--config", "-c"}:
42
+ if index + 1 < len(args):
43
+ return args[index + 1]
44
+ break
45
+ if arg.startswith("--config="):
46
+ return arg.partition("=")[2]
47
+ if arg.startswith("-c") and len(arg) > 2:
48
+ return arg[2:]
49
+ return "config.yaml"
50
+
51
+
52
+ def initializing_group(console: Console) -> type[TyperGroup]:
53
+ """Bind application presentation while allocating invocation data anew in main."""
54
+
55
+ class ConfigInitializingGroup(TyperGroup):
56
+ """Check the default configuration before Click dispatches or exits early."""
57
+
58
+ def main(
59
+ self,
60
+ args: Sequence[str] | None = None,
61
+ *main_args: Any,
62
+ **main_kwargs: Any,
63
+ ) -> Any:
64
+ """Locate and create a missing default config before Click parses the command."""
65
+ cli_args = list(args) if args is not None else sys.argv[1:]
66
+ config_path = config_path_from_args(cli_args)
67
+ main_kwargs["obj"] = CommandContext(
68
+ config_path, any(arg in {"--help", "-h"} for arg in cli_args), console
69
+ )
70
+ Config.create_default_file(config_path)
71
+ from wenyi_core.llm.limits import RequestStopped
72
+
73
+ try:
74
+ return super().main(*main_args, args=args, **main_kwargs)
75
+ except RequestStopped as error:
76
+ typer.echo(f"Stopped: {error}", err=True)
77
+ raise SystemExit(1) from None
78
+
79
+ return ConfigInitializingGroup
80
+
81
+
82
+ def register_bootstrap(app: typer.Typer) -> None:
83
+ def _version_callback(value: bool) -> None:
84
+ """Print the installed package version derived from Git tags and exit."""
85
+ if value:
86
+ current_context().console.print(package_version("wenyi-cli"))
87
+ raise typer.Exit()
88
+
89
+ @app.callback()
90
+ def _root(
91
+ ctx: typer.Context,
92
+ config: str = typer.Option(
93
+ "config.yaml",
94
+ "--config",
95
+ "-c",
96
+ help="Configuration path; created automatically if missing",
97
+ ),
98
+ version: bool = typer.Option(
99
+ False,
100
+ "--version",
101
+ callback=_version_callback,
102
+ is_eager=True,
103
+ help="Show the version and exit",
104
+ ),
105
+ ):
106
+ """Record the config path; workflow commands validate credentials after their overrides."""
107
+ del version
108
+ current_context().config_path = config
@@ -0,0 +1,50 @@
1
+ """Configuration and preflight policy scoped to one Click invocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ import typer
9
+ import yaml
10
+ from rich.console import Console
11
+ from typer.main import get_current_context
12
+ from wenyi_core.config import Config
13
+
14
+
15
+ @dataclass
16
+ class CommandContext:
17
+ config_path: str
18
+ skip_api_check: bool
19
+ console: Console
20
+
21
+ def load_config(self) -> Config:
22
+ """Load the configuration selected for this invocation."""
23
+ try:
24
+ return Config.load(self.config_path)
25
+ except (OSError, ValueError, yaml.YAMLError) as error:
26
+ self.console.print(f"[red]Configuration error: {error}[/]")
27
+ raise typer.Exit(1) from None
28
+
29
+ def validate_api_configuration(self, config: Config, workflow: str) -> None:
30
+ """Validate only the connections reachable after command-line overrides."""
31
+ from wenyi_core.llm.factory import build_client
32
+ from wenyi_core.llm.operations import configured_operations
33
+
34
+ if not self.skip_api_check:
35
+ try:
36
+ build_client(config).validate_credentials(configured_operations(config, workflow))
37
+ except (ValueError, RuntimeError) as error:
38
+ self.console.print(f"[red]Error: {error}[/]")
39
+ raise typer.Exit(1) from None
40
+
41
+
42
+ ContextAccessor = Callable[[], CommandContext]
43
+
44
+
45
+ def current_context() -> CommandContext:
46
+ """Return the root invocation data, including during eager option callbacks."""
47
+ context = get_current_context().find_root().obj
48
+ if not isinstance(context, CommandContext):
49
+ raise RuntimeError("The CLI invocation has no CommandContext")
50
+ return context
@@ -0,0 +1,104 @@
1
+ """Register glossary commands with an invocation context accessor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.table import Table
7
+
8
+ from .context import ContextAccessor
9
+ from .validation import (
10
+ runstore_for_cli,
11
+ )
12
+
13
+
14
+ def register_glossary_commands(app: typer.Typer, context: ContextAccessor) -> None:
15
+ glossary_app = typer.Typer(
16
+ add_completion=False,
17
+ no_args_is_help=True,
18
+ help="Inspect glossary entries, check conflicts and resolve translations.",
19
+ )
20
+ app.add_typer(glossary_app, name="glossary", rich_help_panel="Glossary")
21
+
22
+ @glossary_app.command("list")
23
+ def glossary_list(
24
+ input: str = typer.Argument(..., help="Source file with existing translation state"),
25
+ ) -> None:
26
+ """List established glossary translations and their status."""
27
+ console = context().console
28
+ from wenyi_core.glossary.store import GlossaryStore
29
+
30
+ config = context().load_config()
31
+ store = runstore_for_cli(config, input, console=console)
32
+ if not store.exists():
33
+ console.print("[yellow]No progress found. Run prepare or translate first.[/]")
34
+ raise typer.Exit(1)
35
+ g = GlossaryStore(store.glossary_path)
36
+ try:
37
+ table = Table("Source", "Translation", "Type", "Status")
38
+ # all_terms() preserves insertion order for prompt prefix caching.
39
+ # Sort only this display by type/source; preserve the shared data order.
40
+ for term in sorted(g.all_terms(), key=lambda t: (t.type, t.source)):
41
+ table.add_row(
42
+ term.source,
43
+ term.target,
44
+ f"{term.type}{'/' + term.gender if term.gender else ''}",
45
+ term.status,
46
+ )
47
+ console.print(table)
48
+ finally:
49
+ g.close()
50
+
51
+ @glossary_app.command("conflicts")
52
+ def glossary_conflicts(
53
+ input: str = typer.Argument(..., help="Source file with existing translation state"),
54
+ ) -> None:
55
+ """List unresolved translation conflicts discovered during extraction."""
56
+ console = context().console
57
+ from wenyi_core.glossary.store import GlossaryStore
58
+
59
+ config = context().load_config()
60
+ store = runstore_for_cli(config, input, console=console)
61
+ if not store.exists():
62
+ console.print("[yellow]No progress found. Run translate or prepare first.[/]")
63
+ raise typer.Exit(1)
64
+ glossary = GlossaryStore(store.glossary_path)
65
+ try:
66
+ conflicts = glossary.open_conflicts()
67
+ if not conflicts:
68
+ console.print("No unresolved glossary conflicts.")
69
+ return
70
+ for conflict in conflicts:
71
+ console.print(
72
+ f" {conflict['source']}: existing “{conflict['existing_target']}” vs "
73
+ f"proposed “{conflict['proposed_target']}”"
74
+ f"(chapter {conflict['chapter']})"
75
+ )
76
+ finally:
77
+ glossary.close()
78
+
79
+ @glossary_app.command("resolve")
80
+ def glossary_resolve(
81
+ input: str = typer.Argument(..., help="Source file with existing translation state"),
82
+ source: str = typer.Argument(..., help="Source term to resolve"),
83
+ target: str = typer.Argument(
84
+ ..., help="Target translation to use consistently from now on"
85
+ ),
86
+ ) -> None:
87
+ """Resolve an existing term to a chosen translation and close its conflicts."""
88
+ console = context().console
89
+ from wenyi_core.glossary import resolver
90
+ from wenyi_core.glossary.store import GlossaryStore
91
+
92
+ config = context().load_config()
93
+ store = runstore_for_cli(config, input, console=console)
94
+ if not store.exists():
95
+ console.print("[yellow]No progress found. Run translate or prepare first.[/]")
96
+ raise typer.Exit(1)
97
+ glossary = GlossaryStore(store.glossary_path)
98
+ try:
99
+ if not resolver.resolve(glossary, source, target):
100
+ console.print(f"[red]Term does not exist: {source}[/]")
101
+ raise typer.Exit(1)
102
+ console.print(f"Resolved {source} → {target}")
103
+ finally:
104
+ glossary.close()
@@ -0,0 +1,179 @@
1
+ """Register inspection commands with an invocation context accessor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.table import Table
7
+ from wenyi_core.timing import load_timing
8
+
9
+ from wenyi_cli.commands import presentation
10
+
11
+ from .context import ContextAccessor
12
+ from .run_report import build_report
13
+ from .validation import (
14
+ require_input_file,
15
+ resolve_output_format,
16
+ runstore_for_cli,
17
+ )
18
+
19
+
20
+ def register_inspection_commands(app: typer.Typer, context: ContextAccessor) -> None:
21
+ @app.command(rich_help_panel="State and output")
22
+ def language_policy(
23
+ source: str | None = typer.Option(
24
+ None, "--source", help="Resolved source language for preview"
25
+ ),
26
+ fmt: str | None = typer.Option(None, "--format", help="Actual export format"),
27
+ backend: str = typer.Option("native", "--backend", help="native / babeldoc"),
28
+ ) -> None:
29
+ """Preview built-in language operations and phase fingerprints without model calls."""
30
+ from wenyi_core.i18n.languages import require_language
31
+
32
+ try:
33
+ config = context().load_config()
34
+ if source is not None:
35
+ config.source_lang = require_language(source, allow_auto=True)
36
+ preview = config.language_policy_preview(
37
+ format=fmt or "epub",
38
+ backend=backend,
39
+ )
40
+ except ValueError as error:
41
+ context().console.print(f"[red]Error: {error}[/]")
42
+ raise typer.Exit(1) from None
43
+ context().console.print_json(data=preview)
44
+
45
+ @app.command(rich_help_panel="State and output")
46
+ def languages() -> None:
47
+ """List built-in translation languages (experimental) without calling a model."""
48
+ console = context().console
49
+ from wenyi_core.i18n.languages import profile, supported_languages
50
+ from wenyi_core.i18n.prompts import render
51
+
52
+ table = Table("Code", "Language")
53
+ for code in supported_languages():
54
+ entry = profile(code)
55
+ render("translator_system", src="auto", tgt=code)
56
+ table.add_row(code, entry["english_name"])
57
+ console.print(table)
58
+ console.print("Set language.source and language.target; source also accepts auto.")
59
+
60
+ @app.command(rich_help_panel="State and output")
61
+ def status(
62
+ input: str = typer.Argument(..., help="Source file with existing translation state"),
63
+ ) -> None:
64
+ """Show chapter progress and glossary statistics."""
65
+ console = context().console
66
+ from wenyi_core.glossary.store import GlossaryStore
67
+
68
+ config = context().load_config()
69
+ store = runstore_for_cli(config, input, console=console)
70
+ if not store.exists():
71
+ console.print("[yellow]No progress found. Run prepare or translate first.[/]")
72
+ raise typer.Exit(1)
73
+ m = store.load_manifest()
74
+ console.print(f"“{m['title']}”({m['fmt']}) {m['source_lang']}→{m['target_lang']}")
75
+ table = Table("", "#", "Chapter", "Translation")
76
+ for c in m["chapters"]:
77
+ mark = "✓" if c["status"] == "done" else "·"
78
+ table.add_row(
79
+ mark,
80
+ str(c["index"]),
81
+ c["title"],
82
+ c["status"],
83
+ )
84
+ console.print(table)
85
+ g = GlossaryStore(store.glossary_path)
86
+ console.print("Glossary: ", g.stats())
87
+ g.close()
88
+ presentation.print_timing(console, load_timing(store.run_dir))
89
+
90
+ @app.command(rich_help_panel="State and output")
91
+ def inspect_run(
92
+ input: str = typer.Argument(..., help="Source file with existing translation state"),
93
+ verbose: bool = typer.Option(False, "--verbose", help="Include the last failure traceback"),
94
+ ) -> None:
95
+ """Summarize one run: failures, retries, timing and token usage, read-only."""
96
+ console = context().console
97
+ config = context().load_config()
98
+ store = runstore_for_cli(config, input, console=console)
99
+ if not store.exists():
100
+ console.print("[yellow]No progress found. Run prepare or translate first.[/]")
101
+ raise typer.Exit(1)
102
+
103
+ report = build_report(store)
104
+ console.print(f"Run: [bold]{report['run_dir']}[/]")
105
+ console.print(
106
+ f" Events {report['events']} Failures {report['failures']} "
107
+ f"Retries {report['retries']} Pauses {report['pauses']}"
108
+ )
109
+ failure = report["last_failure"]
110
+ if failure:
111
+ # The type is what makes a failure diagnosable; the text alone is not.
112
+ console.print(
113
+ f" Last failure: [red]{failure['error_type'] or 'unknown'}[/] {failure['error'] or ''}"
114
+ )
115
+ if verbose and failure.get("traceback"):
116
+ console.print(failure["traceback"])
117
+ else:
118
+ console.print(" Last failure: none")
119
+
120
+ usage = report["usage"]
121
+ if usage:
122
+ console.print(
123
+ " Tokens: "
124
+ f"in {usage.get('prompt_tokens', 0)} out {usage.get('completion_tokens', 0)} "
125
+ f"cache read {usage.get('cache_hit_tokens', 0)}"
126
+ )
127
+ presentation.print_timing(console, report["timing"])
128
+
129
+ @app.command(rich_help_panel="State and output")
130
+ def assemble(
131
+ input: str = typer.Argument(..., help="Source file with a complete or partial translation"),
132
+ out: str | None = typer.Option(
133
+ None,
134
+ "--out",
135
+ help="Monolingual output path; defaults to the output directory beside the source",
136
+ ),
137
+ fmt: str | None = typer.Option(
138
+ None,
139
+ "--format",
140
+ help="Output format: epub / txt / html / markdown / docx / pdf (BabelDOC only); default: pdf for BabelDOC PDF state, docx for .docx input, epub otherwise",
141
+ ),
142
+ mono: bool | None = typer.Option(
143
+ None,
144
+ "--mono/--no-mono",
145
+ help="Override output.mono to enable or disable monolingual output",
146
+ ),
147
+ bilingual: bool | None = typer.Option(
148
+ None,
149
+ "--bilingual/--no-bilingual",
150
+ help="Override output.bilingual to enable or disable bilingual output",
151
+ ),
152
+ ):
153
+ """Export translations from existing state without calling a model."""
154
+ from wenyi_core.models.errors import IngestError
155
+
156
+ console = context().console
157
+ from wenyi_core.llm.providers.fake import FakeClient
158
+ from wenyi_core.pipeline.orchestrator import Orchestrator
159
+
160
+ config = context().load_config()
161
+ require_input_file(input, console=console)
162
+ fmt = resolve_output_format(input, fmt, console=console)
163
+ if mono is not None:
164
+ config.output.mono = mono
165
+ if bilingual is not None:
166
+ config.output.bilingual = bilingual
167
+ try:
168
+ result = Orchestrator(config, client=FakeClient()).run_assemble(
169
+ input,
170
+ out_format=fmt,
171
+ out_path=out,
172
+ )
173
+ except (IngestError, OSError, ValueError) as error:
174
+ console.print(f"[red]Error: {error}[/]")
175
+ raise typer.Exit(1) from None
176
+ paths = result["outputs"]
177
+ presentation.print_timing(console, load_timing(result["store"].run_dir))
178
+ for path in paths:
179
+ console.print(f"Translation written: [bold]{path}[/]")
@@ -0,0 +1,102 @@
1
+ """Atomic, versioned CLI receipts for external process supervisors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from uuid import uuid4
10
+
11
+ from wenyi_core.atomicio import publish_bytes
12
+
13
+
14
+ def file_digest(path: Path) -> str:
15
+ digest = hashlib.sha256()
16
+ with path.open("rb") as handle:
17
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
18
+ digest.update(block)
19
+ return digest.hexdigest()
20
+
21
+
22
+ class MachineOutput:
23
+ """Publish progress and receipts without exposing configuration or model responses."""
24
+
25
+ def __init__(
26
+ self,
27
+ progress_path: str | None,
28
+ result_path: str | None,
29
+ *,
30
+ source: str,
31
+ config: str,
32
+ output: str | None,
33
+ ) -> None:
34
+ self.progress_path = Path(progress_path).resolve() if progress_path else None
35
+ self.result_path = Path(result_path).resolve() if result_path else None
36
+ self.source = Path(source).resolve()
37
+ self.run_id = uuid4().hex
38
+ self.completed = False
39
+ paths = [path for path in (self.progress_path, self.result_path) if path is not None]
40
+ protected = {self.source, Path(config).resolve()}
41
+ if output:
42
+ protected.add(Path(output).resolve())
43
+ if len(set(paths)) != len(paths) or protected.intersection(paths):
44
+ raise ValueError("Machine output paths must be distinct from source, config and output")
45
+
46
+ @property
47
+ def enabled(self) -> bool:
48
+ return self.progress_path is not None or self.result_path is not None
49
+
50
+ def _write(self, path: Path | None, **data) -> None:
51
+ if path is None:
52
+ return
53
+ payload = {
54
+ "protocol_version": 1,
55
+ "run_id": self.run_id,
56
+ "updated_at": datetime.now(timezone.utc).isoformat(),
57
+ **data,
58
+ }
59
+ publish_bytes(path, json.dumps(payload, ensure_ascii=False).encode("utf-8"))
60
+
61
+ def start(self) -> None:
62
+ # Replace an old receipt before preflight so it cannot masquerade as this run.
63
+ self._write(self.result_path, status="running", outputs=[])
64
+ self(0, 0, "Starting Wenyi")
65
+
66
+ def __call__(self, done: int, total: int, label: str) -> None:
67
+ self._write(
68
+ self.progress_path,
69
+ status="running",
70
+ done=done,
71
+ total=total if total > 0 else None,
72
+ label=label,
73
+ )
74
+
75
+ def success(self, paths: list[str], state_dir: str) -> None:
76
+ if not self.enabled:
77
+ self.completed = True
78
+ return
79
+ outputs = []
80
+ for name in paths:
81
+ path = Path(name).resolve()
82
+ outputs.append(
83
+ {"path": str(path), "size": path.stat().st_size, "sha256": file_digest(path)}
84
+ )
85
+ self._write(
86
+ self.result_path,
87
+ status="success",
88
+ source_sha256=file_digest(self.source),
89
+ state_dir=str(Path(state_dir).resolve()),
90
+ outputs=outputs,
91
+ )
92
+ self._write(self.progress_path, status="success", done=1, total=1, label="Completed")
93
+ self.completed = True
94
+
95
+ def failed(self) -> None:
96
+ self._write(
97
+ self.result_path,
98
+ status="failed",
99
+ outputs=[],
100
+ error={"code": "translation_failed", "message": "See the Wenyi log for details."},
101
+ )
102
+ self._write(self.progress_path, status="failed", done=0, total=None, label="Stopped")