moxtral 0.4.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.
moxtral/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("moxtral")
7
+ except PackageNotFoundError: # e.g. vendored source tree without installation
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ __all__ = ["__version__"]
moxtral/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from moxtral.cli.main import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1 @@
1
+ """Command-line interface for moxtral."""
moxtral/cli/agent.py ADDED
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import resources
4
+ from typing import TYPE_CHECKING
5
+
6
+ import click
7
+
8
+ from moxtral.formatters import serialize_json
9
+ from moxtral.schema import record_schema
10
+
11
+ if TYPE_CHECKING:
12
+ from moxtral.cli.main import AppContext
13
+
14
+
15
+ @click.command()
16
+ @click.option(
17
+ "--schema",
18
+ "show_schema",
19
+ is_flag=True,
20
+ help="Print the JSON Schema describing --json output records.",
21
+ )
22
+ @click.pass_obj
23
+ def agent(context: AppContext, show_schema: bool) -> None:
24
+ """Print agent-oriented usage documentation for this CLI."""
25
+ if show_schema:
26
+ context.consoles.write_stdout(serialize_json(record_schema()))
27
+ return
28
+ guide = (
29
+ resources.files("moxtral")
30
+ .joinpath("data/agent_guide.md")
31
+ .read_text(encoding="utf-8")
32
+ )
33
+ context.consoles.write_stdout(guide)
moxtral/cli/common.py ADDED
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Protocol, cast
8
+
9
+ import click
10
+
11
+ from moxtral.config import ConfigStore
12
+ from moxtral.console import sanitize_terminal_text
13
+ from moxtral.errors import (
14
+ ConfigError,
15
+ format_debug_exception,
16
+ redact,
17
+ translate_exception,
18
+ )
19
+ from moxtral.models import ApiResult, InputSource, JSONMapping, JSONValue
20
+
21
+
22
+ def positive_days(
23
+ context: click.Context,
24
+ parameter: click.Parameter,
25
+ value: float,
26
+ ) -> float:
27
+ """Validate a look-back window expressed as a positive, finite number of days."""
28
+ if not math.isfinite(value) or value <= 0:
29
+ raise click.BadParameter(
30
+ "must be a positive number of days",
31
+ ctx=context,
32
+ param=parameter,
33
+ )
34
+ return value
35
+
36
+
37
+ def nonnegative_integer(
38
+ context: click.Context,
39
+ parameter: click.Parameter,
40
+ value: int | None,
41
+ ) -> int | None:
42
+ """Validate an optional nonnegative integer click option."""
43
+ if value is not None and value < 0:
44
+ raise click.BadParameter(
45
+ "must be a nonnegative integer",
46
+ ctx=context,
47
+ param=parameter,
48
+ )
49
+ return value
50
+
51
+
52
+ class CommandConsoles(Protocol):
53
+ def write_stdout(self, payload: str) -> None: ...
54
+
55
+ def write_stderr(self, payload: str) -> None: ...
56
+
57
+
58
+ class CommandContext(Protocol):
59
+ @property
60
+ def config_path(self) -> Path: ...
61
+
62
+ @property
63
+ def debug(self) -> bool: ...
64
+
65
+ @property
66
+ def consoles(self) -> CommandConsoles: ...
67
+
68
+
69
+ def safe_terminal_text(text: str, secrets: tuple[str, ...]) -> str:
70
+ """Sanitize terminal controls and redact every resulting secret variant."""
71
+ safe_text = sanitize_terminal_text(text)
72
+ secret_variants: list[str] = []
73
+ for secret in secrets:
74
+ if not secret.strip():
75
+ continue
76
+ secret_variants.append(secret)
77
+ secret_variants.append(sanitize_terminal_text(secret))
78
+ return redact(safe_text, secret_variants)
79
+
80
+
81
+ def candidate_secrets(context: CommandContext) -> tuple[str, ...]:
82
+ """Collect potential keys without allowing config errors to mask input errors."""
83
+ secrets: list[str] = []
84
+ environment_key = os.environ.get("MISTRAL_API_KEY")
85
+ if environment_key is not None and environment_key.strip():
86
+ secrets.append(environment_key)
87
+
88
+ try:
89
+ configured_key = ConfigStore(context.config_path, environ={}).load().api_key
90
+ except ConfigError:
91
+ configured_key = None
92
+ if configured_key is not None:
93
+ secrets.append(configured_key)
94
+ return tuple(dict.fromkeys(secrets))
95
+
96
+
97
+ def extend_secrets(secrets: tuple[str, ...], secret: str) -> tuple[str, ...]:
98
+ """Append a newly resolved secret while preserving order and uniqueness."""
99
+ if secret in secrets:
100
+ return secrets
101
+ return (*secrets, secret)
102
+
103
+
104
+ def _redact_json_value(
105
+ value: JSONValue,
106
+ secrets: tuple[str, ...],
107
+ ) -> JSONValue:
108
+ if isinstance(value, str):
109
+ return redact(value, secrets)
110
+ if isinstance(value, list):
111
+ return [_redact_json_value(item, secrets) for item in value]
112
+ if isinstance(value, Mapping):
113
+ return _redact_json_mapping(cast(JSONMapping, value), secrets)
114
+ return value
115
+
116
+
117
+ def _redact_json_mapping(
118
+ mapping: JSONMapping,
119
+ secrets: tuple[str, ...],
120
+ ) -> dict[str, JSONValue]:
121
+ redacted_mapping: dict[str, JSONValue] = {}
122
+ for key, value in mapping.items():
123
+ safe_key = redact(key, secrets)
124
+ unique_key = safe_key
125
+ suffix = 1
126
+ while unique_key in redacted_mapping:
127
+ unique_key = f"{safe_key}#{suffix}"
128
+ suffix += 1
129
+ redacted_mapping[unique_key] = _redact_json_value(value, secrets)
130
+ return redacted_mapping
131
+
132
+
133
+ def _redact_source(
134
+ source: InputSource,
135
+ secrets: tuple[str, ...],
136
+ ) -> InputSource:
137
+ safe_path = None if source.path is None else Path(redact(str(source.path), secrets))
138
+ return InputSource(
139
+ kind=source.kind,
140
+ value=redact(source.value, secrets),
141
+ filename=redact(source.filename, secrets),
142
+ path=safe_path,
143
+ ocr_kind=source.ocr_kind,
144
+ )
145
+
146
+
147
+ def redact_result(
148
+ result: ApiResult,
149
+ secrets: tuple[str, ...],
150
+ ) -> ApiResult:
151
+ """Return an API result with secrets recursively removed."""
152
+ return ApiResult(
153
+ operation=result.operation,
154
+ source=_redact_source(result.source, secrets),
155
+ request_metadata=_redact_json_mapping(result.request_metadata, secrets),
156
+ response=_redact_json_mapping(result.response, secrets),
157
+ created_at=result.created_at,
158
+ )
159
+
160
+
161
+ def report_error(
162
+ context: CommandContext,
163
+ error: Exception,
164
+ *,
165
+ secrets: tuple[str, ...],
166
+ setup_debug_context: str,
167
+ source_debug_prefix: str,
168
+ source: str | None = None,
169
+ ) -> None:
170
+ """Translate and safely report a setup or per-source command error."""
171
+ translated = translate_exception(error)
172
+ line = (
173
+ f"Setup error: {translated}\n"
174
+ if source is None
175
+ else f"{source}: {translated}\n"
176
+ )
177
+ debug_context = (
178
+ setup_debug_context if source is None else f"{source_debug_prefix}: {source}"
179
+ )
180
+ context.consoles.write_stderr(safe_terminal_text(line, secrets))
181
+ if context.debug:
182
+ write_debug_exception(
183
+ context,
184
+ error,
185
+ secrets=secrets,
186
+ debug_context=debug_context,
187
+ )
188
+
189
+
190
+ def write_debug_exception(
191
+ context: CommandContext,
192
+ error: Exception,
193
+ *,
194
+ secrets: tuple[str, ...],
195
+ debug_context: str,
196
+ ) -> None:
197
+ """Write traceback diagnostics through the shared safe terminal boundary."""
198
+ formatted = format_debug_exception(
199
+ error,
200
+ context=debug_context,
201
+ )
202
+ context.consoles.write_stderr(safe_terminal_text(formatted, secrets))
moxtral/cli/config.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import TYPE_CHECKING, NoReturn, cast
5
+
6
+ import click
7
+ import tomli_w
8
+
9
+ from moxtral.cli.common import (
10
+ candidate_secrets,
11
+ extend_secrets,
12
+ safe_terminal_text,
13
+ write_debug_exception,
14
+ )
15
+ from moxtral.config import ConfigStore
16
+ from moxtral.errors import ConfigError
17
+
18
+ if TYPE_CHECKING:
19
+ from moxtral.cli.main import AppContext
20
+
21
+
22
+ @click.group()
23
+ def config() -> None:
24
+ """Manage CLI configuration."""
25
+
26
+
27
+ def _raise_config_error(
28
+ context: AppContext,
29
+ error: ConfigError,
30
+ *,
31
+ secrets: tuple[str, ...],
32
+ debug_context: str,
33
+ ) -> NoReturn:
34
+ if context.debug:
35
+ write_debug_exception(
36
+ context,
37
+ error,
38
+ secrets=secrets,
39
+ debug_context=debug_context,
40
+ )
41
+ raise click.ClickException(safe_terminal_text(str(error), secrets)) from error
42
+
43
+
44
+ @config.command(
45
+ "set",
46
+ context_settings={
47
+ "allow_extra_args": True,
48
+ "ignore_unknown_options": True,
49
+ },
50
+ )
51
+ @click.argument("name", type=click.Choice(["api-key"]))
52
+ @click.option(
53
+ "--stdin",
54
+ "read_stdin",
55
+ is_flag=True,
56
+ help="Read the value from standard input.",
57
+ )
58
+ @click.pass_context
59
+ def set_value(context: click.Context, name: str, read_stdin: bool) -> None:
60
+ """Set a configuration value."""
61
+ if context.args:
62
+ raise click.UsageError(
63
+ "API keys must be provided through the prompt or --stdin.",
64
+ ctx=context,
65
+ )
66
+
67
+ if read_stdin:
68
+ try:
69
+ value = sys.stdin.read().rstrip("\r\n")
70
+ except UnicodeError as error:
71
+ raise click.ClickException(
72
+ "Could not read API key from standard input."
73
+ ) from error
74
+ if not value:
75
+ raise click.ClickException("Standard input must not be empty.")
76
+ if "\r" in value or "\n" in value:
77
+ raise click.ClickException("Standard input must contain a single line.")
78
+ else:
79
+ value = click.prompt(
80
+ "API key",
81
+ hide_input=True,
82
+ confirmation_prompt=True,
83
+ )
84
+
85
+ app_context = cast("AppContext", context.obj)
86
+ try:
87
+ ConfigStore(app_context.config_path).set(name, value)
88
+ except ConfigError as error:
89
+ secrets = extend_secrets(candidate_secrets(app_context), value)
90
+ _raise_config_error(
91
+ app_context,
92
+ error,
93
+ secrets=secrets,
94
+ debug_context=f"setting configuration {name}",
95
+ )
96
+
97
+ click.echo("Configuration updated.")
98
+
99
+
100
+ @config.command("show")
101
+ @click.pass_obj
102
+ def show(context: AppContext) -> None:
103
+ """Show configuration with secrets redacted."""
104
+ try:
105
+ values = ConfigStore(context.config_path).redacted()
106
+ except ConfigError as error:
107
+ _raise_config_error(
108
+ context,
109
+ error,
110
+ secrets=candidate_secrets(context),
111
+ debug_context="showing configuration",
112
+ )
113
+
114
+ click.echo(tomli_w.dumps(values), nl=False)
115
+
116
+
117
+ @config.command("unset")
118
+ @click.argument("name", type=click.Choice(["api-key"]))
119
+ @click.pass_obj
120
+ def unset(context: AppContext, name: str) -> None:
121
+ """Remove a configuration value."""
122
+ try:
123
+ removed = ConfigStore(context.config_path).unset(name)
124
+ except ConfigError as error:
125
+ _raise_config_error(
126
+ context,
127
+ error,
128
+ secrets=candidate_secrets(context),
129
+ debug_context=f"unsetting configuration {name}",
130
+ )
131
+
132
+ if removed:
133
+ click.echo(f"{name} removed.")
134
+ else:
135
+ click.echo(f"{name} already absent.")
136
+
137
+
138
+ @config.command("path")
139
+ @click.pass_obj
140
+ def show_path(context: AppContext) -> None:
141
+ """Print the effective configuration path."""
142
+ click.echo(context.config_path)
moxtral/cli/main.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from moxtral import __version__
9
+ from moxtral.cli.agent import agent
10
+ from moxtral.cli.config import config
11
+ from moxtral.cli.ocr import ocr
12
+ from moxtral.cli.transcribe import transcribe
13
+ from moxtral.console import ConsoleBundle, create_console_bundle
14
+
15
+ DEFAULT_CONFIG_PATH = Path("~/.moxtral/config.toml").expanduser()
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class AppContext:
20
+ config_path: Path
21
+ debug: bool
22
+ consoles: ConsoleBundle
23
+
24
+
25
+ @click.group(
26
+ context_settings={"help_option_names": ["-h", "--help"]},
27
+ epilog=(
28
+ "Run 'moxtral agent' for agent-oriented usage docs and "
29
+ "'moxtral agent --schema' for the JSON output schema."
30
+ ),
31
+ )
32
+ @click.version_option(version=__version__)
33
+ @click.option("--debug", is_flag=True, help="Show detailed error information.")
34
+ @click.option(
35
+ "--config",
36
+ "config_path",
37
+ type=click.Path(path_type=Path, dir_okay=False),
38
+ default=DEFAULT_CONFIG_PATH,
39
+ show_default=True,
40
+ help="Path to the configuration file.",
41
+ )
42
+ @click.pass_context
43
+ def cli(ctx: click.Context, debug: bool, config_path: Path) -> None:
44
+ """Work with Mistral OCR and audio transcription."""
45
+ ctx.obj = AppContext(
46
+ config_path=config_path,
47
+ debug=debug,
48
+ consoles=create_console_bundle(),
49
+ )
50
+
51
+
52
+ cli.add_command(agent)
53
+ cli.add_command(config)
54
+ cli.add_command(ocr)
55
+ cli.add_command(transcribe)
moxtral/cli/ocr.py ADDED
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, cast
5
+
6
+ import click
7
+
8
+ from moxtral.cli.common import nonnegative_integer, positive_days
9
+ from moxtral.cli.runner import BatchPlan, DedupeOptions, OutputOptions, run_batch
10
+ from moxtral.formatters import format_ocr_markdown
11
+ from moxtral.mistral_client import MistralGateway
12
+ from moxtral.models import (
13
+ DEFAULT_RETRIES,
14
+ Confidence,
15
+ OcrRequest,
16
+ Operation,
17
+ OutputFormat,
18
+ TableFormat,
19
+ build_ocr_request,
20
+ ocr_request_metadata,
21
+ )
22
+ from moxtral.services.ocr import OcrGateway, OcrService
23
+ from moxtral.sources import resolve_source
24
+ from moxtral.storage import ResultStore
25
+
26
+ if TYPE_CHECKING:
27
+ from moxtral.cli.main import AppContext
28
+
29
+
30
+ def create_gateway(api_key: str) -> OcrGateway:
31
+ """Create the production OCR gateway."""
32
+ return MistralGateway(api_key)
33
+
34
+
35
+ def create_result_store() -> ResultStore:
36
+ """Create the production result store."""
37
+ return ResultStore()
38
+
39
+
40
+ @click.command()
41
+ @click.argument("sources", metavar="SOURCE...", nargs=-1, required=True)
42
+ @click.option(
43
+ "--model",
44
+ default="mistral-ocr-latest",
45
+ show_default=True,
46
+ help="OCR model.",
47
+ )
48
+ @click.option(
49
+ "--pages",
50
+ help="Page numbers or ranges in API syntax, such as 0,2-4.",
51
+ )
52
+ @click.option(
53
+ "--table-format",
54
+ type=click.Choice(["inline", "markdown", "html"]),
55
+ default="inline",
56
+ show_default=True,
57
+ help="How tables are represented in the OCR response.",
58
+ )
59
+ @click.option(
60
+ "--extract-header",
61
+ is_flag=True,
62
+ help="Extract page headers.",
63
+ )
64
+ @click.option(
65
+ "--extract-footer",
66
+ is_flag=True,
67
+ help="Extract page footers.",
68
+ )
69
+ @click.option(
70
+ "--include-images",
71
+ is_flag=True,
72
+ help="Include extracted image data in the response.",
73
+ )
74
+ @click.option(
75
+ "--image-limit",
76
+ type=int,
77
+ callback=nonnegative_integer,
78
+ help="Maximum number of images to extract (requires --include-images).",
79
+ )
80
+ @click.option(
81
+ "--image-min-size",
82
+ type=int,
83
+ callback=nonnegative_integer,
84
+ help="Minimum extracted image size (requires --include-images).",
85
+ )
86
+ @click.option(
87
+ "--include-blocks",
88
+ is_flag=True,
89
+ help="Include structured OCR blocks.",
90
+ )
91
+ @click.option(
92
+ "--confidence",
93
+ type=click.Choice(["none", "page", "word"]),
94
+ default="none",
95
+ show_default=True,
96
+ help="Confidence score granularity.",
97
+ )
98
+ @click.option(
99
+ "--output-dir",
100
+ type=click.Path(path_type=Path, file_okay=False),
101
+ help="Directory in which to save result files.",
102
+ )
103
+ @click.option(
104
+ "--format",
105
+ "output_format",
106
+ type=click.Choice(["md", "json", "both"]),
107
+ default="both",
108
+ show_default=True,
109
+ help="Result file format to save.",
110
+ )
111
+ @click.option(
112
+ "--timeout",
113
+ type=float,
114
+ default=300.0,
115
+ show_default=True,
116
+ help="Request timeout in seconds.",
117
+ )
118
+ @click.option(
119
+ "--retries",
120
+ type=int,
121
+ default=DEFAULT_RETRIES,
122
+ show_default=True,
123
+ callback=nonnegative_integer,
124
+ help=(
125
+ "Retry attempts for rate-limited, server-error, and connection "
126
+ "failures (0 disables)."
127
+ ),
128
+ )
129
+ @click.option(
130
+ "--stdout",
131
+ "write_stdout",
132
+ is_flag=True,
133
+ help="Also write OCR Markdown to standard output.",
134
+ )
135
+ @click.option(
136
+ "--json",
137
+ "write_json",
138
+ is_flag=True,
139
+ help="Write NDJSON result records to standard output (one per source).",
140
+ )
141
+ @click.option(
142
+ "--quiet",
143
+ is_flag=True,
144
+ help="Suppress progress and summary output.",
145
+ )
146
+ @click.option(
147
+ "--no-save",
148
+ is_flag=True,
149
+ help="Do not save result files (requires --json or --stdout).",
150
+ )
151
+ @click.option(
152
+ "--dry-run",
153
+ is_flag=True,
154
+ help="Validate sources and options without calling the API.",
155
+ )
156
+ @click.option(
157
+ "--force",
158
+ is_flag=True,
159
+ help="Process the source even if an identical recent result exists.",
160
+ )
161
+ @click.option(
162
+ "--dedupe-window",
163
+ type=float,
164
+ default=30.0,
165
+ show_default=True,
166
+ metavar="DAYS",
167
+ callback=positive_days,
168
+ help="Look-back window in days for skipping identical, already-saved results.",
169
+ )
170
+ @click.pass_obj
171
+ def ocr(
172
+ context: AppContext,
173
+ sources: tuple[str, ...],
174
+ model: str,
175
+ pages: str | None,
176
+ table_format: str,
177
+ extract_header: bool,
178
+ extract_footer: bool,
179
+ include_images: bool,
180
+ image_limit: int | None,
181
+ image_min_size: int | None,
182
+ include_blocks: bool,
183
+ confidence: str,
184
+ output_dir: Path | None,
185
+ output_format: str,
186
+ timeout: float,
187
+ retries: int,
188
+ write_stdout: bool,
189
+ write_json: bool,
190
+ quiet: bool,
191
+ no_save: bool,
192
+ dry_run: bool,
193
+ force: bool,
194
+ dedupe_window: float,
195
+ ) -> None:
196
+ """Extract readable text from local documents, images, or HTTP(S) URLs."""
197
+ selected_table_format = (
198
+ None if table_format == "inline" else cast(TableFormat, table_format)
199
+ )
200
+ selected_confidence = None if confidence == "none" else cast(Confidence, confidence)
201
+
202
+ def build_request(source_value: str) -> OcrRequest:
203
+ return build_ocr_request(
204
+ source=resolve_source(source_value, Operation.OCR),
205
+ model=model,
206
+ pages=pages,
207
+ table_format=selected_table_format,
208
+ extract_header=extract_header,
209
+ extract_footer=extract_footer,
210
+ include_images=include_images,
211
+ image_limit=image_limit,
212
+ image_min_size=image_min_size,
213
+ include_blocks=include_blocks,
214
+ confidence=selected_confidence,
215
+ timeout_seconds=timeout,
216
+ retries=retries,
217
+ )
218
+
219
+ run_batch(
220
+ context,
221
+ sources,
222
+ BatchPlan(
223
+ setup_debug_context="setting up OCR command",
224
+ source_debug_prefix="OCR source",
225
+ operation=Operation.OCR,
226
+ build_request=build_request,
227
+ request_metadata=ocr_request_metadata,
228
+ create_service=lambda api_key: OcrService(create_gateway(api_key)),
229
+ create_store=lambda: create_result_store(),
230
+ format_markdown=format_ocr_markdown,
231
+ ),
232
+ OutputOptions(
233
+ output_format=OutputFormat(output_format),
234
+ output_dir=output_dir,
235
+ write_markdown_stdout=write_stdout,
236
+ write_json_stdout=write_json,
237
+ quiet=quiet,
238
+ no_save=no_save,
239
+ dry_run=dry_run,
240
+ ),
241
+ DedupeOptions(force=force, window_days=dedupe_window),
242
+ )