extract-core 0.5.4__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.
@@ -0,0 +1,63 @@
1
+ from typing import Annotated
2
+
3
+ from icij_common.pydantic_utils import make_enum_discriminator, tagged_union
4
+ from pydantic import Discriminator
5
+
6
+ from .configs import BasePipelineConfig, PipelineType
7
+ from .objects import (
8
+ BaseModel,
9
+ ConversionOutput,
10
+ Error,
11
+ InputDoc,
12
+ MarkdownDoc,
13
+ OutputFormat,
14
+ PageIndexes,
15
+ Result,
16
+ Status,
17
+ )
18
+ from .pipeline import Pipeline
19
+
20
+ try:
21
+ from .docling_ import DoclingFormatOption, DoclingPipelineConfig
22
+ except ImportError:
23
+ DoclingPipelineConfig, DoclingFormatOption = None, None
24
+ try:
25
+ from .marker_ import MarkerPipelineConfig
26
+ except ImportError:
27
+ MarkerPipelineConfig = None
28
+
29
+
30
+ try:
31
+ from .miner_u import MinerUBackend, MinerUConfig, MinerUPipelineConfig
32
+ except ImportError:
33
+ MinerUBackend, MinerUPipelineConfig, MinerUConfig = None, None, None
34
+
35
+
36
+ pipeline_type_discriminator = make_enum_discriminator("pipeline", PipelineType)
37
+ PipelineConfig = Annotated[
38
+ tagged_union(
39
+ BasePipelineConfig.__subclasses__(), lambda t: t.pipeline.default.value
40
+ ),
41
+ Discriminator(pipeline_type_discriminator),
42
+ ]
43
+
44
+
45
+ __all__ = [
46
+ "BaseModel",
47
+ "BasePipelineConfig",
48
+ "ConversionOutput",
49
+ "DoclingPipelineConfig",
50
+ "Error",
51
+ "InputDoc",
52
+ "MarkdownDoc",
53
+ "MarkerPipelineConfig",
54
+ "MinerUBackend",
55
+ "MinerUConfig",
56
+ "MinerUPipelineConfig",
57
+ "OutputFormat",
58
+ "PageIndexes",
59
+ "Pipeline",
60
+ "PipelineType",
61
+ "Result",
62
+ "Status",
63
+ ]
@@ -0,0 +1,27 @@
1
+ from abc import ABC, abstractmethod
2
+ from enum import StrEnum
3
+ from typing import ClassVar
4
+
5
+ from icij_common.pydantic_utils import icij_config, merge_configs, no_enum_values_config
6
+ from icij_common.registrable import RegistrableConfig
7
+ from pydantic import Field
8
+
9
+ from .objects import SupportedExt
10
+
11
+
12
+ class PipelineType(StrEnum):
13
+ DOCLING = "docling"
14
+ MARKER = "marker"
15
+ MINER_U = "miner_u"
16
+
17
+
18
+ class BasePipelineConfig(RegistrableConfig, ABC):
19
+ # TODO: move this icij_config() to RegistrableConfig
20
+ model_config = merge_configs(icij_config(), no_enum_values_config())
21
+
22
+ registry_key: ClassVar[str] = Field(frozen=True, default="pipeline")
23
+ pipeline: ClassVar[PipelineType]
24
+
25
+ @classmethod
26
+ @abstractmethod
27
+ def supported_exts(cls) -> set[SupportedExt]: ...
@@ -0,0 +1,268 @@
1
+ import importlib
2
+ from functools import cache
3
+ from typing import Annotated, Any, ClassVar, TypeVar, get_type_hints
4
+
5
+ from docling.datamodel.backend_options import BackendOptions, BaseBackendOptions
6
+ from docling.datamodel.base_models import (
7
+ BaseFormatOption,
8
+ FormatToExtensions,
9
+ InputFormat,
10
+ )
11
+ from docling.datamodel.pipeline_options import (
12
+ BaseLayoutOptions,
13
+ BaseTableStructureOptions,
14
+ EasyOcrOptions,
15
+ LayoutOptions,
16
+ OcrOptions,
17
+ PictureDescriptionBaseOptions,
18
+ PictureDescriptionVlmEngineOptions,
19
+ PipelineOptions,
20
+ TableStructureOptions,
21
+ ThreadedPdfPipelineOptions,
22
+ )
23
+ from icij_common.pydantic_utils import (
24
+ merge_configs,
25
+ tagged_union,
26
+ to_lower_snake_case,
27
+ )
28
+ from pydantic import (
29
+ ConfigDict,
30
+ Discriminator,
31
+ Field,
32
+ TypeAdapter,
33
+ WrapSerializer,
34
+ )
35
+ from pydantic_core.core_schema import SerializerFunctionWrapHandler
36
+
37
+ from .configs import BasePipelineConfig, PipelineType
38
+ from .objects import BaseModel, SupportedExt
39
+ from .utils import all_subclasses
40
+
41
+
42
+ @cache
43
+ def _ext_to_docling_input_format() -> dict:
44
+
45
+ mapping = dict()
46
+ supported = DoclingPipelineConfig.supported_exts()
47
+ for input_f, exts in FormatToExtensions.items():
48
+ for ext in exts:
49
+ try:
50
+ ext = SupportedExt(f".{ext.lower()}") # noqa: PLW2901
51
+ except ValueError:
52
+ continue
53
+ if ext in supported:
54
+ mapping[ext] = input_f
55
+ return mapping
56
+
57
+
58
+ def _validate_pipeline_opts(v: PipelineOptions) -> PipelineOptions:
59
+ generate_picture_images = getattr(v, "generate_picture_images", None)
60
+ if generate_picture_images is False:
61
+ msg = "generate_picture_images should be set to True"
62
+ raise ValueError(msg)
63
+ return v
64
+
65
+
66
+ T = TypeVar("T")
67
+
68
+
69
+ def _find_subcls(cls: type[T], name: str) -> type[T]:
70
+ # Check if the class available
71
+ for c in all_subclasses(cls):
72
+ if c.__name__ == name:
73
+ return c
74
+ # Then apply ad-hoc search
75
+ if "pipeline" in cls.__name__.lower():
76
+ module_name = f"docling.pipeline.{to_lower_snake_case(name)}"
77
+ try:
78
+ module = importlib.import_module(module_name)
79
+ return getattr(module, name)
80
+ except (ModuleNotFoundError, AttributeError):
81
+ pass
82
+ raise ValueError(f"unknown {cls.__name__} subclass {name}")
83
+
84
+
85
+ def _find_init_arg_type(cls: type[Any], arg: str) -> type[BaseModel]:
86
+ hints = get_type_hints(cls.__init__)
87
+ return hints[arg]
88
+
89
+
90
+ def _resolve_pipeline_cls(v: str) -> Any:
91
+ if isinstance(v, str):
92
+ from docling.pipeline.base_pipeline import BasePipeline # noqa: PLC0415
93
+
94
+ return _find_subcls(BasePipeline, v)
95
+ return v
96
+
97
+
98
+ def _ser_as_str(v: type) -> str:
99
+ return v.__name__
100
+
101
+
102
+ def _ser_with_backend_option_kind(
103
+ v: Any, handler: SerializerFunctionWrapHandler
104
+ ) -> Any:
105
+ serialized = handler(v)
106
+ if isinstance(v, BaseBackendOptions):
107
+ kind = getattr(v, "kind", None)
108
+ if kind is not None:
109
+ serialized["kind"] = kind
110
+ return serialized
111
+
112
+
113
+ def _resolve_backend(v: Any) -> Any:
114
+ from docling.backend.abstract_backend import ( # noqa: PLC0415
115
+ AbstractDocumentBackend,
116
+ )
117
+
118
+ if isinstance(v, str):
119
+ return _find_subcls(AbstractDocumentBackend, v)
120
+ return v
121
+
122
+
123
+ @cache
124
+ def _picture_descr_opts_type_adapter() -> TypeAdapter:
125
+ _PictureDescriptionModel = Annotated[ # noqa: N806
126
+ tagged_union(
127
+ PictureDescriptionBaseOptions.__subclasses__(), tag_getter=lambda x: x.kind
128
+ ),
129
+ Discriminator(lambda x: x["kind"]),
130
+ ]
131
+ return TypeAdapter(_PictureDescriptionModel)
132
+
133
+
134
+ @cache
135
+ def _ocr_opts_type_adapter() -> TypeAdapter:
136
+ _OcrOptions = Annotated[ # noqa: N806
137
+ tagged_union(OcrOptions.__subclasses__(), tag_getter=lambda x: x.kind),
138
+ Discriminator(lambda x: x.pop("kind")),
139
+ ]
140
+ return TypeAdapter(_OcrOptions)
141
+
142
+
143
+ @cache
144
+ def _layout_opts_type_adapter() -> TypeAdapter:
145
+ _LayoutOptions = Annotated[ # noqa: N806
146
+ tagged_union(BaseLayoutOptions.__subclasses__(), tag_getter=lambda x: x.kind),
147
+ Discriminator(lambda x: x["kind"]),
148
+ ]
149
+ return TypeAdapter(_LayoutOptions)
150
+
151
+
152
+ @cache
153
+ def _table_structure_opts_type_adapter() -> TypeAdapter:
154
+ _TableStructureOptions = Annotated[ # noqa: N806
155
+ tagged_union(
156
+ BaseTableStructureOptions.__subclasses__(), tag_getter=lambda x: x.kind
157
+ ),
158
+ Discriminator(lambda x: x["kind"]),
159
+ ]
160
+ return TypeAdapter(_TableStructureOptions)
161
+
162
+
163
+ def _resolve_pipeline_options(
164
+ pipeline_options: dict[str, Any] | None | PipelineOptions, pipeline_cls: type
165
+ ) -> PipelineOptions:
166
+ option_cls = _find_init_arg_type(pipeline_cls, "pipeline_options")
167
+ picture_descr_opts = pipeline_options.get("picture_description_options")
168
+ if picture_descr_opts is not None:
169
+ if "kind" not in picture_descr_opts:
170
+ msg = f"missing picture description options kind: {picture_descr_opts}"
171
+ raise ValueError(msg)
172
+
173
+ picture_descr_opts = _picture_descr_opts_type_adapter().validate_python(
174
+ picture_descr_opts
175
+ )
176
+ pipeline_options["picture_description_options"] = picture_descr_opts
177
+ ocr_opts = pipeline_options.get("ocr_options")
178
+ if ocr_opts is not None:
179
+ if "kind" not in ocr_opts:
180
+ msg = f"missing ocr options kind: {ocr_opts}"
181
+ raise ValueError(msg)
182
+ ocr_opts = _ocr_opts_type_adapter().validate_python(ocr_opts)
183
+ pipeline_options["ocr_options"] = ocr_opts
184
+ layout_opts = pipeline_options.get("layout_options")
185
+ if layout_opts is not None:
186
+ if "kind" not in layout_opts:
187
+ msg = f"missing layout options kind: {layout_opts}"
188
+ raise ValueError(msg)
189
+ layout_opts = _layout_opts_type_adapter().validate_python(layout_opts)
190
+ pipeline_options["layout_options"] = layout_opts
191
+ table_structure_opts = pipeline_options.get("table_structure_options")
192
+ if table_structure_opts is not None:
193
+ if "kind" not in table_structure_opts:
194
+ msg = f"missing table structure options kind: {table_structure_opts}"
195
+ raise ValueError(msg)
196
+ table_structure_opts = _table_structure_opts_type_adapter().validate_python(
197
+ table_structure_opts
198
+ )
199
+ pipeline_options["table_structure_options"] = table_structure_opts
200
+ pipeline_options = option_cls.model_validate(pipeline_options)
201
+ return pipeline_options
202
+
203
+
204
+ # Mimics the docling FormatOption but only with lightweight types,
205
+ # the heavy convertion is done at runtime
206
+ class DoclingFormatOption(BaseFormatOption):
207
+ model_config = merge_configs(
208
+ BaseModel.model_config, ConfigDict(polymorphic_serialization=True)
209
+ )
210
+ backend: str
211
+ backend_options: Annotated[
212
+ BackendOptions | None, WrapSerializer(_ser_with_backend_option_kind)
213
+ ] = None
214
+ pipeline_cls: str
215
+ pipeline_options: dict[str, Any] | None = None
216
+
217
+ def to_docling(self) -> BaseFormatOption: # noqa: ANN201
218
+ from docling.document_converter import FormatOption # noqa: PLC0415
219
+
220
+ pipeline_cls = _resolve_pipeline_cls(self.pipeline_cls)
221
+ pipeline_opts = _resolve_pipeline_options(self.pipeline_options, pipeline_cls)
222
+ pipeline_opts = _validate_pipeline_opts(pipeline_opts)
223
+ return FormatOption(
224
+ pipeline_cls=pipeline_cls,
225
+ pipeline_options=pipeline_opts,
226
+ backend=_resolve_backend(self.backend),
227
+ backend_options=self.backend_options,
228
+ )
229
+
230
+
231
+ @cache
232
+ def _default_format_opts() -> dict[InputFormat, DoclingFormatOption]:
233
+ pipeline_opts = ThreadedPdfPipelineOptions(
234
+ ocr_options=EasyOcrOptions(), generate_picture_images=True
235
+ ).model_dump(polymorphic_serialization=True)
236
+ pipeline_opts["picture_description_options"]["kind"] = (
237
+ PictureDescriptionVlmEngineOptions.kind
238
+ )
239
+ pipeline_opts["ocr_options"]["kind"] = EasyOcrOptions.kind
240
+ pipeline_opts["layout_options"]["kind"] = LayoutOptions.kind
241
+ pipeline_opts["table_structure_options"]["kind"] = TableStructureOptions.kind
242
+ return {
243
+ InputFormat.PDF: DoclingFormatOption(
244
+ pipeline_cls="StandardPdfPipeline",
245
+ backend="DoclingParseDocumentBackend",
246
+ pipeline_options=pipeline_opts,
247
+ ),
248
+ }
249
+
250
+
251
+ class DoclingPipelineConfig(BasePipelineConfig):
252
+ pipeline: ClassVar[PipelineType] = Field(frozen=True, default=PipelineType.DOCLING)
253
+
254
+ format_options: dict[InputFormat, DoclingFormatOption] = Field(
255
+ default_factory=_default_format_opts
256
+ )
257
+
258
+ @classmethod
259
+ @cache
260
+ def supported_exts(cls) -> set[SupportedExt]:
261
+ unsupported = {InputFormat.AUDIO, InputFormat.METS_GBS, InputFormat.VTT}
262
+ supported = set()
263
+ for f in InputFormat:
264
+ if f in unsupported:
265
+ continue
266
+ for ext in FormatToExtensions[f]:
267
+ supported.add(SupportedExt(f".{ext.lower()}"))
268
+ return supported
@@ -0,0 +1,40 @@
1
+ from functools import cache
2
+ from typing import Any, ClassVar
3
+
4
+ from pydantic import Field
5
+
6
+ from .configs import BasePipelineConfig, PipelineType
7
+ from .objects import SupportedExt
8
+
9
+
10
+ class MarkerPipelineConfig(BasePipelineConfig):
11
+ pipeline: ClassVar[PipelineType] = Field(frozen=True, default=PipelineType.MARKER)
12
+
13
+ config: dict[str, Any] = dict()
14
+
15
+ @classmethod
16
+ @cache
17
+ def supported_exts(cls) -> set[SupportedExt]:
18
+ # Subset of https://documentation.datalab.to/docs/common/supportedfiletypes
19
+ return {
20
+ SupportedExt.PDF,
21
+ SupportedExt.XLS,
22
+ SupportedExt.XLSX,
23
+ SupportedExt.XLSM,
24
+ SupportedExt.CSV,
25
+ SupportedExt.ODS,
26
+ SupportedExt.DOC,
27
+ SupportedExt.DOCX,
28
+ SupportedExt.ODT,
29
+ SupportedExt.PPT,
30
+ SupportedExt.PPTX,
31
+ SupportedExt.ODP,
32
+ SupportedExt.HTLM,
33
+ SupportedExt.EPUB,
34
+ SupportedExt.PNG,
35
+ SupportedExt.JPG,
36
+ SupportedExt.JPEG,
37
+ SupportedExt.WEBP,
38
+ SupportedExt.GIF,
39
+ SupportedExt.TIFF,
40
+ }
@@ -0,0 +1,76 @@
1
+ from collections.abc import Callable
2
+ from copy import copy
3
+ from enum import StrEnum
4
+ from functools import cache
5
+ from typing import Any, ClassVar
6
+
7
+ from pydantic import Field
8
+ from pydantic_extra_types.language_code import LanguageAlpha2
9
+
10
+ from .configs import BasePipelineConfig, PipelineType
11
+ from .objects import BaseModel, SupportedExt
12
+
13
+ _MINER_U_CONVERSION_ERRORS = tuple()
14
+ MDMakeFunction = Callable[[list, str, str], str | None]
15
+
16
+
17
+ class MinerUBackend(StrEnum):
18
+ PIPELINE = "pipeline"
19
+ VLM = "vlm"
20
+
21
+
22
+ class MinerUConfig(BaseModel):
23
+ backend: MinerUBackend = MinerUBackend.PIPELINE
24
+ enable_formula_extraction: bool = True
25
+ enable_table_extraction: bool = True
26
+ # TODO: use enum or literal here
27
+ parse_method: str = "auto"
28
+
29
+ def as_parse_kwargs(self) -> dict[str, Any]:
30
+ kwargs = copy(self._get_default_kwargs())
31
+ kwargs["backend"] = self.backend
32
+ kwargs["parse_method"] = self.parse_method
33
+ kwargs["formula_enable"] = self.enable_formula_extraction
34
+ kwargs["table_enable"] = self.enable_table_extraction
35
+ return kwargs
36
+
37
+ @classmethod
38
+ @cache
39
+ def _get_default_kwargs(cls) -> dict[str, Any]:
40
+ from mineru.utils.enum_class import MakeMode # noqa: PLC0415
41
+
42
+ return {
43
+ "server_url": None,
44
+ # We don't dump md directly we process, we dump the middle json in order
45
+ # to be able to get page indexes
46
+ "parse_method": "auto",
47
+ "dump_md": False,
48
+ "dump_middle_json": True,
49
+ "f_draw_layout_bbox": False,
50
+ "f_draw_span_bbox": False,
51
+ "f_dump_model_output": False, # might be useful for debug though
52
+ "f_dump_orig_pdf": False,
53
+ "f_dump_content_list": False, # might be useful for debug though
54
+ "start_page_id": 0,
55
+ "f_make_md_mode": MakeMode.MM_MD,
56
+ "image_analysis": True,
57
+ "end_page_id": None,
58
+ "client_side_output_generation": False,
59
+ }
60
+
61
+
62
+ class MinerUPipelineConfig(BasePipelineConfig): # noqa: F821
63
+ pipeline: ClassVar[PipelineType] = Field(frozen=True, default=PipelineType.MINER_U)
64
+
65
+ config: MinerUConfig = Field(frozen=True, default=MinerUConfig())
66
+ language: LanguageAlpha2 = Field(frozen=True, default="en")
67
+
68
+ @classmethod
69
+ @cache
70
+ def supported_exts(cls) -> set[SupportedExt]:
71
+ return {
72
+ SupportedExt.PDF,
73
+ SupportedExt.DOCX,
74
+ SupportedExt.PPTX,
75
+ SupportedExt.XLSX,
76
+ }
@@ -0,0 +1,295 @@
1
+ import logging
2
+ import os
3
+ import traceback
4
+ import uuid
5
+ from abc import ABC
6
+ from enum import StrEnum
7
+ from functools import cache
8
+ from io import BytesIO
9
+ from pathlib import Path
10
+ from typing import Annotated, Any, NoReturn, Self
11
+
12
+ from icij_common.pydantic_utils import (
13
+ icij_config,
14
+ merge_configs,
15
+ no_enum_values_config,
16
+ safe_copy,
17
+ )
18
+ from pydantic import AfterValidator, RootModel, TypeAdapter
19
+ from pydantic import BaseModel as _BaseModel
20
+
21
+ logger = logging.getLogger(__name__)
22
+ base_config = merge_configs(icij_config(), no_enum_values_config())
23
+
24
+
25
+ class BaseModel(_BaseModel):
26
+ model_config = base_config
27
+
28
+
29
+ class SupportedExt(StrEnum):
30
+ ADOC = ".adoc"
31
+ ASC = ".asc"
32
+ ASCIIDOC = ".asciidoc"
33
+ BMP = ".bmp"
34
+ CSV = ".csv"
35
+ DOC = ".doc"
36
+ DOCX = ".docx"
37
+ DOTX = ".dotx"
38
+ DOTM = ".dotm"
39
+ DOCM = ".docm"
40
+ EPUB = ".epub"
41
+ EML = ".eml"
42
+ GIF = ".gif"
43
+ HTLM = ".html"
44
+ HTM = ".htm"
45
+ JPEG = ".jpeg"
46
+ JPG = ".jpg"
47
+ JSON = ".json"
48
+ LATEX = ".latex"
49
+ MD = ".md"
50
+ NXML = ".nxml"
51
+ ODP = ".odp"
52
+ ODS = ".ods"
53
+ ODT = ".odt"
54
+ PDF = ".pdf"
55
+ PNG = ".png"
56
+ PPSX = ".ppsx"
57
+ PPT = ".ppt"
58
+ PPTM = ".pptm"
59
+ PPSM = ".ppsm"
60
+ POTX = ".potx"
61
+ POTM = ".potm"
62
+ PPTX = ".pptx"
63
+ QMD = ".qmd"
64
+ RMD = ".rmd"
65
+ TEX = ".tex"
66
+ TIF = ".tif"
67
+ TIFF = ".tiff"
68
+ TXT = ".txt"
69
+ TEXT = ".text"
70
+ WEBP = ".webp"
71
+ XBRL = ".xbrl"
72
+ XHTML = ".xhtml"
73
+ XLS = ".xls"
74
+ XLSM = ".xlsm"
75
+ XLSX = ".xlsx"
76
+ XLTX = ".xltx"
77
+ XML = ".xml"
78
+
79
+ def to_docling(self): # noqa: ANN201
80
+ from .docling_ import _ext_to_docling_input_format # noqa: PLC0415
81
+
82
+ return _ext_to_docling_input_format()[self]
83
+
84
+
85
+ class OutputFormat(StrEnum):
86
+ MARKDOWN = ".md"
87
+
88
+ @property
89
+ def suffix(self) -> str:
90
+ return self.value[1:]
91
+
92
+ def to_marker(self) -> str:
93
+ match self:
94
+ case OutputFormat.MARKDOWN:
95
+ return "markdown"
96
+ case _:
97
+ raise ValueError(f"{self} is unsupported by marker")
98
+
99
+
100
+ class Status(StrEnum):
101
+ FAILURE = "failure"
102
+ SUCCESS = "success"
103
+ PARTIAL_SUCCESS = "partial_success"
104
+
105
+ @classmethod
106
+ def from_docling(cls, v: Any) -> Self:
107
+ from docling.datamodel.base_models import ConversionStatus # noqa: PLC0415
108
+
109
+ if v is ConversionStatus.SUCCESS:
110
+ return cls.SUCCESS
111
+ if v is ConversionStatus.PARTIAL_SUCCESS:
112
+ return cls.PARTIAL_SUCCESS
113
+ if isinstance(v, ConversionStatus):
114
+ return cls.FAILURE
115
+ raise TypeError(f"can't convert {v!r} to {cls.__name__!r}")
116
+
117
+ @property
118
+ def allows_conversion(self) -> bool:
119
+ return self is Status.SUCCESS or self is Status.PARTIAL_SUCCESS
120
+
121
+
122
+ class Error(BaseModel):
123
+ id: str
124
+ title: str
125
+ detail: str
126
+
127
+ @classmethod
128
+ def from_exception(cls, exception: BaseException) -> Self:
129
+ title = exception.__class__.__name__
130
+ trace_lines = traceback.format_exception(
131
+ None, value=exception, tb=exception.__traceback__
132
+ )
133
+ detail = f"{exception}\n{''.join(trace_lines)}"
134
+ error_id = f"{_id_title(title)}-{uuid.uuid4().hex}"
135
+ error = cls(id=error_id, title=title, detail=detail)
136
+ return error
137
+
138
+ @classmethod
139
+ def from_docling(cls, docling_error) -> Self: # noqa: ANN001
140
+ title = "DoclingConversionError"
141
+ error_id = f"{_id_title(title)}-{uuid.uuid4().hex}"
142
+ detail = (
143
+ f"error in module {docling_error.module_name} of"
144
+ f" {docling_error.component_type}:\n{docling_error.error_message}"
145
+ )
146
+ return cls(id=error_id, title=title, detail=detail)
147
+
148
+
149
+ def _id_title(title: str) -> str:
150
+ id_title = []
151
+ for i, letter in enumerate(title):
152
+ if i and letter.isupper():
153
+ id_title.append("-")
154
+ id_title.append(letter.lower())
155
+ return "".join(id_title)
156
+
157
+
158
+ class InputDoc(BaseModel):
159
+ ext: SupportedExt
160
+ path: Path
161
+ content: bytes | None = None
162
+
163
+ @classmethod
164
+ def from_path(cls, path: str | Path) -> Self:
165
+ if isinstance(path, str):
166
+ path = Path(path)
167
+ ext = SupportedExt(path.suffix)
168
+ return cls(path=path, ext=ext)
169
+
170
+ def to_docling(self): # noqa: ANN201
171
+ from docling_core.types.io import DocumentStream # noqa: PLC0415
172
+
173
+ if self.content is not None:
174
+ return DocumentStream(name=str(self.path), stream=BytesIO(self.content))
175
+
176
+ if not self.path.suffix:
177
+ return DocumentStream(
178
+ name=str(self.path), stream=BytesIO(self.path.read_bytes())
179
+ )
180
+ return self.path
181
+
182
+ def without_content(self) -> Self:
183
+ return safe_copy(self, update={"content": None})
184
+
185
+
186
+ class PageIndexes(RootModel[list[tuple[int, int]]]):
187
+ # Stores page end index
188
+ @classmethod
189
+ def from_page_end_indices(cls, lengths: list[int]) -> Self:
190
+ return [
191
+ ((lengths[p - 1] if p > 0 else 0), lengths[p]) for p in range(len(lengths))
192
+ ]
193
+
194
+
195
+ class ConversionOutput(BaseModel):
196
+ path: Path
197
+ pages: PageIndexes = []
198
+
199
+
200
+ class MarkdownDoc(ConversionOutput):
201
+ @classmethod
202
+ @property
203
+ @cache
204
+ def _valid_conversion_statuses(cls) -> set:
205
+ from docling.datamodel.base_models import ConversionStatus # noqa: PLC0415
206
+
207
+ return {ConversionStatus.SUCCESS, ConversionStatus.PARTIAL_SUCCESS}
208
+
209
+
210
+ def _input_should_not_have_content(value: InputDoc) -> InputDoc:
211
+ if value.content is not None:
212
+ raise ValueError(f"response input can't have content, but got {value}")
213
+ return value
214
+
215
+
216
+ class _BaseResult(BaseModel, ABC):
217
+ input: InputDoc
218
+ status: Status
219
+ errors: list[Error] = []
220
+
221
+
222
+ class ResponseResult(_BaseResult):
223
+ input: Annotated[InputDoc, AfterValidator(func=_input_should_not_have_content)]
224
+ output_path: Path
225
+
226
+
227
+ class Result(_BaseResult):
228
+ # TODO: we could also use generics here when we add more output formats
229
+ output: ConversionOutput | None
230
+
231
+ def to_response(self) -> ResponseResult:
232
+ return ResponseResult(
233
+ input=self.input.without_content(),
234
+ status=self.status,
235
+ errors=self.errors,
236
+ output_path=self.output.path,
237
+ )
238
+
239
+
240
+ class ExtractionResponse(BaseModel):
241
+ results: list[ResponseResult]
242
+
243
+
244
+ _INPUT_DOCS_ADAPTER = TypeAdapter(list[InputDoc | Path])
245
+
246
+
247
+ def parse_extraction_request(
248
+ docs: str | list[dict | str], *, data_dir: Path
249
+ ) -> list[InputDoc]:
250
+ if isinstance(docs, str):
251
+ logger.debug("exploring files in %s", data_dir.absolute())
252
+ docs_dir = Path(data_dir) / docs
253
+ docs = _as_input_docs(docs_dir)
254
+ msg = "found %s"
255
+ if len(docs) > 10:
256
+ msg = msg + ", and more..."
257
+ logger.debug("found %s", docs[:10])
258
+ return docs
259
+ docs = _INPUT_DOCS_ADAPTER.validate_python(docs)
260
+ if not docs:
261
+ return []
262
+ if isinstance(docs[0], Path):
263
+ doc_meta = []
264
+ unknown_exts = []
265
+ for doc in docs:
266
+ _, ext = os.path.splitext(str(doc))
267
+ if not ext:
268
+ unknown_exts.append(doc)
269
+ else:
270
+ doc_meta.append(InputDoc.from_path(path=doc.relative_to(data_dir)))
271
+ if unknown_exts:
272
+ raise ValueError(f"found files with unknown extensions {unknown_exts}")
273
+ return doc_meta
274
+ return docs
275
+
276
+
277
+ def _raise(err: OSError) -> NoReturn:
278
+ raise err
279
+
280
+
281
+ def _as_input_docs(
282
+ docs_dir: Path, *, supported_ext: set[str] | None = None
283
+ ) -> list[InputDoc]:
284
+ if supported_ext is None:
285
+ supported_ext = {v.value for v in SupportedExt}
286
+ docs = []
287
+ for root, _, files in os.walk(docs_dir, onerror=_raise):
288
+ root = Path(root) # noqa: PLW2901
289
+ for f in files:
290
+ ext = Path(f).suffix
291
+ if not ext or ext not in supported_ext:
292
+ continue
293
+ docs.append(InputDoc.from_path(path=root / f))
294
+ docs = sorted(docs, key=lambda x: x.path)
295
+ return docs
@@ -0,0 +1,14 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import AsyncGenerator, Iterable
3
+ from pathlib import Path
4
+
5
+ from icij_common.registrable import RegistrableFromConfig
6
+
7
+ from .objects import InputDoc, OutputFormat, Result
8
+
9
+
10
+ class Pipeline(RegistrableFromConfig, ABC):
11
+ @abstractmethod
12
+ async def extract_content(
13
+ self, docs: Iterable[InputDoc], output_format: OutputFormat, output_path: Path
14
+ ) -> AsyncGenerator[Result, None]: ...
extract_core/utils.py ADDED
@@ -0,0 +1,9 @@
1
+ from typing import TypeVar
2
+
3
+ T = TypeVar("T")
4
+
5
+
6
+ def all_subclasses(cls: type[T]) -> set[type[T]]:
7
+ return set(cls.__subclasses__()).union(
8
+ [s for c in cls.__subclasses__() for s in all_subclasses(c)]
9
+ )
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: extract-core
3
+ Version: 0.5.4
4
+ Summary: Core of extract-python
5
+ Project-URL: Homepage, https://github.com/ICIJ/extract-python
6
+ Project-URL: Repository, https://github.com/ICIJ/extract-python
7
+ Project-URL: Issues, https://github.com/ICIJ/extract-python/issues
8
+ Author-email: Clément Doumouro <cdoumouro@icij.org>
9
+ Requires-Python: <3.14,>=3.11
10
+ Requires-Dist: docling-slim~=2.96
11
+ Requires-Dist: icij-common~=0.8.2
12
+ Requires-Dist: marker-pdf~=1.10
13
+ Requires-Dist: mineru~=3.2
14
+ Requires-Dist: pydantic-extra-types[pycountry]>=2.11.1
15
+ Requires-Dist: pydantic~=2.13
@@ -0,0 +1,11 @@
1
+ extract_core/__init__.py,sha256=ClGxkohwwJDt75FJoIfje23FuZNrXiEmA4FOu3kNcgY,1449
2
+ extract_core/configs.py,sha256=jTg9eqT4ifqZopjVvQiNqYa88EbW6nmiFXV7O24rTOg,781
3
+ extract_core/docling_.py,sha256=qxJHrQkoSUGunZTbedmeioFuMApavgM73QHc8NW9SKU,8987
4
+ extract_core/marker_.py,sha256=IPHOlxJ6yLw4dkm-BnFEJWrma6_ItqW0m7L1w8_KpLI,1151
5
+ extract_core/miner_u.py,sha256=q7gLPqVRSW1QdVUgPiBoBibLncm9GbmdfQI8yMRFzpk,2516
6
+ extract_core/objects.py,sha256=UvjbPN--kbHOz5KXcCWvboBpIwilvVajndvdp-Og5Tc,7950
7
+ extract_core/pipeline.py,sha256=Q5ZEBM3J50B2pQT2E-uhSIpd1i1tlsb47WfqBAdsFZ8,453
8
+ extract_core/utils.py,sha256=MaxiOac9YS_fBU_-DhFFSLoEC_VrRuSR96-9M99-NTg,216
9
+ extract_core-0.5.4.dist-info/METADATA,sha256=uN4ejrGJeJ7bFtQQ0F3dAUywy113c2EI4JE4Qgb7x4k,576
10
+ extract_core-0.5.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ extract_core-0.5.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any