opendockit 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.
opendockit/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """Convert an uploaded document into a Markdown string ready for LLM input.
2
+
3
+ from opendockit import OpenAIImageDescriber, aconvert
4
+ result = await aconvert(data, "report.hwp", describer=OpenAIImageDescriber())
5
+ result.text # Markdown to feed to the LLM (images are filled in with vision-model descriptions and OCR)
6
+ result.sections # per page / slide / sheet (for chunking and source attribution)
7
+ result.images # the extracted images and their descriptions
8
+ result.char_count # number of characters (picture descriptions included); the basis for upload limits
9
+ result.exceeded # whether the conversion stopped because the text went over the character limit (500,000 by default)
10
+ result.warnings # limit exceeded, scanned pages, extension mismatch, and so on
11
+
12
+ On a server, convert in a worker process with `IsolatedConverter` (so one file's runaway memory or time does not spread to
13
+ the server). With `convert` (synchronous) alone, the images are collected in `result.images` and the body keeps only placeholder text.
14
+ """
15
+
16
+ from importlib.metadata import PackageNotFoundError, version
17
+
18
+ from opendockit.converter import aconvert, convert, convert_file, render
19
+ from opendockit.describe import ImageDescriber, OpenAIImageDescriber, describe_images
20
+ from opendockit.detect import detect_format
21
+ from opendockit.exceptions import (
22
+ ConversionError,
23
+ CorruptedDocumentError,
24
+ DocumentTooLargeError,
25
+ EncryptedDocumentError,
26
+ UnsupportedFormatError,
27
+ )
28
+ from opendockit.isolated import TOO_COMPLEX_MESSAGE, IsolatedConverter
29
+ from opendockit.models import (
30
+ ConversionResult,
31
+ ConvertOptions,
32
+ DocumentFormat,
33
+ DocumentImage,
34
+ ImageKind,
35
+ Section,
36
+ SectionKind,
37
+ )
38
+
39
+
40
+ try:
41
+ __version__ = version("opendockit")
42
+ except PackageNotFoundError: # running from a source tree that was never installed
43
+ __version__ = "0.0.0"
44
+
45
+ __all__ = [
46
+ "ConversionError",
47
+ "ConversionResult",
48
+ "ConvertOptions",
49
+ "CorruptedDocumentError",
50
+ "DocumentFormat",
51
+ "DocumentImage",
52
+ "DocumentTooLargeError",
53
+ "EncryptedDocumentError",
54
+ "ImageDescriber",
55
+ "ImageKind",
56
+ "IsolatedConverter",
57
+ "OpenAIImageDescriber",
58
+ "Section",
59
+ "SectionKind",
60
+ "TOO_COMPLEX_MESSAGE",
61
+ "UnsupportedFormatError",
62
+ "__version__",
63
+ "aconvert",
64
+ "convert",
65
+ "convert_file",
66
+ "describe_images",
67
+ "detect_format",
68
+ "render",
69
+ ]
opendockit/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """`python -m opendockit` — same as the `opendockit` command."""
2
+
3
+ import sys
4
+
5
+ from opendockit.cli import main
6
+
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
opendockit/cli.py ADDED
@@ -0,0 +1,135 @@
1
+ """Command-line entry point for trying a conversion.
2
+
3
+ opendockit <file> [--count | --json] [--describe [--model NAME]]
4
+
5
+ `--describe` has an OpenAI vision model describe the images (this costs money). The key is read from the `OPENAI_API_KEY`
6
+ environment variable, and `--describe` needs the optional extra: `pip install "opendockit[openai]"`.
7
+ """
8
+
9
+ import argparse
10
+ import asyncio
11
+ from dataclasses import asdict
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import sys
16
+
17
+ from opendockit import (
18
+ ConversionError,
19
+ ConversionResult,
20
+ OpenAIImageDescriber,
21
+ convert_file,
22
+ describe_images,
23
+ )
24
+ from opendockit.describe import DEFAULT_MODEL
25
+
26
+
27
+ def main() -> int:
28
+ _write_output_as_utf8()
29
+ parser = argparse.ArgumentParser(description="Convert a document to Markdown ready for LLM input.")
30
+ parser.add_argument("path")
31
+ # Given both, only --count was honored and --json was silently ignored
32
+ # (Pinned by: test_cli.py::TestArguments::test_count_and_json_cannot_be_used_together).
33
+ output = parser.add_mutually_exclusive_group()
34
+ output.add_argument("--json", action="store_true", help="print JSON that includes the sections, metadata, warnings and image list")
35
+ output.add_argument("--count", action="store_true",
36
+ help="print only the character count instead of the text ('exceeded' when over the limit). "
37
+ "Add --describe to also count the text inside pictures")
38
+ parser.add_argument("--describe", action="store_true",
39
+ help="describe the images with an OpenAI vision model and fill the descriptions into the text")
40
+ parser.add_argument("--model", default=DEFAULT_MODEL, help=f"model to use for --describe (default {DEFAULT_MODEL})")
41
+ args = parser.parse_args()
42
+
43
+ path = Path(args.path)
44
+ describer = OpenAIImageDescriber(model_name=args.model) if args.describe else None
45
+ try:
46
+ # convert_file checks the size from the file metadata before reading. A file over the limit is never read whole.
47
+ result = convert_file(path)
48
+ if describer is not None and result.images:
49
+ result = asyncio.run(_describe(result, describer))
50
+ except ConversionError as exc:
51
+ print(f"[{exc.code}] {exc}", file=sys.stderr)
52
+ return 1
53
+ except _DescriberUnavailable as exc:
54
+ print(f"Cannot use --describe: {exc}", file=sys.stderr)
55
+ return 1
56
+ except FileNotFoundError:
57
+ print(f"File not found: {path}", file=sys.stderr)
58
+ return 1
59
+ except OSError:
60
+ print(f"Could not read the file (it may be a directory, or permission may be missing): {path}", file=sys.stderr)
61
+ return 1
62
+
63
+ try:
64
+ _print_result(result, args)
65
+ except BrokenPipeError:
66
+ # The reader closed first, as with `| head`. Finish without dumping a traceback. Point standard output at /dev/null so
67
+ # the error does not fire again at exit while flushing the remaining output buffer
68
+ # (the SIGPIPE advice in the Python docs, Pinned by: test_cli.py::TestArguments::test_closed_pipe_ends_quietly).
69
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
70
+ return 1
71
+ return 0
72
+
73
+
74
+ def _write_output_as_utf8() -> None:
75
+ """Write standard output and error as UTF-8. Under `PYTHONIOENCODING=ascii` or a Windows redirect (cp949), Korean text ended in
76
+ a UnicodeEncodeError traceback (Pinned by: test_cli.py::TestArguments::test_output_is_written_as_utf8_even_when_stdout_is_not).
77
+ A stream that cannot be reconfigured is left as it is."""
78
+ for stream in (sys.stdout, sys.stderr):
79
+ reconfigure = getattr(stream, "reconfigure", None)
80
+ if reconfigure is None:
81
+ continue
82
+ try:
83
+ reconfigure(encoding="utf-8", errors="replace")
84
+ except (ValueError, OSError): # a closed stream, or one that cannot be reconfigured
85
+ pass
86
+
87
+
88
+ def _print_result(result: ConversionResult, args: argparse.Namespace) -> None:
89
+ if args.count:
90
+ print(f"{result.char_count}{' exceeded' if result.exceeded else ''}")
91
+ if result.images and not args.describe:
92
+ print(f"[warning] The text in {len(result.images)} picture(s) was not counted. Add --describe to include it.", file=sys.stderr)
93
+ for warning in result.warnings:
94
+ print(f"[warning] {warning}", file=sys.stderr)
95
+ elif args.json:
96
+ payload = asdict(result)
97
+ for key in ("base_warnings", "options", "fallbacks", "counted", "marker_key"):
98
+ payload.pop(key)
99
+ for section in payload["sections"]:
100
+ section.pop("template")
101
+ for image in payload["images"]:
102
+ # Picture bytes are written as their size only. So are the tiles of a very tall picture (`tiles`) — they came out as
103
+ # a repr and the output went over 1MB
104
+ # (Pinned by: test_cli.py::test_json_output_summarizes_picture_tiles_like_picture_data).
105
+ image["data"] = f"<{len(image['data'])} bytes>"
106
+ image["tiles"] = [f"<{len(tile)} bytes>" for tile in image["tiles"]]
107
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
108
+ else:
109
+ print(result.text)
110
+ for warning in result.warnings:
111
+ print(f"[warning] {warning}", file=sys.stderr)
112
+
113
+
114
+ class _DescriberUnavailable(Exception):
115
+ """The vision model could not be set up (missing extra, missing API key)."""
116
+
117
+
118
+ async def _describe(result: ConversionResult, describer: OpenAIImageDescriber) -> ConversionResult:
119
+ """Close the HTTP client the describer created in this loop before the loop ends.
120
+
121
+ The model is built first: without the `openai` extra or an API key every image would otherwise just come back as
122
+ "could not be described" with exit code 0 (Pinned by: test_cli.py::TestDescribeSetup).
123
+ """
124
+ try:
125
+ try:
126
+ describer.check()
127
+ except Exception as exc:
128
+ raise _DescriberUnavailable(str(exc)) from exc
129
+ return await describe_images(result, describer)
130
+ finally:
131
+ await describer.aclose()
132
+
133
+
134
+ if __name__ == "__main__":
135
+ sys.exit(main())