grobid-client-python 0.1.5__py3-none-any.whl → 0.2.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.
grobid_client/client.py CHANGED
@@ -1,6 +1,10 @@
1
1
  """ Generic API Client """
2
+ from __future__ import annotations
3
+
2
4
  from copy import deepcopy
3
5
  import json
6
+ from typing import Any, Optional, Tuple
7
+
4
8
  import requests
5
9
 
6
10
  try:
@@ -16,12 +20,17 @@ class ApiClient(object):
16
20
  service methods, i.e. ``get``, ``post``, ``put`` and ``delete``.
17
21
  """
18
22
 
19
- accept_type = "application/xml"
20
- api_base = None
23
+ accept_type: str = "application/xml"
24
+ api_base: Optional[str] = None
21
25
 
22
26
  def __init__(
23
- self, base_url, username=None, api_key=None, status_endpoint=None, timeout=60
24
- ):
27
+ self,
28
+ base_url: str,
29
+ username: Optional[str] = None,
30
+ api_key: Optional[str] = None,
31
+ status_endpoint: Optional[str] = None,
32
+ timeout: int = 60,
33
+ ) -> None:
25
34
  """Initialise client.
26
35
 
27
36
  Args:
@@ -37,7 +46,7 @@ class ApiClient(object):
37
46
  self.timeout = timeout
38
47
 
39
48
  @staticmethod
40
- def encode(request, data):
49
+ def encode(request: Any, data: Optional[dict]) -> Any:
41
50
  """Add request content data to request body, set Content-type header.
42
51
 
43
52
  Should be overridden by subclasses if not using JSON encoding.
@@ -58,7 +67,7 @@ class ApiClient(object):
58
67
  return request
59
68
 
60
69
  @staticmethod
61
- def decode(response):
70
+ def decode(response: Any) -> Any:
62
71
  """Decode the returned data in the response.
63
72
 
64
73
  Should be overridden by subclasses if something else than JSON is
@@ -73,9 +82,9 @@ class ApiClient(object):
73
82
  try:
74
83
  return response.json()
75
84
  except ValueError as e:
76
- return e.message
85
+ return e.message # type: ignore[attr-defined] # pre-existing (Python 2 style)
77
86
 
78
- def get_credentials(self):
87
+ def get_credentials(self) -> dict:
79
88
  """Returns parameters to be added to authenticate the request.
80
89
 
81
90
  This lives on its own to make it easier to re-implement it if needed.
@@ -87,14 +96,14 @@ class ApiClient(object):
87
96
 
88
97
  def call_api(
89
98
  self,
90
- method,
91
- url,
92
- headers=None,
93
- params=None,
94
- data=None,
95
- files=None,
96
- timeout=None,
97
- ):
99
+ method: str,
100
+ url: str,
101
+ headers: Optional[dict] = None,
102
+ params: Optional[dict] = None,
103
+ data: Optional[dict] = None,
104
+ files: Optional[dict] = None,
105
+ timeout: Optional[int] = None,
106
+ ) -> Tuple[requests.Response, int]:
98
107
  """Call API.
99
108
 
100
109
  This returns object containing data, with error details if applicable.
@@ -130,7 +139,7 @@ class ApiClient(object):
130
139
 
131
140
  return r, r.status_code
132
141
 
133
- def get(self, url, params=None, **kwargs):
142
+ def get(self, url: str, params: Optional[dict] = None, **kwargs: Any) -> Tuple[requests.Response, int]:
134
143
  """Call the API with a GET request.
135
144
 
136
145
  Args:
@@ -142,7 +151,7 @@ class ApiClient(object):
142
151
  """
143
152
  return self.call_api("GET", url, params=params, **kwargs)
144
153
 
145
- def delete(self, url, params=None, **kwargs):
154
+ def delete(self, url: str, params: Optional[dict] = None, **kwargs: Any) -> Tuple[requests.Response, int]:
146
155
  """Call the API with a DELETE request.
147
156
 
148
157
  Args:
@@ -154,7 +163,14 @@ class ApiClient(object):
154
163
  """
155
164
  return self.call_api("DELETE", url, params=params, **kwargs)
156
165
 
157
- def put(self, url, params=None, data=None, files=None, **kwargs):
166
+ def put(
167
+ self,
168
+ url: str,
169
+ params: Optional[dict] = None,
170
+ data: Optional[dict] = None,
171
+ files: Optional[dict] = None,
172
+ **kwargs: Any,
173
+ ) -> Tuple[requests.Response, int]:
158
174
  """Call the API with a PUT request.
159
175
 
160
176
  Args:
@@ -170,7 +186,14 @@ class ApiClient(object):
170
186
  "PUT", url, params=params, data=data, files=files, **kwargs
171
187
  )
172
188
 
173
- def post(self, url, params=None, data=None, files=None, **kwargs):
189
+ def post(
190
+ self,
191
+ url: str,
192
+ params: Optional[dict] = None,
193
+ data: Optional[dict] = None,
194
+ files: Optional[dict] = None,
195
+ **kwargs: Any,
196
+ ) -> Tuple[requests.Response, int]:
174
197
  """Call the API with a POST request.
175
198
 
176
199
  Args:
@@ -186,7 +209,7 @@ class ApiClient(object):
186
209
  method="POST", url=url, params=params, data=data, files=files, **kwargs
187
210
  )
188
211
 
189
- def service_status(self, **kwargs):
212
+ def service_status(self, **kwargs: Any) -> Tuple[requests.Response, int]:
190
213
  """Call the API to get the status of the service.
191
214
 
192
215
  Returns:
@@ -4,6 +4,8 @@
4
4
 
5
5
  Original version: https://github.com/howisonlab/softcite-dataset/blob/master/code/corpus/TEI2LossyJSON.py
6
6
  """
7
+ from __future__ import annotations
8
+
7
9
  import logging
8
10
  import os
9
11
  import uuid
@@ -12,13 +14,13 @@ from concurrent.futures import ProcessPoolExecutor, as_completed
12
14
  import html
13
15
  import re
14
16
  from pathlib import Path
15
- from typing import Dict, Union, BinaryIO, Iterator
17
+ from typing import Any, Dict, Union, BinaryIO, Iterator
16
18
 
17
19
  import dateparser
18
20
  from bs4 import BeautifulSoup, Tag
19
21
 
20
22
  # Configure module-level logger
21
- logger = logging.getLogger(__name__)
23
+ logger: logging.Logger = logging.getLogger(__name__)
22
24
  logger.propagate = False # Prevent propagation to avoid duplicate logs
23
25
 
24
26
  # Only configure basic logging if nothing is set up yet
@@ -35,10 +37,10 @@ class TEI2LossyJSONConverter:
35
37
  The class also provides utilities to process a directory of TEI files in parallel and in batches.
36
38
  """
37
39
 
38
- def __init__(self, validate_refs: bool = True):
39
- self.validate_refs = validate_refs
40
+ def __init__(self, validate_refs: bool = True) -> None:
41
+ self.validate_refs: bool = validate_refs
40
42
 
41
- def convert_tei_file(self, tei_file: Union[Path, BinaryIO], stream: bool = False):
43
+ def convert_tei_file(self, tei_file: Union[str, Path, BinaryIO], stream: bool = False) -> Any:
42
44
  """Backward-compatible function. If stream=True returns a generator that yields passages (dicts).
43
45
  If stream=False returns the full document dict (same shape as original function).
44
46
  """
@@ -226,7 +228,7 @@ class TEI2LossyJSONConverter:
226
228
 
227
229
  return document
228
230
 
229
- def _extract_comprehensive_reference_data(self, bibl_struct: Tag, index: int) -> Dict:
231
+ def _extract_comprehensive_reference_data(self, bibl_struct: Tag, index: int) -> dict[str, Any] | None:
230
232
  """
231
233
  Extract detailed bibliographic information from TEI biblStruct elements.
232
234
  Implements comprehensive parsing for all standard TEI bibliographic components.
@@ -241,11 +243,11 @@ class TEI2LossyJSONConverter:
241
243
  citation_data['target'] = xml_id
242
244
 
243
245
  # Initialize containers for different types of content
244
- contributor_list = []
245
- publication_metadata = {}
246
- identifier_collection = {}
247
- supplementary_info = []
248
- link_references = []
246
+ contributor_list: list[dict[str, Any]] = []
247
+ publication_metadata: dict[str, Any] = {}
248
+ identifier_collection: dict[str, Any] = {}
249
+ supplementary_info: list[str] = []
250
+ link_references: list[str] = []
249
251
 
250
252
  # 1. Process analytic level information (article/conference paper content)
251
253
  analytic_section = bibl_struct.find("analytic")
@@ -376,9 +378,9 @@ class TEI2LossyJSONConverter:
376
378
 
377
379
  return None
378
380
 
379
- def _extract_contributor_details(self, contributor_element: Tag) -> Dict:
381
+ def _extract_contributor_details(self, contributor_element: Tag) -> dict[str, Any] | None:
380
382
  """Extract detailed information about authors, editors, and other contributors."""
381
- contributor_info = {}
383
+ contributor_info: dict[str, Any] = {}
382
384
 
383
385
  # Extract name components
384
386
  surname_element = contributor_element.find("surname")
@@ -413,7 +415,7 @@ class TEI2LossyJSONConverter:
413
415
 
414
416
  return contributor_info if contributor_info.get('name') else None
415
417
 
416
- def _process_identifier_element(self, identifier_element: Tag, identifier_collection: Dict, level: str):
418
+ def _process_identifier_element(self, identifier_element: Tag, identifier_collection: dict[str, Any], level: str) -> None:
417
419
  """Process identifier elements (DOI, ISBN, ISSN, etc.) and organize by type and level."""
418
420
  identifier_text = self._clean_text(identifier_element.get_text())
419
421
  identifier_type = identifier_element.get("type", "").lower()
@@ -430,13 +432,13 @@ class TEI2LossyJSONConverter:
430
432
  else:
431
433
  identifier_collection[level_key]['unknown'] = identifier_text
432
434
 
433
- def _process_pointer_element(self, pointer_element: Tag, link_references: list):
435
+ def _process_pointer_element(self, pointer_element: Tag, link_references: list[str]) -> None:
434
436
  """Process pointer elements that contain external links."""
435
437
  pointer_target = pointer_element.get("target", "").strip()
436
438
  if pointer_target:
437
439
  link_references.append(pointer_target)
438
440
 
439
- def _process_imprint_details(self, imprint_element: Tag, publication_metadata: Dict):
441
+ def _process_imprint_details(self, imprint_element: Tag, publication_metadata: dict[str, Any]) -> None:
440
442
  """Extract and process imprint information including publisher, dates, and page ranges."""
441
443
 
442
444
  # Extract publisher information
@@ -494,9 +496,9 @@ class TEI2LossyJSONConverter:
494
496
  elif scope_unit == "chapter":
495
497
  publication_metadata['chapter'] = scope_text
496
498
 
497
- def _compile_citation_data(self, citation_data: Dict, contributors: list,
498
- publication_metadata: Dict, identifiers: Dict,
499
- supplementary_info: list, links: list):
499
+ def _compile_citation_data(self, citation_data: dict[str, Any], contributors: list[dict[str, Any]],
500
+ publication_metadata: dict[str, Any], identifiers: dict[str, Any],
501
+ supplementary_info: list[str], links: list[str]) -> None:
500
502
  """Compile all extracted information into the final citation structure."""
501
503
  # Process contributors
502
504
  if contributors:
@@ -546,7 +548,7 @@ class TEI2LossyJSONConverter:
546
548
  else:
547
549
  citation_data['urls'] = links
548
550
 
549
- def _validate_citation_content(self, citation_data: Dict) -> bool:
551
+ def _validate_citation_content(self, citation_data: dict[str, Any]) -> bool:
550
552
  """Validate that the citation contains meaningful information."""
551
553
  # Check for essential bibliographic elements
552
554
  essential_elements = ['title', 'authors', 'journal', 'doi', 'isbn', 'issn', 'pmc', 'pmid']
@@ -559,13 +561,13 @@ class TEI2LossyJSONConverter:
559
561
 
560
562
  return has_essential or has_fallback
561
563
 
562
- def _extract_person_data(self, person_element: Tag) -> Dict:
564
+ def _extract_person_data(self, person_element: Tag) -> dict[str, Any] | None:
563
565
  """
564
566
  Extract person data (author/editor) from TEI persName or author elements.
565
567
  Handles various name formats and affiliations.
566
568
  """
567
569
 
568
- person_data = {}
570
+ person_data: dict[str, Any] = {}
569
571
 
570
572
  # Try different name extraction methods
571
573
  forename = person_element.find("forename")
@@ -691,7 +693,7 @@ class TEI2LossyJSONConverter:
691
693
  head_paragraph = None
692
694
 
693
695
 
694
- def _process_div_with_nested_content(self, div: Tag, passage_level: str, head_paragraph: str = None) -> Iterator[Dict[str, Union[str, Dict[str, str]]]]:
696
+ def _process_div_with_nested_content(self, div: Tag, passage_level: str, head_paragraph: str | None = None) -> Iterator[Dict[str, Union[str, Dict[str, str]]]]:
695
697
  """
696
698
  Process a div and its nested content, handling various back section types.
697
699
  Supports nested divs for complex back sections like annex with multiple subsections.
@@ -813,7 +815,7 @@ class TEI2LossyJSONConverter:
813
815
  if current_head_paragraph is not None:
814
816
  head_paragraph = current_head_paragraph
815
817
 
816
- def process_directory(self, directory: Union[str, Path], pattern: str = "*.tei.xml", parallel: bool = True, workers: int = None) -> Iterator[Dict]:
818
+ def process_directory(self, directory: Union[str, Path], pattern: str = "*.tei.xml", parallel: bool = True, workers: int | None = None) -> Iterator[Dict]:
817
819
  """Process a directory of TEI files and yield converted documents.
818
820
  When parallel=True this uses ProcessPoolExecutor to parallelize file-level conversion.
819
821
  Each yielded item is a dict with keys: 'path' and 'document' (document may be None on parse error).
@@ -839,7 +841,7 @@ class TEI2LossyJSONConverter:
839
841
  yield {"path": f, "document": doc}
840
842
 
841
843
 
842
- def _convert_file_worker(path: str):
844
+ def _convert_file_worker(path: str) -> Any:
843
845
  """Worker used by ProcessPoolExecutor. Imports inside function to avoid pickling issues."""
844
846
  from bs4 import BeautifulSoup
845
847
  # Reuse existing top-level helpers from this module by importing here
@@ -850,7 +852,7 @@ def _convert_file_worker(path: str):
850
852
  return converter.convert_tei_file(path, stream=False)
851
853
 
852
854
 
853
- def box_to_dict(coord_list):
855
+ def box_to_dict(coord_list: list[str]) -> dict[str, float]:
854
856
  """Convert coordinate list to dictionary format."""
855
857
  if len(coord_list) >= 4:
856
858
  return {
@@ -862,105 +864,141 @@ def box_to_dict(coord_list):
862
864
  return {}
863
865
 
864
866
 
865
- def get_random_id(prefix=""):
867
+ def get_random_id(prefix: str = "") -> str:
866
868
  """Generate a random ID with optional prefix."""
867
869
  return f"{prefix}{uuid.uuid4().hex[:8]}"
868
870
 
869
871
 
870
872
  def get_refs_with_offsets(element):
871
- """Extract references with their text offsets from an element."""
873
+ """Extract references with their text offsets from an element.
874
+
875
+ Uses a deterministic character-level index map from raw to cleaned text,
876
+ so offsets are always exact — no fuzzy search needed.
877
+ """
872
878
  refs = []
873
879
 
874
- # Apply the same text cleaning as get_formatted_passage
875
- def _clean_text(text: str) -> str:
880
+ def _clean_text(text):
876
881
  if not text:
877
882
  return ""
878
883
  text = re.sub(r'\s+', ' ', text.strip())
879
- text = html.unescape(text)
884
+ # DO NOT call html.unescape(text). BeautifulSoup get_text() already unescapes
885
+ # standard XML entities. Calling html.unescape causes aggressive conversion of
886
+ # malformed text (e.g., "&ltniss" without semicolon -> "<niss"), which shrinks
887
+ # the string and silently breaks all subsequent offsets.
880
888
  return text
881
889
 
882
- # Now extract references with offsets based on the cleaned text
883
- def traverse_and_collect(node, current_pos=0):
884
- """
885
- Recursively traverse the DOM tree, building cleaned text content and tracking exact positions.
886
- Returns tuple: (text_content, next_position)
890
+ def _build_clean_map(raw_text):
891
+ """Build cleaned text and a mapping array: raw_map[raw_idx] -> cleaned_idx.
892
+
893
+ Mimics the exact same cleaning as _clean_text:
894
+ 1. strip leading/trailing whitespace
895
+ 2. collapse internal whitespace runs to single space
896
+ (html.unescape is applied afterwards to the final string)
887
897
  """
898
+ n = len(raw_text)
899
+ raw_map = [0] * (n + 1)
900
+
901
+ # Find boundaries for strip()
902
+ left = 0
903
+ while left < n and raw_text[left].isspace():
904
+ left += 1
905
+ right = n - 1
906
+ while right >= 0 and raw_text[right].isspace():
907
+ right -= 1
908
+
909
+ if left > right:
910
+ return "", raw_map
911
+
912
+ out_chars = []
913
+ ci = 0 # current index in cleaned text
914
+ in_ws = False
915
+
916
+ for i in range(n):
917
+ if i < left or i > right:
918
+ raw_map[i] = ci
919
+ continue
920
+ ch = raw_text[i]
921
+ if ch.isspace():
922
+ if not in_ws:
923
+ out_chars.append(' ')
924
+ raw_map[i] = ci
925
+ ci += 1
926
+ in_ws = True
927
+ else:
928
+ raw_map[i] = ci
929
+ else:
930
+ out_chars.append(ch)
931
+ raw_map[i] = ci
932
+ ci += 1
933
+ in_ws = False
934
+
935
+ raw_map[n] = ci
936
+ return "".join(out_chars), raw_map
937
+
938
+ def traverse(node, pos=0):
939
+ """Walk the DOM tree in document order, collecting raw text positions for refs."""
888
940
  if hasattr(node, 'name') and node.name:
889
- # This is an element node
890
941
  if node.name == "ref" and node.get("type") == "bibr":
891
- # Found a reference - get its cleaned text and record its exact position
892
- ref_text = _clean_text(node.get_text())
893
- if ref_text: # Only record non-empty references
942
+ ref_text = node.get_text()
943
+ if ref_text.strip():
894
944
  refs.append({
895
945
  "type": node.get("type", ""),
896
946
  "target": node.get("target", ""),
897
- "text": ref_text,
898
- "offset_start": current_pos,
899
- "offset_end": current_pos + len(ref_text)
947
+ "raw_text": ref_text,
948
+ "raw_start": pos,
949
+ "raw_end": pos + len(ref_text),
900
950
  })
901
- # Return the cleaned reference text and advance position
902
- return ref_text, current_pos + len(ref_text)
951
+ return ref_text, pos + len(ref_text)
903
952
  else:
904
- # Process children in document order and accumulate their cleaned text
905
- text_parts = []
906
- pos = current_pos
953
+ parts = []
907
954
  for child in node.children:
908
- child_text, new_pos = traverse_and_collect(child, pos)
955
+ child_text, pos = traverse(child, pos)
909
956
  if child_text is not None:
910
- text_parts.append(child_text)
911
- pos = new_pos
912
- return "".join(text_parts), pos
957
+ parts.append(child_text)
958
+ return "".join(parts), pos
913
959
  else:
914
- # This is a text node (NavigableString) - be more careful with cleaning
915
- text_content = str(node)
916
-
917
- # For text nodes, we need to be more careful about whitespace
918
- # Only apply the full cleaning at the end for the complete text
919
- return text_content, current_pos + len(text_content)
960
+ s = str(node)
961
+ return s, pos + len(s)
920
962
 
921
- # Build raw text with accurate positions first
922
- raw_text, _ = traverse_and_collect(element, 0)
963
+ # 1. Collect raw text and raw ref positions
964
+ raw_text, _ = traverse(element, 0)
923
965
 
924
- # Now apply the same cleaning as get_formatted_passage to the complete text
925
- final_text = _clean_text(raw_text)
966
+ # 2. Build the index map
967
+ cleaned, raw_map = _build_clean_map(raw_text)
926
968
 
927
- # Adjust all reference offsets to match the cleaned text
969
+ # 3. Map each ref's raw offsets to cleaned offsets (deterministic, no search)
928
970
  final_refs = []
929
971
  for ref in refs:
930
- # Find the reference text in the cleaned text to get correct offsets
931
- ref_text = ref['text']
932
-
933
- # The reference text was also cleaned, so we need to find it in the final cleaned text
934
- # We can search around the original position to find the correct occurrence
935
- search_start = max(0, ref['offset_start'] - 10) # Look a bit before the original position
936
- search_end = min(len(final_text), ref['offset_end'] + 10) # Look a bit after
937
- search_area = final_text[search_start:search_end]
938
-
939
- # Find the reference in the search area
940
- relative_pos = search_area.find(ref_text)
941
- if relative_pos != -1:
942
- final_start = search_start + relative_pos
943
- final_end = final_start + len(ref_text)
944
-
945
- final_refs.append({
946
- "type": ref["type"],
947
- "target": ref["target"],
948
- "text": ref_text,
949
- "offset_start": final_start,
950
- "offset_end": final_end
951
- })
972
+ mapped_start = raw_map[ref["raw_start"]]
973
+ mapped_end = raw_map[ref["raw_end"]]
974
+
975
+ # Extract the text directly from the cleaned string using the precise offsets.
976
+ # This absolutely guarantees that struct['text'][start:end] == ref['text'].
977
+ exact_text = cleaned[mapped_start:mapped_end]
978
+
979
+ # Sometimes a ref is entirely whitespace and gets collapsed to empty string
980
+ if not exact_text.strip():
981
+ continue
982
+
983
+ final_refs.append({
984
+ "type": ref["type"],
985
+ "target": ref["target"],
986
+ "text": exact_text,
987
+ "offset_start": mapped_start,
988
+ "offset_end": mapped_end,
989
+ })
952
990
 
953
991
  return final_refs
954
992
 
955
993
 
956
- def get_formatted_passage(head_paragraph, head_section, paragraph_id, element):
994
+ def get_formatted_passage(head_paragraph: str | None, head_section: str | None, paragraph_id: str, element: Tag) -> dict[str, Any]:
957
995
  """Format a passage (paragraph or sentence) with metadata and references."""
958
996
  # Import the clean_text method
959
997
  def _clean_text_local(text: str) -> str:
960
998
  if not text:
961
999
  return ""
962
1000
  text = re.sub(r'\s+', ' ', text.strip())
963
- text = html.unescape(text)
1001
+ # DO NOT call html.unescape(text) here either, to maintain parity and prevent offset corruption.
964
1002
  return text
965
1003
 
966
1004
  text = _clean_text_local(element.get_text())
@@ -984,7 +1022,7 @@ def get_formatted_passage(head_paragraph, head_section, paragraph_id, element):
984
1022
  return passage
985
1023
 
986
1024
 
987
- def xml_table_to_markdown(table_element):
1025
+ def xml_table_to_markdown(table_element: Tag | None) -> str | None:
988
1026
  """Convert XML table to markdown format."""
989
1027
  if not table_element:
990
1028
  return None
@@ -1004,7 +1042,7 @@ def xml_table_to_markdown(table_element):
1004
1042
  return "\n".join(markdown_lines) if markdown_lines else None
1005
1043
 
1006
1044
 
1007
- def xml_table_to_json(table_element):
1045
+ def xml_table_to_json(table_element: Tag | None) -> dict[str, Any] | None:
1008
1046
  """Convert XML table to JSON format."""
1009
1047
  if not table_element:
1010
1048
  return None
@@ -1055,6 +1093,7 @@ def xml_table_to_json(table_element):
1055
1093
 
1056
1094
 
1057
1095
  # Backwards compatible top-level function that uses the class
1058
- def convert_tei_file(tei_file: Union[Path, BinaryIO], stream: bool = False):
1096
+ def convert_tei_file(tei_file: Union[str, Path, BinaryIO], stream: bool = False) -> Any:
1059
1097
  converter = TEI2LossyJSONConverter()
1060
1098
  return converter.convert_tei_file(tei_file, stream=stream)
1099
+
@@ -5,6 +5,8 @@ Standalone CLI for TEI2LossyJSON converter.
5
5
  This script provides a command-line interface for converting TEI XML files to JSON format
6
6
  using the TEI2LossyJSONConverter.
7
7
  """
8
+ from __future__ import annotations
9
+
8
10
  import argparse
9
11
  import json
10
12
  import logging
@@ -14,7 +16,7 @@ from pathlib import Path
14
16
  from .TEI2LossyJSON import TEI2LossyJSONConverter
15
17
 
16
18
 
17
- def setup_logging(verbose: bool = False):
19
+ def setup_logging(verbose: bool = False) -> None:
18
20
  """Setup logging configuration."""
19
21
  level = logging.INFO if verbose else logging.WARNING
20
22
  logging.basicConfig(
@@ -54,7 +56,7 @@ def convert_single_file(input_file: Path, output_file: Path, verbose: bool = Fal
54
56
  return False
55
57
 
56
58
 
57
- def main():
59
+ def main() -> None:
58
60
  """Main CLI entry point."""
59
61
  parser = argparse.ArgumentParser(
60
62
  description="Convert TEI XML files to JSON format using TEI2LossyJSON converter",