grobid-client-python 0.0.12__py3-none-any.whl → 0.0.13__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/grobid_client.py +459 -188
- {grobid_client_python-0.0.12.dist-info → grobid_client_python-0.0.13.dist-info}/METADATA +11 -10
- grobid_client_python-0.0.13.dist-info/RECORD +8 -0
- grobid_client_python-0.0.12.dist-info/RECORD +0 -8
- {grobid_client_python-0.0.12.dist-info → grobid_client_python-0.0.13.dist-info}/WHEEL +0 -0
- {grobid_client_python-0.0.12.dist-info → grobid_client_python-0.0.13.dist-info}/entry_points.txt +0 -0
- {grobid_client_python-0.0.12.dist-info → grobid_client_python-0.0.13.dist-info}/top_level.txt +0 -0
grobid_client/grobid_client.py
CHANGED
|
@@ -15,7 +15,6 @@ which is not implemented for the moment.
|
|
|
15
15
|
|
|
16
16
|
"""
|
|
17
17
|
import os
|
|
18
|
-
import io
|
|
19
18
|
import json
|
|
20
19
|
import argparse
|
|
21
20
|
import time
|
|
@@ -23,58 +22,234 @@ import concurrent.futures
|
|
|
23
22
|
import ntpath
|
|
24
23
|
import requests
|
|
25
24
|
import pathlib
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Tuple
|
|
26
27
|
|
|
27
28
|
from .client import ApiClient
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
class ServerUnavailableException(Exception):
|
|
31
|
-
|
|
32
|
+
"""Exception raised when GROBID server is not available or not responding."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message="GROBID server is not available"):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.message = message
|
|
37
|
+
|
|
32
38
|
|
|
33
39
|
class GrobidClient(ApiClient):
|
|
34
40
|
|
|
35
|
-
def __init__(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
grobid_server='http://localhost:8070',
|
|
44
|
+
batch_size=1000,
|
|
45
|
+
coordinates=None,
|
|
46
|
+
sleep_time=5,
|
|
47
|
+
timeout=60,
|
|
48
|
+
config_path=None,
|
|
49
|
+
check_server=True
|
|
50
|
+
):
|
|
51
|
+
# Set default coordinates if None provided
|
|
52
|
+
if coordinates is None:
|
|
53
|
+
coordinates = [
|
|
54
|
+
"title",
|
|
55
|
+
"persName",
|
|
56
|
+
"affiliation",
|
|
57
|
+
"orgName",
|
|
58
|
+
"formula",
|
|
59
|
+
"figure",
|
|
60
|
+
"ref",
|
|
61
|
+
"biblStruct",
|
|
62
|
+
"head",
|
|
63
|
+
"p",
|
|
64
|
+
"s",
|
|
65
|
+
"note"
|
|
66
|
+
]
|
|
67
|
+
|
|
42
68
|
self.config = {
|
|
43
69
|
'grobid_server': grobid_server,
|
|
44
70
|
'batch_size': batch_size,
|
|
45
71
|
'coordinates': coordinates,
|
|
46
72
|
'sleep_time': sleep_time,
|
|
47
|
-
'timeout': timeout
|
|
73
|
+
'timeout': timeout,
|
|
74
|
+
'logging': {
|
|
75
|
+
'level': 'INFO',
|
|
76
|
+
'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
77
|
+
'console': True,
|
|
78
|
+
'file': None, # Disabled by default
|
|
79
|
+
'max_file_size': '10MB',
|
|
80
|
+
'backup_count': 3
|
|
81
|
+
}
|
|
48
82
|
}
|
|
83
|
+
|
|
84
|
+
# Load config first (which may override logging settings)
|
|
49
85
|
if config_path:
|
|
50
86
|
self._load_config(config_path)
|
|
87
|
+
|
|
88
|
+
# Configure logging based on config
|
|
89
|
+
self._configure_logging()
|
|
90
|
+
|
|
51
91
|
if check_server:
|
|
52
92
|
self._test_server_connection()
|
|
53
93
|
|
|
94
|
+
def _configure_logging(self):
|
|
95
|
+
"""Configure logging based on the configuration settings."""
|
|
96
|
+
# Get logging config with defaults
|
|
97
|
+
log_config = self.config.get('logging', {})
|
|
98
|
+
|
|
99
|
+
# Parse log level
|
|
100
|
+
log_level_str = log_config.get('level', 'INFO').upper()
|
|
101
|
+
log_level = getattr(logging, log_level_str, logging.INFO)
|
|
102
|
+
|
|
103
|
+
# Parse log format
|
|
104
|
+
log_format = log_config.get('format', '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
105
|
+
|
|
106
|
+
# Create formatter
|
|
107
|
+
formatter = logging.Formatter(log_format)
|
|
108
|
+
|
|
109
|
+
# Configure the logger
|
|
110
|
+
self.logger = logging.getLogger(__name__)
|
|
111
|
+
self.logger.setLevel(log_level)
|
|
112
|
+
|
|
113
|
+
# Clear any existing handlers to avoid duplicates
|
|
114
|
+
for handler in self.logger.handlers[:]:
|
|
115
|
+
self.logger.removeHandler(handler)
|
|
116
|
+
|
|
117
|
+
# Console handler
|
|
118
|
+
if log_config.get('console', True):
|
|
119
|
+
console_handler = logging.StreamHandler()
|
|
120
|
+
console_handler.setLevel(log_level)
|
|
121
|
+
console_handler.setFormatter(formatter)
|
|
122
|
+
self.logger.addHandler(console_handler)
|
|
123
|
+
|
|
124
|
+
# File handler
|
|
125
|
+
log_file = log_config.get('file')
|
|
126
|
+
if log_file:
|
|
127
|
+
try:
|
|
128
|
+
# Parse file size (support formats like "10MB", "1GB", etc.)
|
|
129
|
+
max_bytes = self._parse_file_size(log_config.get('max_file_size', '10MB'))
|
|
130
|
+
backup_count = log_config.get('backup_count', 3)
|
|
131
|
+
|
|
132
|
+
from logging.handlers import RotatingFileHandler
|
|
133
|
+
file_handler = RotatingFileHandler(
|
|
134
|
+
log_file,
|
|
135
|
+
maxBytes=max_bytes,
|
|
136
|
+
backupCount=backup_count
|
|
137
|
+
)
|
|
138
|
+
file_handler.setLevel(log_level)
|
|
139
|
+
file_handler.setFormatter(formatter)
|
|
140
|
+
self.logger.addHandler(file_handler)
|
|
141
|
+
|
|
142
|
+
self.logger.debug(
|
|
143
|
+
f"File logging configured: {log_file} (max size: {max_bytes}, backups: {backup_count})")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
# Fallback to basic file handler if rotating handler fails
|
|
146
|
+
try:
|
|
147
|
+
file_handler = logging.FileHandler(log_file)
|
|
148
|
+
file_handler.setLevel(log_level)
|
|
149
|
+
file_handler.setFormatter(formatter)
|
|
150
|
+
self.logger.addHandler(file_handler)
|
|
151
|
+
self.logger.warning(f"Using basic file handler due to error with rotating handler: {e}")
|
|
152
|
+
except Exception as file_error:
|
|
153
|
+
self.logger.warning(f"Could not configure file logging: {file_error}")
|
|
154
|
+
|
|
155
|
+
self.logger.info(
|
|
156
|
+
f"Logging configured - Level: {log_level_str}, Console: {log_config.get('console', True)}, File: {log_file or 'disabled'}")
|
|
157
|
+
|
|
158
|
+
def _parse_file_size(self, size_str):
|
|
159
|
+
"""Parse file size string like '10MB', '1GB' to bytes."""
|
|
160
|
+
size_str = str(size_str).upper().strip()
|
|
161
|
+
|
|
162
|
+
# Extract number and unit
|
|
163
|
+
import re
|
|
164
|
+
match = re.match(r'(\d+(?:\.\d+)?)\s*([KMGT]?B?)', size_str)
|
|
165
|
+
if not match:
|
|
166
|
+
return 10 * 1024 * 1024 # Default 10MB
|
|
167
|
+
|
|
168
|
+
number = float(match.group(1))
|
|
169
|
+
unit = match.group(2)
|
|
170
|
+
|
|
171
|
+
# Convert to bytes
|
|
172
|
+
multipliers = {
|
|
173
|
+
'': 1,
|
|
174
|
+
'B': 1,
|
|
175
|
+
'KB': 1024,
|
|
176
|
+
'MB': 1024 ** 2,
|
|
177
|
+
'GB': 1024 ** 3,
|
|
178
|
+
'TB': 1024 ** 4
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return int(number * multipliers.get(unit, 1))
|
|
182
|
+
|
|
54
183
|
def _load_config(self, path="./config.json"):
|
|
55
184
|
"""
|
|
56
|
-
Load
|
|
185
|
+
Load and merge configuration from a JSON file with default values.
|
|
186
|
+
If the file doesn't exist, keep the default values.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
path (str): Path to the JSON configuration file
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
FileNotFoundError: If the config file is not found
|
|
193
|
+
json.JSONDecodeError: If the config file contains invalid JSON
|
|
194
|
+
Exception: For other file reading errors
|
|
57
195
|
"""
|
|
58
|
-
|
|
59
|
-
|
|
196
|
+
# Create a temporary logger for configuration loading since main logger isn't configured yet
|
|
197
|
+
temp_logger = logging.getLogger(f"{__name__}.config_loader")
|
|
198
|
+
if not temp_logger.handlers:
|
|
199
|
+
temp_handler = logging.StreamHandler()
|
|
200
|
+
temp_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))
|
|
201
|
+
temp_logger.addHandler(temp_handler)
|
|
202
|
+
temp_logger.setLevel(logging.INFO)
|
|
60
203
|
|
|
61
|
-
|
|
62
|
-
|
|
204
|
+
try:
|
|
205
|
+
temp_logger.info(f"Loading configuration file from {path}")
|
|
206
|
+
with open(path, 'r') as config_file:
|
|
207
|
+
config_json = config_file.read()
|
|
208
|
+
# Update the default config with values from the file
|
|
209
|
+
file_config = json.loads(config_json)
|
|
210
|
+
self.config.update(file_config)
|
|
211
|
+
temp_logger.info("Configuration file loaded successfully")
|
|
212
|
+
except FileNotFoundError as e:
|
|
213
|
+
# If config file doesn't exist, keep using default values
|
|
214
|
+
error_msg = f"The specified config file {path} was not found. Check the path or leave it blank to use the default configuration."
|
|
215
|
+
temp_logger.error(error_msg)
|
|
216
|
+
raise FileNotFoundError(error_msg) from e
|
|
217
|
+
except json.JSONDecodeError as e:
|
|
218
|
+
# If config exists, but it's invalid, we raise an exception
|
|
219
|
+
error_msg = f"Could not parse config file at {path}: {str(e)}"
|
|
220
|
+
temp_logger.error(error_msg)
|
|
221
|
+
raise json.JSONDecodeError(error_msg, e.doc, e.pos) from e
|
|
222
|
+
except Exception as e:
|
|
223
|
+
error_msg = f"Error reading config file at {path}: {str(e)}"
|
|
224
|
+
temp_logger.error(error_msg)
|
|
225
|
+
raise Exception(error_msg) from e
|
|
226
|
+
|
|
227
|
+
def _test_server_connection(self) -> Tuple[bool, int]:
|
|
228
|
+
"""Test if the server is up and running.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
tuple: (is_available, status_code)
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
ServerUnavailableException: If server is not reachable
|
|
235
|
+
"""
|
|
63
236
|
the_url = self.get_server_url("isalive")
|
|
64
237
|
try:
|
|
65
|
-
r = requests.get(the_url)
|
|
66
|
-
|
|
67
|
-
print("GROBID server does not appear up and running, the connection to the server failed")
|
|
68
|
-
raise ServerUnavailableException
|
|
238
|
+
r = requests.get(the_url, timeout=10)
|
|
239
|
+
status = r.status_code
|
|
69
240
|
|
|
70
|
-
|
|
241
|
+
if status != 200:
|
|
242
|
+
error_msg = f"GROBID server {self.config['grobid_server']} does not appear up and running (status: {status})"
|
|
243
|
+
self.logger.error(error_msg)
|
|
244
|
+
return False, status
|
|
245
|
+
else:
|
|
246
|
+
self.logger.info(f"GROBID server {self.config['grobid_server']} is up and running")
|
|
247
|
+
return True, status
|
|
71
248
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
print("GROBID server is up and running")
|
|
77
|
-
return True, status
|
|
249
|
+
except requests.exceptions.RequestException as e:
|
|
250
|
+
error_msg = f"GROBID server {self.config['grobid_server']} does not appear up and running, connection failed: {str(e)}"
|
|
251
|
+
self.logger.error(error_msg)
|
|
252
|
+
raise ServerUnavailableException(error_msg) from e
|
|
78
253
|
|
|
79
254
|
def _output_file_name(self, input_file, input_path, output):
|
|
80
255
|
# we use ntpath here to be sure it will work on Windows too
|
|
@@ -92,7 +267,7 @@ class GrobidClient(ApiClient):
|
|
|
92
267
|
|
|
93
268
|
return filename
|
|
94
269
|
|
|
95
|
-
def ping(self) ->
|
|
270
|
+
def ping(self) -> Tuple[bool, int]:
|
|
96
271
|
"""
|
|
97
272
|
Check the Grobid service. Returns True if the service is up.
|
|
98
273
|
In addition, returns also the status code.
|
|
@@ -100,61 +275,85 @@ class GrobidClient(ApiClient):
|
|
|
100
275
|
return self._test_server_connection()
|
|
101
276
|
|
|
102
277
|
def process(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
278
|
+
self,
|
|
279
|
+
service,
|
|
280
|
+
input_path,
|
|
281
|
+
output=None,
|
|
282
|
+
n=10,
|
|
283
|
+
generateIDs=False,
|
|
284
|
+
consolidate_header=True,
|
|
285
|
+
consolidate_citations=False,
|
|
286
|
+
include_raw_citations=False,
|
|
287
|
+
include_raw_affiliations=False,
|
|
288
|
+
tei_coordinates=False,
|
|
289
|
+
segment_sentences=False,
|
|
290
|
+
force=True,
|
|
291
|
+
verbose=False,
|
|
292
|
+
flavor=None
|
|
118
293
|
):
|
|
119
294
|
batch_size_pdf = self.config["batch_size"]
|
|
120
|
-
input_files = []
|
|
121
295
|
|
|
296
|
+
# First pass: count all eligible files
|
|
297
|
+
all_input_files = []
|
|
122
298
|
for (dirpath, dirnames, filenames) in os.walk(input_path):
|
|
123
299
|
for filename in filenames:
|
|
124
300
|
if filename.endswith(".pdf") or filename.endswith(".PDF") or \
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
301
|
+
(service == 'processCitationList' and (
|
|
302
|
+
filename.endswith(".txt") or filename.endswith(".TXT"))) or \
|
|
303
|
+
(service == 'processCitationPatentST36' and (
|
|
304
|
+
filename.endswith(".xml") or filename.endswith(".XML"))):
|
|
305
|
+
full_path = os.sep.join([dirpath, filename])
|
|
306
|
+
all_input_files.append(full_path)
|
|
307
|
+
|
|
308
|
+
# Log total files found
|
|
309
|
+
total_files = len(all_input_files)
|
|
310
|
+
if total_files == 0:
|
|
311
|
+
self.logger.warning(f"No eligible files found in {input_path}")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
self.logger.info(f"Found {total_files} file(s) to process")
|
|
315
|
+
|
|
316
|
+
# Counter for actually processed files
|
|
317
|
+
processed_files_count = 0
|
|
318
|
+
input_files = []
|
|
319
|
+
|
|
320
|
+
for input_file in all_input_files:
|
|
321
|
+
# Extract just the filename for verbose logging
|
|
322
|
+
filename = os.path.basename(input_file)
|
|
323
|
+
|
|
324
|
+
if verbose:
|
|
325
|
+
try:
|
|
326
|
+
self.logger.info(f"Found file: {filename}")
|
|
327
|
+
except UnicodeEncodeError:
|
|
328
|
+
# may happen on linux see https://stackoverflow.com/questions/27366479/python-3-os-walk-file-paths-unicodeencodeerror-utf-8-codec-cant-encode-s
|
|
329
|
+
self.logger.warning(f"Could not log filename due to encoding issues")
|
|
330
|
+
|
|
331
|
+
input_files.append(input_file)
|
|
332
|
+
|
|
333
|
+
if len(input_files) == batch_size_pdf:
|
|
334
|
+
batch_processed = self.process_batch(
|
|
335
|
+
service,
|
|
336
|
+
input_files,
|
|
337
|
+
input_path,
|
|
338
|
+
output,
|
|
339
|
+
n,
|
|
340
|
+
generateIDs,
|
|
341
|
+
consolidate_header,
|
|
342
|
+
consolidate_citations,
|
|
343
|
+
include_raw_citations,
|
|
344
|
+
include_raw_affiliations,
|
|
345
|
+
tei_coordinates,
|
|
346
|
+
segment_sentences,
|
|
347
|
+
force,
|
|
348
|
+
verbose,
|
|
349
|
+
flavor
|
|
350
|
+
)
|
|
351
|
+
processed_files_count += batch_processed
|
|
352
|
+
input_files = []
|
|
154
353
|
|
|
155
354
|
# last batch
|
|
156
355
|
if len(input_files) > 0:
|
|
157
|
-
self.process_batch(
|
|
356
|
+
batch_processed = self.process_batch(
|
|
158
357
|
service,
|
|
159
358
|
input_files,
|
|
160
359
|
input_path,
|
|
@@ -170,37 +369,44 @@ class GrobidClient(ApiClient):
|
|
|
170
369
|
force,
|
|
171
370
|
verbose,
|
|
172
371
|
)
|
|
372
|
+
processed_files_count += batch_processed
|
|
373
|
+
|
|
374
|
+
# Log final statistics
|
|
375
|
+
self.logger.info(f"Processing completed: {processed_files_count} out of {total_files} files processed")
|
|
173
376
|
|
|
174
377
|
def process_batch(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
378
|
+
self,
|
|
379
|
+
service,
|
|
380
|
+
input_files,
|
|
381
|
+
input_path,
|
|
382
|
+
output,
|
|
383
|
+
n,
|
|
384
|
+
generateIDs,
|
|
385
|
+
consolidate_header,
|
|
386
|
+
consolidate_citations,
|
|
387
|
+
include_raw_citations,
|
|
388
|
+
include_raw_affiliations,
|
|
389
|
+
tei_coordinates,
|
|
390
|
+
segment_sentences,
|
|
391
|
+
force,
|
|
392
|
+
verbose=False,
|
|
393
|
+
flavor=None
|
|
191
394
|
):
|
|
192
395
|
if verbose:
|
|
193
|
-
|
|
396
|
+
self.logger.info(f"{len(input_files)} files to process in current batch")
|
|
397
|
+
|
|
398
|
+
processed_count = 0
|
|
194
399
|
|
|
195
400
|
# we use ThreadPoolExecutor and not ProcessPoolExecutor because it is an I/O intensive process
|
|
196
401
|
with concurrent.futures.ThreadPoolExecutor(max_workers=n) as executor:
|
|
197
|
-
#with concurrent.futures.ProcessPoolExecutor(max_workers=n) as executor:
|
|
402
|
+
# with concurrent.futures.ProcessPoolExecutor(max_workers=n) as executor:
|
|
198
403
|
results = []
|
|
199
404
|
for input_file in input_files:
|
|
200
405
|
# check if TEI file is already produced
|
|
201
406
|
filename = self._output_file_name(input_file, input_path, output)
|
|
202
407
|
if not force and os.path.isfile(filename):
|
|
203
|
-
|
|
408
|
+
self.logger.info(
|
|
409
|
+
f"{filename} already exists, skipping... (use --force to reprocess pdf input files)")
|
|
204
410
|
continue
|
|
205
411
|
|
|
206
412
|
selected_process = self.process_pdf
|
|
@@ -208,8 +414,8 @@ class GrobidClient(ApiClient):
|
|
|
208
414
|
selected_process = self.process_txt
|
|
209
415
|
|
|
210
416
|
if verbose:
|
|
211
|
-
|
|
212
|
-
|
|
417
|
+
self.logger.info(f"Adding {input_file} to the queue")
|
|
418
|
+
|
|
213
419
|
r = executor.submit(
|
|
214
420
|
selected_process,
|
|
215
421
|
service,
|
|
@@ -230,44 +436,55 @@ class GrobidClient(ApiClient):
|
|
|
230
436
|
for r in concurrent.futures.as_completed(results):
|
|
231
437
|
input_file, status, text = r.result()
|
|
232
438
|
filename = self._output_file_name(input_file, input_path, output)
|
|
439
|
+
processed_count += 1
|
|
233
440
|
|
|
234
441
|
if status != 200 or text is None:
|
|
235
|
-
|
|
442
|
+
self.logger.error(f"Processing of {input_file} failed with error {status}: {text}")
|
|
236
443
|
# writing error file with suffixed error code
|
|
237
444
|
try:
|
|
238
445
|
pathlib.Path(os.path.dirname(filename)).mkdir(parents=True, exist_ok=True)
|
|
239
|
-
|
|
446
|
+
error_filename = filename.replace(".grobid.tei.xml", f"_{status}.txt")
|
|
447
|
+
with open(error_filename, 'w', encoding='utf8') as error_file:
|
|
240
448
|
if text is not None:
|
|
241
|
-
|
|
449
|
+
error_file.write(text)
|
|
242
450
|
else:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
451
|
+
error_file.write("")
|
|
452
|
+
self.logger.info(f"Error details written to {error_filename}")
|
|
453
|
+
except OSError as e:
|
|
454
|
+
self.logger.error(f"Failed to write error file {filename}: {str(e)}")
|
|
246
455
|
else:
|
|
247
456
|
# writing TEI file
|
|
248
457
|
try:
|
|
249
458
|
pathlib.Path(os.path.dirname(filename)).mkdir(parents=True, exist_ok=True)
|
|
250
|
-
with open(filename,'w',encoding='utf8') as tei_file:
|
|
459
|
+
with open(filename, 'w', encoding='utf8') as tei_file:
|
|
251
460
|
tei_file.write(text)
|
|
252
|
-
|
|
253
|
-
|
|
461
|
+
self.logger.debug(f"Successfully wrote TEI file: {filename}")
|
|
462
|
+
except OSError as e:
|
|
463
|
+
self.logger.error(f"Failed to write TEI XML file {filename}: {str(e)}")
|
|
464
|
+
|
|
465
|
+
return processed_count
|
|
254
466
|
|
|
255
467
|
def process_pdf(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
468
|
+
self,
|
|
469
|
+
service,
|
|
470
|
+
pdf_file,
|
|
471
|
+
generateIDs,
|
|
472
|
+
consolidate_header,
|
|
473
|
+
consolidate_citations,
|
|
474
|
+
include_raw_citations,
|
|
475
|
+
include_raw_affiliations,
|
|
476
|
+
tei_coordinates,
|
|
477
|
+
segment_sentences,
|
|
478
|
+
flavor=None,
|
|
479
|
+
start=-1,
|
|
480
|
+
end=-1
|
|
269
481
|
):
|
|
270
|
-
|
|
482
|
+
try:
|
|
483
|
+
pdf_handle = open(pdf_file, "rb")
|
|
484
|
+
except IOError as e:
|
|
485
|
+
self.logger.error(f"Failed to open PDF file {pdf_file}: {str(e)}")
|
|
486
|
+
return (pdf_file, 500, f"Failed to open file: {str(e)}")
|
|
487
|
+
|
|
271
488
|
files = {
|
|
272
489
|
"input": (
|
|
273
490
|
pdf_file,
|
|
@@ -276,7 +493,7 @@ class GrobidClient(ApiClient):
|
|
|
276
493
|
{"Expires": "0"},
|
|
277
494
|
)
|
|
278
495
|
}
|
|
279
|
-
|
|
496
|
+
|
|
280
497
|
the_url = self.get_server_url(service)
|
|
281
498
|
|
|
282
499
|
# set the GROBID parameters
|
|
@@ -304,10 +521,12 @@ class GrobidClient(ApiClient):
|
|
|
304
521
|
|
|
305
522
|
try:
|
|
306
523
|
res, status = self.post(
|
|
307
|
-
url=the_url, files=files, data=the_data, headers={"Accept": "text/plain"},
|
|
524
|
+
url=the_url, files=files, data=the_data, headers={"Accept": "text/plain"},
|
|
525
|
+
timeout=self.config['timeout']
|
|
308
526
|
)
|
|
309
527
|
|
|
310
528
|
if status == 503:
|
|
529
|
+
self.logger.warning(f"Server busy (503), retrying {pdf_file} after {self.config['sleep_time']} seconds")
|
|
311
530
|
time.sleep(self.config["sleep_time"])
|
|
312
531
|
return self.process_pdf(
|
|
313
532
|
service,
|
|
@@ -319,13 +538,22 @@ class GrobidClient(ApiClient):
|
|
|
319
538
|
include_raw_affiliations,
|
|
320
539
|
tei_coordinates,
|
|
321
540
|
segment_sentences,
|
|
541
|
+
flavor,
|
|
322
542
|
start,
|
|
323
|
-
end
|
|
324
|
-
flavor
|
|
543
|
+
end
|
|
325
544
|
)
|
|
326
|
-
except requests.exceptions.ReadTimeout:
|
|
545
|
+
except requests.exceptions.ReadTimeout as e:
|
|
546
|
+
self.logger.error(f"Request timeout for {pdf_file}: {str(e)}")
|
|
547
|
+
pdf_handle.close()
|
|
548
|
+
return (pdf_file, 408, f"Request timeout: {str(e)}")
|
|
549
|
+
except requests.exceptions.RequestException as e:
|
|
550
|
+
self.logger.error(f"Request failed for {pdf_file}: {str(e)}")
|
|
327
551
|
pdf_handle.close()
|
|
328
|
-
return (pdf_file,
|
|
552
|
+
return (pdf_file, 500, f"Request failed: {str(e)}")
|
|
553
|
+
except Exception as e:
|
|
554
|
+
self.logger.error(f"Unexpected error processing {pdf_file}: {str(e)}")
|
|
555
|
+
pdf_handle.close()
|
|
556
|
+
return (pdf_file, 500, f"Unexpected error: {str(e)}")
|
|
329
557
|
|
|
330
558
|
pdf_handle.close()
|
|
331
559
|
return (pdf_file, status, res.text)
|
|
@@ -334,21 +562,27 @@ class GrobidClient(ApiClient):
|
|
|
334
562
|
return self.config['grobid_server'] + "/api/" + service
|
|
335
563
|
|
|
336
564
|
def process_txt(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
565
|
+
self,
|
|
566
|
+
service,
|
|
567
|
+
txt_file,
|
|
568
|
+
generateIDs,
|
|
569
|
+
consolidate_header,
|
|
570
|
+
consolidate_citations,
|
|
571
|
+
include_raw_citations,
|
|
572
|
+
include_raw_affiliations,
|
|
573
|
+
tei_coordinates,
|
|
574
|
+
segment_sentences
|
|
347
575
|
):
|
|
348
576
|
# create request based on file content
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
577
|
+
try:
|
|
578
|
+
with open(txt_file, 'r', encoding='utf-8') as f:
|
|
579
|
+
references = [line.rstrip() for line in f]
|
|
580
|
+
except IOError as e:
|
|
581
|
+
self.logger.error(f"Failed to read text file {txt_file}: {str(e)}")
|
|
582
|
+
return (txt_file, 500, f"Failed to read file: {str(e)}")
|
|
583
|
+
except UnicodeDecodeError as e:
|
|
584
|
+
self.logger.error(f"Unicode decode error reading {txt_file}: {str(e)}")
|
|
585
|
+
return (txt_file, 500, f"Unicode decode error: {str(e)}")
|
|
352
586
|
|
|
353
587
|
the_url = self.get_server_url(service)
|
|
354
588
|
|
|
@@ -359,29 +593,46 @@ class GrobidClient(ApiClient):
|
|
|
359
593
|
if include_raw_citations:
|
|
360
594
|
the_data["includeRawCitations"] = "1"
|
|
361
595
|
the_data["citations"] = references
|
|
362
|
-
res, status = self.post(
|
|
363
|
-
url=the_url, data=the_data, headers={"Accept": "application/xml"}
|
|
364
|
-
)
|
|
365
596
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
service,
|
|
370
|
-
txt_file,
|
|
371
|
-
generateIDs,
|
|
372
|
-
consolidate_header,
|
|
373
|
-
consolidate_citations,
|
|
374
|
-
include_raw_citations,
|
|
375
|
-
include_raw_affiliations,
|
|
376
|
-
tei_coordinates,
|
|
377
|
-
segment_sentences
|
|
597
|
+
try:
|
|
598
|
+
res, status = self.post(
|
|
599
|
+
url=the_url, data=the_data, headers={"Accept": "application/xml"}
|
|
378
600
|
)
|
|
379
601
|
|
|
602
|
+
if status == 503:
|
|
603
|
+
self.logger.warning(f"Server busy (503), retrying {txt_file} after {self.config['sleep_time']} seconds")
|
|
604
|
+
time.sleep(self.config["sleep_time"])
|
|
605
|
+
return self.process_txt(
|
|
606
|
+
service,
|
|
607
|
+
txt_file,
|
|
608
|
+
generateIDs,
|
|
609
|
+
consolidate_header,
|
|
610
|
+
consolidate_citations,
|
|
611
|
+
include_raw_citations,
|
|
612
|
+
include_raw_affiliations,
|
|
613
|
+
tei_coordinates,
|
|
614
|
+
segment_sentences
|
|
615
|
+
)
|
|
616
|
+
except requests.exceptions.RequestException as e:
|
|
617
|
+
self.logger.error(f"Request failed for {txt_file}: {str(e)}")
|
|
618
|
+
return (txt_file, 500, f"Request failed: {str(e)}")
|
|
619
|
+
except Exception as e:
|
|
620
|
+
self.logger.error(f"Unexpected error processing {txt_file}: {str(e)}")
|
|
621
|
+
return (txt_file, 500, f"Unexpected error: {str(e)}")
|
|
622
|
+
|
|
380
623
|
return (txt_file, status, res.text)
|
|
381
624
|
|
|
625
|
+
|
|
382
626
|
def main():
|
|
627
|
+
# Basic logging setup for initialization only
|
|
628
|
+
# The actual logging configuration will be done by GrobidClient based on config.json
|
|
629
|
+
temp_logger = logging.getLogger(__name__)
|
|
630
|
+
temp_handler = logging.StreamHandler()
|
|
631
|
+
temp_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))
|
|
632
|
+
temp_logger.addHandler(temp_handler)
|
|
633
|
+
temp_logger.setLevel(logging.INFO)
|
|
634
|
+
|
|
383
635
|
valid_services = [
|
|
384
|
-
"processFulltextDocumentBlank",
|
|
385
636
|
"processFulltextDocument",
|
|
386
637
|
"processHeaderDocument",
|
|
387
638
|
"processReferences",
|
|
@@ -393,10 +644,13 @@ def main():
|
|
|
393
644
|
parser = argparse.ArgumentParser(description="Client for GROBID services")
|
|
394
645
|
parser.add_argument(
|
|
395
646
|
"service",
|
|
396
|
-
|
|
647
|
+
choices=valid_services,
|
|
648
|
+
help="Grobid service to be called.",
|
|
397
649
|
)
|
|
398
650
|
parser.add_argument(
|
|
399
|
-
"--input",
|
|
651
|
+
"--input",
|
|
652
|
+
default=None,
|
|
653
|
+
help="path to the directory containing files to process: PDF or .txt (for processCitationList only, one reference per line), or .xml for patents in ST36"
|
|
400
654
|
)
|
|
401
655
|
parser.add_argument(
|
|
402
656
|
"--output",
|
|
@@ -405,10 +659,14 @@ def main():
|
|
|
405
659
|
)
|
|
406
660
|
parser.add_argument(
|
|
407
661
|
"--config",
|
|
408
|
-
default=
|
|
409
|
-
help="path to the config file
|
|
662
|
+
default=None,
|
|
663
|
+
help="path to the config file (optional)",
|
|
664
|
+
)
|
|
665
|
+
parser.add_argument(
|
|
666
|
+
"--n",
|
|
667
|
+
default=10,
|
|
668
|
+
help="concurrency for service usage"
|
|
410
669
|
)
|
|
411
|
-
parser.add_argument("--n", default=10, help="concurrency for service usage")
|
|
412
670
|
parser.add_argument(
|
|
413
671
|
"--generateIDs",
|
|
414
672
|
action="store_true",
|
|
@@ -468,22 +726,35 @@ def main():
|
|
|
468
726
|
output_path = args.output
|
|
469
727
|
flavor = args.flavor
|
|
470
728
|
|
|
729
|
+
# Initialize n with default value
|
|
730
|
+
n = 10
|
|
471
731
|
if args.n is not None:
|
|
472
732
|
try:
|
|
473
733
|
n = int(args.n)
|
|
474
734
|
except ValueError:
|
|
475
|
-
|
|
476
|
-
|
|
735
|
+
temp_logger.warning(f"Invalid concurrency parameter n: {args.n}. Using default value n = 10")
|
|
736
|
+
|
|
737
|
+
# Initialize GrobidClient which will configure logging based on config.json
|
|
738
|
+
try:
|
|
739
|
+
client = GrobidClient(config_path=config_path)
|
|
740
|
+
# Now use the client's logger for all subsequent logging
|
|
741
|
+
logger = client.logger
|
|
742
|
+
except ServerUnavailableException as e:
|
|
743
|
+
temp_logger.error(f"Server unavailable: {str(e)}")
|
|
744
|
+
exit(1)
|
|
745
|
+
except Exception as e:
|
|
746
|
+
temp_logger.error(f"Failed to initialize GrobidClient: {str(e)}")
|
|
747
|
+
exit(1)
|
|
477
748
|
|
|
478
749
|
# if output path does not exist, we create it
|
|
479
750
|
if output_path is not None and not os.path.isdir(output_path):
|
|
480
751
|
try:
|
|
481
|
-
|
|
752
|
+
logger.info(f"Output directory does not exist but will be created: {output_path}")
|
|
482
753
|
os.makedirs(output_path)
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
754
|
+
logger.info(f"Successfully created the directory {output_path}")
|
|
755
|
+
except OSError as e:
|
|
756
|
+
logger.error(f"Creation of the directory {output_path} failed: {str(e)}")
|
|
757
|
+
exit(1)
|
|
487
758
|
|
|
488
759
|
service = args.service
|
|
489
760
|
generateIDs = args.generateIDs
|
|
@@ -496,36 +767,36 @@ def main():
|
|
|
496
767
|
segment_sentences = args.segmentSentences
|
|
497
768
|
verbose = args.verbose
|
|
498
769
|
|
|
499
|
-
if service is None or not
|
|
500
|
-
|
|
501
|
-
exit(1)
|
|
502
|
-
|
|
503
|
-
try:
|
|
504
|
-
client = GrobidClient(config_path=config_path)
|
|
505
|
-
except ServerUnavailableException:
|
|
770
|
+
if service is None or service not in valid_services:
|
|
771
|
+
logger.error(f"Missing or invalid service '{service}', must be one of {valid_services}")
|
|
506
772
|
exit(1)
|
|
507
773
|
|
|
508
774
|
start_time = time.time()
|
|
509
775
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
776
|
+
try:
|
|
777
|
+
client.process(
|
|
778
|
+
service,
|
|
779
|
+
input_path,
|
|
780
|
+
output=output_path,
|
|
781
|
+
n=n,
|
|
782
|
+
generateIDs=generateIDs,
|
|
783
|
+
consolidate_header=consolidate_header,
|
|
784
|
+
consolidate_citations=consolidate_citations,
|
|
785
|
+
include_raw_citations=include_raw_citations,
|
|
786
|
+
include_raw_affiliations=include_raw_affiliations,
|
|
787
|
+
tei_coordinates=tei_coordinates,
|
|
788
|
+
segment_sentences=segment_sentences,
|
|
789
|
+
force=force,
|
|
790
|
+
verbose=verbose,
|
|
791
|
+
flavor=flavor
|
|
792
|
+
)
|
|
793
|
+
except Exception as e:
|
|
794
|
+
logger.error(f"Processing failed: {str(e)}")
|
|
795
|
+
exit(1)
|
|
526
796
|
|
|
527
797
|
runtime = round(time.time() - start_time, 3)
|
|
528
|
-
|
|
798
|
+
logger.info(f"Processing completed in {runtime} seconds")
|
|
799
|
+
|
|
529
800
|
|
|
530
801
|
if __name__ == "__main__":
|
|
531
802
|
main()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: grobid-client-python
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.13
|
|
4
4
|
Summary: Simple python client for GROBID REST services
|
|
5
5
|
Author-email: Patrice Lopez <patrice.lopez@science-miner.com>
|
|
6
6
|
Maintainer-email: Patrice Lopez <patrice.lopez@science-miner.com>, Luca Foppiano <lucanoro@duck.com>
|
|
@@ -218,15 +218,12 @@ Requires-Dist: requests
|
|
|
218
218
|
[](https://archive.softwareheritage.org/browse/origin/https://github.com/kermitt2/grobid_client_python/)
|
|
219
219
|
[](http://www.apache.org/licenses/LICENSE-2.0.html)
|
|
220
220
|
|
|
221
|
-
# Simple
|
|
221
|
+
# Simple Python client for GROBID REST services
|
|
222
222
|
|
|
223
223
|
This Python client can be used to process in an efficient concurrent manner a set of PDF in a given directory by the [GROBID](https://github.com/kermitt2/grobid) service. It includes a command line for processing PDF on a file system and write results in a given output directory and a library for import in other python scripts. The client can also process similarly a list of files with reference strings (one per line) and patents in XML ST36 formats.
|
|
224
224
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
Please be aware that, at the moment, [grobid does not support Windows](https://grobid.readthedocs.io/en/latest/Troubleshooting/#windows-related-issues).
|
|
228
|
-
If you are a Windows user, don't worry. You can still [run grobid
|
|
229
|
-
via Docker](https://grobid.readthedocs.io/en/latest/Grobid-docker/).
|
|
225
|
+
> [!IMPORTANT]
|
|
226
|
+
> Please be aware that, at the moment, Grobid supports windows only through Docker containers. See [here](https://grobid.readthedocs.io/en/latest/Grobid-docker/).
|
|
230
227
|
|
|
231
228
|
## Build and run
|
|
232
229
|
|
|
@@ -314,11 +311,12 @@ optional arguments:
|
|
|
314
311
|
with additional <s> elements
|
|
315
312
|
--verbose print information about processed files in the console
|
|
316
313
|
--flavor specifcy the flavor to be used. Current accepted
|
|
317
|
-
values: `article/light`, `article/light-ref`, `sdo/ietf
|
|
318
|
-
|
|
314
|
+
values: `article/light`, `article/light-ref`, `sdo/ietf`.
|
|
315
|
+
```
|
|
319
316
|
|
|
317
|
+
> [!TIP]
|
|
318
|
+
> Before using flavors, check [here](https://grobid.readthedocs.io/en/latest/Grobid-specialized-processes/#flavors).
|
|
320
319
|
|
|
321
|
-
```
|
|
322
320
|
|
|
323
321
|
Examples:
|
|
324
322
|
|
|
@@ -367,6 +365,9 @@ See also `example.py`.
|
|
|
367
365
|
|
|
368
366
|
## Configuration of the client
|
|
369
367
|
|
|
368
|
+
> [!TIP]
|
|
369
|
+
> from version 0.0.12 the `config.json` will be optional, by default the client will connect to the local server.
|
|
370
|
+
|
|
370
371
|
There are a few parameters that can be set with the `config.json` file.
|
|
371
372
|
|
|
372
373
|
- `grobid_server` indicates the URL of the GROBID server to be used by the client.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
grobid_client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
grobid_client/client.py,sha256=crvUiVnnkn6tFwO4lKQg2cTxAHZxCBAmeNSVtuL4ZA0,6009
|
|
3
|
+
grobid_client/grobid_client.py,sha256=1ZHOhlPEgPDExK8RCYpZTq4M0kfMRVgrKEEpCCqUlZg,29533
|
|
4
|
+
grobid_client_python-0.0.13.dist-info/METADATA,sha256=F1h_3LtqdZRhttiwmenPLeS4BBp8Ttk7MYG6HAOBXf0,24213
|
|
5
|
+
grobid_client_python-0.0.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
+
grobid_client_python-0.0.13.dist-info/entry_points.txt,sha256=-WwPi8D8eXEbDB6QvitvHjxSGBeyLCyFvAoZcaDGswQ,67
|
|
7
|
+
grobid_client_python-0.0.13.dist-info/top_level.txt,sha256=HnjgWxdJNKTLIN86XkgRAfVeEK4C71fc9X4fe7qdMvA,14
|
|
8
|
+
grobid_client_python-0.0.13.dist-info/RECORD,,
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
grobid_client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
grobid_client/client.py,sha256=crvUiVnnkn6tFwO4lKQg2cTxAHZxCBAmeNSVtuL4ZA0,6009
|
|
3
|
-
grobid_client/grobid_client.py,sha256=bt5gdmUEkhKVojI0y5yO9bW1BLT1Mf8EZ2ZKZmjfkII,18071
|
|
4
|
-
grobid_client_python-0.0.12.dist-info/METADATA,sha256=XTZ0S_vrfhuN6EboeInLPGzzqT7RjwLsVRb6thNGJvs,24226
|
|
5
|
-
grobid_client_python-0.0.12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
-
grobid_client_python-0.0.12.dist-info/entry_points.txt,sha256=-WwPi8D8eXEbDB6QvitvHjxSGBeyLCyFvAoZcaDGswQ,67
|
|
7
|
-
grobid_client_python-0.0.12.dist-info/top_level.txt,sha256=HnjgWxdJNKTLIN86XkgRAfVeEK4C71fc9X4fe7qdMvA,14
|
|
8
|
-
grobid_client_python-0.0.12.dist-info/RECORD,,
|
|
File without changes
|
{grobid_client_python-0.0.12.dist-info → grobid_client_python-0.0.13.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{grobid_client_python-0.0.12.dist-info → grobid_client_python-0.0.13.dist-info}/top_level.txt
RENAMED
|
File without changes
|