HowdenParser 2.0.3__tar.gz → 3.1.0__tar.gz

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.
@@ -4,8 +4,8 @@ import logging
4
4
  from pathlib import Path
5
5
  from PyPDF2 import PdfReader
6
6
  import dotenv
7
- import logging
8
- from inspect import signature
7
+ from typing import overload, Literal
8
+ from inspect import signature, Signature
9
9
 
10
10
  dotenv.load_dotenv()
11
11
  logger = logging.getLogger(__name__)
@@ -14,6 +14,10 @@ logger = logging.getLogger(__name__)
14
14
  class BaseParser(ABC):
15
15
  _registry: dict[str, type["BaseParser"]] = {}
16
16
 
17
+ def __init__(self):
18
+ # Every instance gets a .name attribute
19
+ self.name = getattr(self.__class__, "_parser_name", self.__class__.__name__)
20
+
17
21
  def __init_subclass__(cls, name: str | None = None, **kwargs):
18
22
  """Automatically register subclasses under a key."""
19
23
  super().__init_subclass__(**kwargs)
@@ -29,6 +33,13 @@ class BaseParser(ABC):
29
33
  class Parser(BaseParser):
30
34
  """Factory + registry interface for all parsers."""
31
35
 
36
+ def __new__(cls, config_or_dict: "Parameter | dict | str" = None, **kwargs) -> BaseParser:
37
+ """
38
+ Factory entrypoint.
39
+ Allows calling Parser(...) directly to create the correct subclass.
40
+ """
41
+ return cls._create(config_or_dict, **kwargs)
42
+
32
43
  @classmethod
33
44
  def available_parsers(cls) -> None:
34
45
  """Print registered parsers and their init arguments."""
@@ -37,53 +48,130 @@ class Parser(BaseParser):
37
48
  for name, parser_cls in BaseParser._registry.items():
38
49
  sig = inspect.signature(parser_cls.__init__)
39
50
  result[name] = [p for p in sig.parameters if p != "self"]
40
- result.pop('', None)
51
+ result.pop("", None)
41
52
  for key, values in result.items():
42
53
  print(f"{key} with parameters: {values}")
43
54
 
55
+ # ---- Overloads for IDE autocomplete ----
56
+ @overload
57
+ @classmethod
58
+ def _create(
59
+ cls,
60
+ *,
61
+ provider_and_model: Literal["llamaparser:"],
62
+ result_type: str,
63
+ mode: bool,
64
+ preserve_layout_alignment_across_pages: bool,
65
+ merge_tables_across_pages_in_markdown: bool
66
+ ) -> "LlamaParser": ...
67
+
68
+ @overload
69
+ @classmethod
70
+ def _create(
71
+ cls,
72
+ *,
73
+ provider_and_model: Literal["mistralocr:ocr-large", "mistralocr:ocr-small"],
74
+ ) -> "MistralOCRParser": ...
75
+
76
+ @overload
77
+ @classmethod
78
+ def _create(
79
+ cls,
80
+ *,
81
+ provider_and_model: Literal["langchain:gpt-3.5-turbo", "langchain:gpt-4"],
82
+ ) -> "LangChainParser": ...
83
+
84
+ @overload
44
85
  @classmethod
45
- def create(cls, config_or_dict: "Parameter | dict", **kwargs) -> BaseParser:
86
+ def _create(
87
+ cls,
88
+ *,
89
+ provider_and_model: Literal[
90
+ "huggingface:microsoft/trocr-base-handwritten",
91
+ "huggingface:microsoft/trocr-large-printed",
92
+ ],
93
+ ) -> "HuggingFaceParser": ...
94
+
95
+ # ---- Implementation ----
96
+ @classmethod
97
+ def _create(cls, config_or_dict: "Parameter | dict | str" = None, **kwargs) -> BaseParser:
46
98
  """
47
- Dynamically create parser instances from a config object or dict.
48
- Only passes arguments accepted by the parser constructor.
99
+ Dynamically create parser instances.
100
+ Supports: Parameter, dict, str (provider_and_model), or kwargs.
49
101
  """
50
- # Convert Parameter -> dict if needed
51
- if hasattr(config_or_dict, "model_dump"):
102
+
103
+ if config_or_dict is None:
104
+ config_dict = {}
105
+ elif hasattr(config_or_dict, "model_dump"):
52
106
  config_dict = config_or_dict.model_dump()
53
107
  elif isinstance(config_or_dict, dict):
54
108
  config_dict = config_or_dict
109
+ elif isinstance(config_or_dict, str): # shorthand
110
+ config_dict = {"provider_and_model": config_or_dict}
55
111
  else:
56
- raise TypeError("Expected Parameter instance or dict for config_or_dict")
57
-
58
- if "provider_and_model" not in kwargs and "provider_and_model" not in config_dict:
59
- raise ValueError("provider_and_model must be specified")
112
+ raise TypeError("Expected Parameter instance, dict, str, or None for config_or_dict")
60
113
 
61
- # Merge dict + kwargs (kwargs take precedence)
62
114
  merged_args = {**config_dict, **kwargs}
63
115
 
64
- # Extract provider/model info
65
- provider_and_model = merged_args.get("provider_and_model")
66
- provider, model = provider_and_model.split(":")
67
- provider = provider.lower()
68
- model = model.lower()
116
+ if "provider_and_model" not in merged_args:
117
+ raise ValueError("provider_and_model must be specified, e.g. 'llamaparser:'")
118
+
119
+ provider_and_model = str(merged_args.get("provider_and_model")).strip()
120
+
121
+ if ":" not in provider_and_model:
122
+ raise ValueError("provider_and_model must include a colon, e.g. 'llamaparser:'")
123
+
124
+ provider, model = provider_and_model.split(":", 1)
125
+ provider = (provider or "").strip().lower()
126
+ model = (model or "").strip().lower()
127
+
128
+ if not provider:
129
+ raise ValueError(f"Invalid provider_and_model '{provider_and_model}': provider part is empty.")
69
130
 
70
131
  if provider not in BaseParser._registry:
71
132
  raise ValueError(f"Unknown parser '{provider}'. Available: {list(BaseParser._registry)}")
72
133
 
73
134
  parser_cls = BaseParser._registry[provider]
74
135
 
75
- # Inspect constructor and only pass valid arguments
136
+ # --- Filter valid constructor args ---
76
137
  sig = signature(parser_cls.__init__)
77
138
  valid_args = {k: v for k, v in merged_args.items() if k in sig.parameters and k != "self"}
78
139
 
79
- # Auto-insert 'model' if required
140
+ # Auto-fill common args
80
141
  if "model" in sig.parameters and "model" not in valid_args:
81
142
  valid_args["model"] = model
82
-
83
- # Auto-insert 'provider_and_model' if required
84
143
  if "provider_and_model" in sig.parameters and "provider_and_model" not in valid_args:
85
144
  valid_args["provider_and_model"] = provider_and_model
86
145
 
146
+ # --- Check required args ---
147
+ required_params = [
148
+ p.name
149
+ for p in sig.parameters.values()
150
+ if p.name != "self"
151
+ and p.default is Signature.empty
152
+ and p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)
153
+ ]
154
+ missing = [name for name in required_params if name not in valid_args]
155
+
156
+ # Special rule: for llamaparser we allow empty `model`
157
+ if provider != "llamaparser" and not model:
158
+ missing.append("model")
159
+
160
+ if missing:
161
+ example = ""
162
+ if provider == "llamaparser":
163
+ example = "Example: Parser('llamaparser:', result_type='md', mode=True, extract_tables=True)"
164
+ elif provider == "mistralocr":
165
+ example = "Example: Parser('mistralocr:ocr-large')"
166
+ elif provider == "huggingface":
167
+ example = "Example: Parser('huggingface:microsoft/trocr-base-handwritten')"
168
+ elif provider == "langchain":
169
+ example = "Example: Parser('langchain:gpt-3.5-turbo')"
170
+
171
+ raise TypeError(
172
+ f"{parser_cls.__name__} cannot be created because it is missing required argument(s): {', '.join(missing)}. {example}"
173
+ )
174
+
87
175
  return parser_cls(**valid_args)
88
176
 
89
177
  @abstractmethod
@@ -93,24 +181,21 @@ class Parser(BaseParser):
93
181
 
94
182
  # --- Parsers ---
95
183
  class MistralOCRParser(BaseParser, name="mistralocr"):
96
- def __init__(self,provider_and_model:str) -> None:
184
+ def __init__(self, provider_and_model: str) -> None:
97
185
  from mistralai import Mistral
98
-
99
186
  self.model = provider_and_model.split(":")[1]
100
187
  self.current_cost: float = 0.0
101
188
  self.total_cost_euro: float = 0.0
102
-
103
189
  api_key = os.getenv("MISTRAL-OCR-API-TOKEN")
104
190
  if not api_key:
105
191
  raise EnvironmentError("Missing MISTRAL-OCR-API-TOKEN in .env file.")
106
-
107
192
  self.client = Mistral(api_key=api_key)
108
193
 
109
194
  def parse(self, file_path: Path) -> str:
110
195
  def upload_pdf(filename):
111
196
  uploaded_pdf = self.client.files.upload(
112
197
  file={"file_name": filename, "content": open(filename, "rb")},
113
- purpose="ocr"
198
+ purpose="ocr",
114
199
  )
115
200
  signed_url = self.client.files.get_signed_url(file_id=uploaded_pdf.id)
116
201
  return signed_url.url
@@ -120,14 +205,11 @@ class MistralOCRParser(BaseParser, name="mistralocr"):
120
205
  document={"type": "document_url", "document_url": upload_pdf(file_path)},
121
206
  include_image_base64=True,
122
207
  )
123
-
124
208
  self.current_cost = 1 / 1000 * self._count_pages(file_path)
125
209
  self.total_cost_euro += self.current_cost
126
-
127
210
  return "\n".join(doc.markdown for doc in ocr_response.pages)
128
211
 
129
212
  def __call__(self, file_path: Path) -> str:
130
- # When instance is called, it delegates to parse
131
213
  return self.parse(file_path)
132
214
 
133
215
  @staticmethod
@@ -147,52 +229,52 @@ class LangChainParser(BaseParser, name="langchain"):
147
229
  return {"source": "LangChain", "output": response}
148
230
 
149
231
  def __call__(self, file_path: Path) -> str:
150
- # When instance is called, it delegates to parse
151
232
  return self.parse(file_path)
152
233
 
153
234
 
154
235
  class LlamaParser(BaseParser, name="llamaparser"):
155
- def __init__(self, result_type: str, mode: bool, provider_and_model) -> None:
236
+ def __init__(self, result_type: str,
237
+ mode: bool,
238
+ provider_and_model: str,
239
+ merge_tables_across_pages_in_markdown: bool,
240
+ preserve_layout_alignment_across_pages: bool) -> None:
156
241
  logging.info("Initializing LlamaParser...")
157
-
158
242
  self.result_type = result_type
159
243
  self.mode = mode
160
244
  self.provider_and_model = provider_and_model
245
+ self.merge_tables_across_pages_in_markdown = merge_tables_across_pages_in_markdown
246
+ self.preserve_layout_alignment_across_pages = preserve_layout_alignment_across_pages
161
247
 
162
248
  from llama_parse import LlamaParse, ResultType
163
-
164
249
  if result_type.lower() in ("md", "markdown"):
165
250
  result_type = ResultType.MD
166
-
167
251
  api_key = os.getenv("LLAMA-PARSER-API-TOKEN")
168
252
  if not api_key:
169
253
  raise EnvironmentError("Missing LLAMA-PARSER-API-TOKEN in .env file.")
170
-
171
- self._parser = LlamaParse(api_key=api_key, result_type=result_type, premium_mode=self.mode)
254
+ self._parser = LlamaParse(api_key=api_key,
255
+ result_type=result_type,
256
+ premium_mode=self.mode,
257
+ merge_tables_across_pages_in_markdown=self.merge_tables_across_pages_in_markdown,
258
+ preserve_layout_alignment_across_pages=self.preserve_layout_alignment_across_pages)
172
259
 
173
260
  def parse(self, file_path: Path) -> str:
174
261
  documents = self._parser.load_data(str(file_path))
175
262
  return "\n".join(doc.text for doc in documents)
176
263
 
177
264
  def __call__(self, file_path: Path) -> str:
178
- # When instance is called, it delegates to parse
179
265
  return self.parse(file_path)
180
266
 
181
267
 
182
268
  class HuggingFaceParser(BaseParser, name="huggingface"):
183
269
  def __init__(self, provider_and_model: str) -> None:
184
270
  from transformers import TrOCRProcessor, VisionEncoderDecoderModel
185
-
186
271
  api_key = os.getenv("HF-API-TOKEN")
187
272
  if not api_key:
188
273
  raise EnvironmentError("Missing HF-API-TOKEN in .env file.")
189
-
190
274
  model_name = provider_and_model.split(":")[1]
191
275
  logger.info(f"Loading Hugging Face OCR model: {model_name}")
192
-
193
276
  self.processor = TrOCRProcessor.from_pretrained(model_name, token=api_key)
194
277
  self.model = VisionEncoderDecoderModel.from_pretrained(model_name, token=api_key)
195
-
196
278
  logger.info("Model and processor loaded successfully.")
197
279
 
198
280
  def parse(self, file_path: Path) -> str:
@@ -208,9 +290,6 @@ class HuggingFaceParser(BaseParser, name="huggingface"):
208
290
  generated_ids = self.model.generate(pixel_values)
209
291
  text = self.processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
210
292
  all_text += text + "\n"
211
- logger.debug(f"OCR text (page {i}): {text[:100]}...") # preview first 100 chars
212
-
293
+ logger.debug(f"OCR text (page {i}): {text[:100]}...")
213
294
  logger.info("OCR completed for all pages.")
214
295
  return all_text
215
-
216
-
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: HowdenParser
3
- Version: 2.0.3
3
+ Version: 3.1.0
4
4
  Summary: A simple configuration manager with Pydantic and JSON export.
5
5
  License: MIT
6
6
  Keywords: config,configuration,pydantic,json
@@ -14,7 +14,7 @@ build-backend = "poetry.core.masonry.api"
14
14
 
15
15
  [tool.poetry]
16
16
  name = "HowdenParser"
17
- version = "2.0.3"
17
+ version = "3.1.0"
18
18
  description = "A simple configuration manager with Pydantic and JSON export."
19
19
  authors = [ "JesperThoftIllemannJ <jesper.jaeger@howdendanmark.dk>",]
20
20
  readme = "README.md"
File without changes