grobid-client-python 0.2.0__tar.gz → 0.2.2__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.
Files changed (72) hide show
  1. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/PKG-INFO +10 -6
  2. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/Readme.md +9 -5
  3. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/grobid_client.py +107 -83
  4. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client_python.egg-info/PKG-INFO +10 -6
  5. grobid_client_python-0.2.2/grobid_client_python.egg-info/scm_version.json +8 -0
  6. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/pyproject.toml +1 -1
  7. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/test_grobid_client.py +161 -6
  8. grobid_client_python-0.2.0/grobid_client_python.egg-info/scm_version.json +0 -8
  9. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/.github/dependabot.yml +0 -0
  10. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/.github/workflows/ci-build.yml +0 -0
  11. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/.github/workflows/ci-release.yml +0 -0
  12. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/.gitignore +0 -0
  13. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/LICENSE +0 -0
  14. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/MANIFEST.in +0 -0
  15. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/config.json +0 -0
  16. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/example.py +0 -0
  17. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/__init__.py +0 -0
  18. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/client.py +0 -0
  19. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/format/TEI2LossyJSON.py +0 -0
  20. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/format/TEI2LossyJSON_cli.py +0 -0
  21. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/format/TEI2Markdown.py +0 -0
  22. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/format/TEI2Markdown_cli.py +0 -0
  23. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/format/__init__.py +0 -0
  24. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/format/__main__.py +0 -0
  25. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/format/validate_json_refs.py +0 -0
  26. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client/py.typed +0 -0
  27. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client_python.egg-info/SOURCES.txt +0 -0
  28. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client_python.egg-info/dependency_links.txt +0 -0
  29. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client_python.egg-info/entry_points.txt +0 -0
  30. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client_python.egg-info/requires.txt +0 -0
  31. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client_python.egg-info/scm_file_list.json +0 -0
  32. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/grobid_client_python.egg-info/top_level.txt +0 -0
  33. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/pytest.ini +0 -0
  34. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/requirements.txt +0 -0
  35. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/20180928112135.png +0 -0
  36. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/20180928112138.csv +0 -0
  37. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_pdf/0046d83a-edd6-4631-b57c-755cdcce8b7f.pdf +0 -0
  38. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_pdf/0046d83a-edd6-4631-b57c-755cdcce8b7f.tei.xml +0 -0
  39. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_pdf/mjb3wlzxcb2mc-migowebupload-1766042162782.grobid.tei.xml +0 -0
  40. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_txt/reference-strings-1.txt +0 -0
  41. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_txt/reference-strings-2.txt +0 -0
  42. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_txt/reference-strings-3.txt +0 -0
  43. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_txt/reference-strings-4.txt +0 -0
  44. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_txt/reference-strings-5.txt +0 -0
  45. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_txt/reference-strings-6.txt +0 -0
  46. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/resources/test_txt/reference-strings-7.txt +0 -0
  47. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/setup.cfg +0 -0
  48. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/__init__.py +0 -0
  49. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/conftest.py +0 -0
  50. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/0046d83a-edd6-4631-b57c-755cdcce8b7f.tei.xml +0 -0
  51. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/__init__.py +0 -0
  52. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/article_withdrawn.grobid.tei.xml +0 -0
  53. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.1038_s41477-023-01501-1.grobid.tei.xml +0 -0
  54. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.1038_s41477-023-01501-1.json +0 -0
  55. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.1038_s41586-023-05895-y.grobid.tei.xml +0 -0
  56. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.1038_s41586-023-05895-y.json +0 -0
  57. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.1038_s41598-023-32039-z.grobid.tei.xml +0 -0
  58. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.1038_s41598-023-32039-z.json +0 -0
  59. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.1371_journal.pone.0218311.grobid.tei.xml +0 -0
  60. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.1371_journal.pone.0218311.json +0 -0
  61. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.7554_elife.78558.grobid.tei.xml +0 -0
  62. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/10.7554_elife.78558.json +0 -0
  63. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/refs_offsets/2021.naacl-main.224.grobid.tei.xml +0 -0
  64. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/repeated_text/ijms-24-05988.grobid.tei.xml +0 -0
  65. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/repeated_text/ijms-24-05988.json +0 -0
  66. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/resources/repeated_text/ijms-24-05988.md +0 -0
  67. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/run_tests.py +0 -0
  68. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/test_client.py +0 -0
  69. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/test_config.json +0 -0
  70. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/test_conversions.py +0 -0
  71. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/test_integration.py +0 -0
  72. {grobid_client_python-0.2.0 → grobid_client_python-0.2.2}/tests/test_s3.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grobid-client-python
3
- Version: 0.2.0
3
+ Version: 0.2.2
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>
@@ -411,6 +411,9 @@ grobid_client --input "~/data/**/*.pdf" --output ~/results processFulltextDocu
411
411
 
412
412
  > [!NOTE]
413
413
  > `--input` accepts a directory, a single file, an **archive**, or a **glob pattern**:
414
+ > - **Directories** are processed recursively and the output mirrors the input tree: `~/pdfs/2023/a.pdf` →
415
+ > `~/results/2023/a.grobid.tei.xml` (subdirectories of `--output` are created as needed, and the JSON/Markdown
416
+ > outputs and error files sit next to each TEI file). Archives keep their internal paths the same way.
414
417
  > - **Archives** (`.zip`, `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`) are streamed: eligible entries are read into
415
418
  > memory in chunks of `queue_size` and sent to GROBID straight from there — the archive is never fully decompressed and
416
419
  > nothing but the results in `--output` ever touches the disk. If `--output` is omitted, results go to a directory named
@@ -636,18 +639,19 @@ settings.
636
639
 
637
640
  ### Choosing a queue size
638
641
 
639
- `queue_size` controls how many files are grouped into one processing chunk. It is a memory/durability knob, not a
642
+ `queue_size` controls how many files are grouped into one processing chunk. It is a memory knob, not a
640
643
  concurrency one: parallelism toward the GROBID server is set by `-n`, and each chunk is processed `n` files at a time.
641
644
  When `queue_size` is not set, the client picks a sensible default per input type (see the table above); set it
642
645
  explicitly only if you need to override that. A few guidelines:
643
646
 
644
647
  - **Never set it below `n`.** Effective parallelism is `min(n, queue_size)`: a queue smaller than the thread pool
645
648
  leaves workers idle. A bit of headroom above `n` (the default streaming value is 1.2 × `n`) keeps the pool busy.
646
- - **Results are written per chunk.** Output files land on disk only once a whole chunk has been processed, so a
647
- larger queue means results are written less frequently and an interrupted run loses at most one chunk of work
649
+ - **Results are written as they complete.** Each output file lands on disk as soon as its document has been
650
+ processed, whatever the queue size, so an interrupted run only loses the documents that were in flight
648
651
  (already-written results are skipped on re-run unless `--force` is used).
649
- - **Local PDF directories:** the queue holds only file paths, so large values are essentially free — the default is
650
- 1000. Lower it if you want results flushed to disk more often on long runs.
652
+ - **Local PDF directories:** the queue holds only file paths (each file is opened when it is sent), so large values
653
+ are essentially free — the default is 1000. At the end of each chunk the pool waits for its slowest document, so a
654
+ large queue keeps the workers busy.
651
655
  - **Archives (zip/tar) and S3:** each chunk is read or downloaded *into memory* before processing starts, so peak RAM
652
656
  grows with `queue_size × average file size`. Keep it moderate — the 1.2 × `n` default is a safe floor; going up to
653
657
  a few multiples of `n` (e.g. 2–5 ×) trades memory for slightly better throughput around chunk boundaries.
@@ -189,6 +189,9 @@ grobid_client --input "~/data/**/*.pdf" --output ~/results processFulltextDocu
189
189
 
190
190
  > [!NOTE]
191
191
  > `--input` accepts a directory, a single file, an **archive**, or a **glob pattern**:
192
+ > - **Directories** are processed recursively and the output mirrors the input tree: `~/pdfs/2023/a.pdf` →
193
+ > `~/results/2023/a.grobid.tei.xml` (subdirectories of `--output` are created as needed, and the JSON/Markdown
194
+ > outputs and error files sit next to each TEI file). Archives keep their internal paths the same way.
192
195
  > - **Archives** (`.zip`, `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`) are streamed: eligible entries are read into
193
196
  > memory in chunks of `queue_size` and sent to GROBID straight from there — the archive is never fully decompressed and
194
197
  > nothing but the results in `--output` ever touches the disk. If `--output` is omitted, results go to a directory named
@@ -414,18 +417,19 @@ settings.
414
417
 
415
418
  ### Choosing a queue size
416
419
 
417
- `queue_size` controls how many files are grouped into one processing chunk. It is a memory/durability knob, not a
420
+ `queue_size` controls how many files are grouped into one processing chunk. It is a memory knob, not a
418
421
  concurrency one: parallelism toward the GROBID server is set by `-n`, and each chunk is processed `n` files at a time.
419
422
  When `queue_size` is not set, the client picks a sensible default per input type (see the table above); set it
420
423
  explicitly only if you need to override that. A few guidelines:
421
424
 
422
425
  - **Never set it below `n`.** Effective parallelism is `min(n, queue_size)`: a queue smaller than the thread pool
423
426
  leaves workers idle. A bit of headroom above `n` (the default streaming value is 1.2 × `n`) keeps the pool busy.
424
- - **Results are written per chunk.** Output files land on disk only once a whole chunk has been processed, so a
425
- larger queue means results are written less frequently and an interrupted run loses at most one chunk of work
427
+ - **Results are written as they complete.** Each output file lands on disk as soon as its document has been
428
+ processed, whatever the queue size, so an interrupted run only loses the documents that were in flight
426
429
  (already-written results are skipped on re-run unless `--force` is used).
427
- - **Local PDF directories:** the queue holds only file paths, so large values are essentially free — the default is
428
- 1000. Lower it if you want results flushed to disk more often on long runs.
430
+ - **Local PDF directories:** the queue holds only file paths (each file is opened when it is sent), so large values
431
+ are essentially free — the default is 1000. At the end of each chunk the pool waits for its slowest document, so a
432
+ large queue keeps the workers busy.
429
433
  - **Archives (zip/tar) and S3:** each chunk is read or downloaded *into memory* before processing starts, so peak RAM
430
434
  grows with `queue_size × average file size`. Keep it moderate — the 1.2 × `n` default is a safe floor; going up to
431
435
  a few multiples of `n` (e.g. 2–5 ×) trades memory for slightly better throughput around chunk boundaries.
@@ -34,7 +34,7 @@ import shutil
34
34
  import tarfile
35
35
  import tempfile
36
36
  import zipfile
37
- from typing import Any, BinaryIO, Callable, Optional, Tuple, Union
37
+ from typing import Any, BinaryIO, Callable, List, Optional, Tuple, Union
38
38
  import copy
39
39
 
40
40
  from .format.TEI2LossyJSON import TEI2LossyJSONConverter
@@ -449,10 +449,15 @@ class GrobidClient(ApiClient):
449
449
  input_file_path = pathlib.Path(input_file)
450
450
 
451
451
  if output is not None:
452
- # Calculate relative path from input_path, then join with output directory
452
+ # Mirror the input tree under the output directory: the path of the
453
+ # file relative to input_path is kept (only the extension changes),
454
+ # so sub/paper.pdf -> <output>/sub/paper.grobid.tei.xml. Keeping the
455
+ # subdirectories also prevents two inputs that share a basename
456
+ # from mapping to the same output file.
453
457
  input_path_abs = pathlib.Path(input_path).resolve()
454
458
  input_file_rel = input_file_path.resolve().relative_to(input_path_abs)
455
- filename = pathlib.Path(output) / f"{input_file_rel.stem}.grobid.tei.xml"
459
+ filename = pathlib.Path(output) / input_file_rel.with_name(
460
+ f"{input_file_rel.stem}.grobid.tei.xml")
456
461
  else:
457
462
  # Use the same directory as the input file
458
463
  filename = input_file_path.parent / f"{input_file_path.stem}.grobid.tei.xml"
@@ -629,9 +634,14 @@ class GrobidClient(ApiClient):
629
634
 
630
635
  # Partition into archives (streamed), remote loose files (s3) and local
631
636
  # filesystem files (directories are expanded to their eligible files).
637
+ # Local files are grouped with the base directory their output paths
638
+ # are computed from: a directory given as input is the base of the
639
+ # files found inside it (so the output mirrors its tree), while loose
640
+ # files (single paths, glob matches) share a common ancestor.
632
641
  archive_paths = []
633
642
  remote_files = []
634
- fs_files = []
643
+ fs_groups: List[Tuple[str, list]] = []
644
+ loose_files = []
635
645
  for path in matched_paths:
636
646
  if self._is_s3(path):
637
647
  if self._looks_like_archive(path):
@@ -643,12 +653,20 @@ class GrobidClient(ApiClient):
643
653
  elif self._is_archive(path):
644
654
  archive_paths.append(path)
645
655
  elif os.path.isdir(path):
646
- fs_files.extend(self._collect_directory_files(path, service))
656
+ dir_files = self._collect_directory_files(path, service)
657
+ if dir_files:
658
+ fs_groups.append((path, dir_files))
647
659
  elif os.path.isfile(path) and self._is_eligible_input(os.path.basename(path), service):
648
- fs_files.append(path)
660
+ loose_files.append(path)
661
+ elif not os.path.exists(path):
662
+ self.logger.warning(f"Input path does not exist: {path}")
649
663
  else:
650
664
  self.logger.debug(f"Skipping input (not an eligible file/dir/archive): {path}")
651
665
 
666
+ if loose_files:
667
+ fs_groups.append((self._common_base(loose_files), loose_files))
668
+ fs_files = [f for _, files in fs_groups for f in files]
669
+
652
670
  if not fs_files and not archive_paths and not remote_files:
653
671
  self.logger.warning(f"No eligible files found in input(s): {inputs}")
654
672
  return
@@ -667,16 +685,17 @@ class GrobidClient(ApiClient):
667
685
  # Local files gathered from directories and/or loose glob matches
668
686
  if fs_files:
669
687
  print(f"Found {len(fs_files)} local file(s) to process")
670
- bp, be, bs = self._run_file_batches(
671
- service, fs_files, self._common_base(fs_files), output, n,
672
- generate_ids, consolidate_header, consolidate_citations,
673
- include_raw_citations, include_raw_affiliations, tei_coordinates,
674
- segment_sentences, force, verbose, flavor, json_output, markdown_output,
675
- skip_errors=skip_errors
676
- )
677
- processed_files_count += bp
678
- errors_files_count += be
679
- skipped_files_count += bs
688
+ for base_dir, group_files in fs_groups:
689
+ bp, be, bs = self._run_file_batches(
690
+ service, group_files, base_dir, output, n,
691
+ generate_ids, consolidate_header, consolidate_citations,
692
+ include_raw_citations, include_raw_affiliations, tei_coordinates,
693
+ segment_sentences, force, verbose, flavor, json_output, markdown_output,
694
+ skip_errors=skip_errors
695
+ )
696
+ processed_files_count += bp
697
+ errors_files_count += be
698
+ skipped_files_count += bs
680
699
  total_files += len(fs_files)
681
700
 
682
701
  # Loose remote (s3) files: streamed into memory one chunk at a time
@@ -927,6 +946,10 @@ class GrobidClient(ApiClient):
927
946
 
928
947
  def _is_eligible_input(self, filename: str, service: str) -> bool:
929
948
  """Return True if a file name is a valid input for the given service."""
949
+ # macOS AppleDouble metadata entries (._paper.pdf), which tar and zip
950
+ # add next to the real files, carry the extension but not the document
951
+ if filename.startswith("._"):
952
+ return False
930
953
  if filename.endswith(".pdf") or filename.endswith(".PDF"):
931
954
  return True
932
955
  if service == 'processCitationList' and (
@@ -1090,8 +1113,9 @@ class GrobidClient(ApiClient):
1090
1113
  the chunk size and nothing but the results ever touches the disk. (The
1091
1114
  exception is ``processCitationList``, whose ``.txt`` inputs are read
1092
1115
  from a path and therefore still go through a temporary directory.)
1093
- Output files follow the same flat naming convention as directory
1094
- processing (one ``<stem>`` per result, in ``output``).
1116
+ Output files follow the same naming convention as directory
1117
+ processing: the entry's path inside the archive is mirrored under
1118
+ ``output`` (``nested/y.pdf`` -> ``<output>/nested/y.grobid.tei.xml``).
1095
1119
  """
1096
1120
  start_time = time.time()
1097
1121
  self._warn_on_consolidation_timeout(consolidate_citations)
@@ -1529,75 +1553,75 @@ class GrobidClient(ApiClient):
1529
1553
 
1530
1554
  results.append(r)
1531
1555
 
1532
- for r in concurrent.futures.as_completed(results):
1533
- input_file, status, text = r.result()
1534
- filename = self._output_file_name(input_file, input_path, output)
1556
+ for r in concurrent.futures.as_completed(results):
1557
+ input_file, status, text = r.result()
1558
+ filename = self._output_file_name(input_file, input_path, output)
1535
1559
 
1536
- if status != 200 or text is None:
1537
- self.logger.error(f"Processing of {input_file} failed with error {status}: {text}")
1538
- error_count += 1
1539
- # writing error file with suffixed error code
1540
- error_filename = self._error_file_name(filename, status)
1541
- try:
1542
- self._write_atomic(error_filename, text if text is not None else "")
1543
- self.logger.info(f"Error details written to {error_filename}")
1544
- # A previous run may have failed with a different status code;
1545
- # keep only the marker of this run so the error state of the
1546
- # document stays unambiguous (and skippable).
1547
- self._remove_error_files(filename, keep=error_filename)
1548
- except OSError as e:
1549
- self.logger.error(f"Failed to write error file {filename}: {str(e)}")
1550
- else:
1551
- processed_count += 1
1552
- # writing TEI file
1553
- try:
1554
- self._write_atomic(filename, text)
1555
- self.logger.debug(f"Successfully wrote TEI file: {filename}")
1560
+ if status != 200 or text is None:
1561
+ self.logger.error(f"Processing of {input_file} failed with error {status}: {text}")
1562
+ error_count += 1
1563
+ # writing error file with suffixed error code
1564
+ error_filename = self._error_file_name(filename, status)
1565
+ try:
1566
+ self._write_atomic(error_filename, text if text is not None else "")
1567
+ self.logger.info(f"Error details written to {error_filename}")
1568
+ # A previous run may have failed with a different status code;
1569
+ # keep only the marker of this run so the error state of the
1570
+ # document stays unambiguous (and skippable).
1571
+ self._remove_error_files(filename, keep=error_filename)
1572
+ except OSError as e:
1573
+ self.logger.error(f"Failed to write error file {filename}: {str(e)}")
1574
+ else:
1575
+ processed_count += 1
1576
+ # writing TEI file
1577
+ try:
1578
+ self._write_atomic(filename, text)
1579
+ self.logger.debug(f"Successfully wrote TEI file: {filename}")
1556
1580
 
1557
- # The document no longer is in error: drop the marker left by
1558
- # a previous failed run so it is not skipped by --skip-errors.
1559
- self._remove_error_files(filename)
1581
+ # The document no longer is in error: drop the marker left by
1582
+ # a previous failed run so it is not skipped by --skip-errors.
1583
+ self._remove_error_files(filename)
1560
1584
 
1561
1585
 
1562
- # Convert to JSON if requested
1563
- if json_output:
1564
- try:
1565
- converter = TEI2LossyJSONConverter()
1566
- json_data = converter.convert_tei_file(filename, stream=False)
1567
-
1568
- if json_data:
1569
- json_filename = filename.replace('.grobid.tei.xml', '.json')
1570
- # Always write JSON file when TEI is written (respects --force behavior)
1571
- json_filename_expanded = os.path.expanduser(json_filename)
1572
- self._write_atomic(
1573
- json_filename_expanded,
1574
- json.dumps(json_data, indent=2, ensure_ascii=False))
1575
- self.logger.debug(f"Successfully wrote JSON file: {json_filename_expanded}")
1576
- else:
1577
- self.logger.warning(f"Failed to convert TEI to JSON for {filename}")
1578
- except Exception as e:
1579
- self.logger.error(f"Failed to convert TEI to JSON for {filename}: {str(e)}")
1580
-
1581
- # Convert to Markdown if requested
1582
- if markdown_output:
1583
- try:
1584
- from .format.TEI2Markdown import TEI2MarkdownConverter
1585
- converter = TEI2MarkdownConverter()
1586
- markdown_data = converter.convert_tei_file(filename)
1587
-
1588
- if markdown_data is not None:
1589
- markdown_filename = filename.replace('.grobid.tei.xml', '.md')
1590
- # Always write Markdown file when TEI is written (respects --force behavior)
1591
- markdown_filename_expanded = os.path.expanduser(markdown_filename)
1592
- self._write_atomic(markdown_filename_expanded, markdown_data)
1593
- self.logger.debug(f"Successfully wrote Markdown file: {markdown_filename_expanded}")
1594
- else:
1595
- self.logger.warning(f"Failed to convert TEI to Markdown for {filename}")
1596
- except Exception as e:
1597
- self.logger.error(f"Failed to convert TEI to Markdown for {filename}: {str(e)}")
1598
-
1599
- except OSError as e:
1600
- self.logger.error(f"Failed to write TEI XML file {filename}: {str(e)}")
1586
+ # Convert to JSON if requested
1587
+ if json_output:
1588
+ try:
1589
+ converter = TEI2LossyJSONConverter()
1590
+ json_data = converter.convert_tei_file(filename, stream=False)
1591
+
1592
+ if json_data:
1593
+ json_filename = filename.replace('.grobid.tei.xml', '.json')
1594
+ # Always write JSON file when TEI is written (respects --force behavior)
1595
+ json_filename_expanded = os.path.expanduser(json_filename)
1596
+ self._write_atomic(
1597
+ json_filename_expanded,
1598
+ json.dumps(json_data, indent=2, ensure_ascii=False))
1599
+ self.logger.debug(f"Successfully wrote JSON file: {json_filename_expanded}")
1600
+ else:
1601
+ self.logger.warning(f"Failed to convert TEI to JSON for {filename}")
1602
+ except Exception as e:
1603
+ self.logger.error(f"Failed to convert TEI to JSON for {filename}: {str(e)}")
1604
+
1605
+ # Convert to Markdown if requested
1606
+ if markdown_output:
1607
+ try:
1608
+ from .format.TEI2Markdown import TEI2MarkdownConverter
1609
+ converter = TEI2MarkdownConverter()
1610
+ markdown_data = converter.convert_tei_file(filename)
1611
+
1612
+ if markdown_data is not None:
1613
+ markdown_filename = filename.replace('.grobid.tei.xml', '.md')
1614
+ # Always write Markdown file when TEI is written (respects --force behavior)
1615
+ markdown_filename_expanded = os.path.expanduser(markdown_filename)
1616
+ self._write_atomic(markdown_filename_expanded, markdown_data)
1617
+ self.logger.debug(f"Successfully wrote Markdown file: {markdown_filename_expanded}")
1618
+ else:
1619
+ self.logger.warning(f"Failed to convert TEI to Markdown for {filename}")
1620
+ except Exception as e:
1621
+ self.logger.error(f"Failed to convert TEI to Markdown for {filename}: {str(e)}")
1622
+
1623
+ except OSError as e:
1624
+ self.logger.error(f"Failed to write TEI XML file {filename}: {str(e)}")
1601
1625
 
1602
1626
  # Calculate batch statistics
1603
1627
  batch_runtime = time.time() - batch_start_time
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: grobid-client-python
3
- Version: 0.2.0
3
+ Version: 0.2.2
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>
@@ -411,6 +411,9 @@ grobid_client --input "~/data/**/*.pdf" --output ~/results processFulltextDocu
411
411
 
412
412
  > [!NOTE]
413
413
  > `--input` accepts a directory, a single file, an **archive**, or a **glob pattern**:
414
+ > - **Directories** are processed recursively and the output mirrors the input tree: `~/pdfs/2023/a.pdf` →
415
+ > `~/results/2023/a.grobid.tei.xml` (subdirectories of `--output` are created as needed, and the JSON/Markdown
416
+ > outputs and error files sit next to each TEI file). Archives keep their internal paths the same way.
414
417
  > - **Archives** (`.zip`, `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`) are streamed: eligible entries are read into
415
418
  > memory in chunks of `queue_size` and sent to GROBID straight from there — the archive is never fully decompressed and
416
419
  > nothing but the results in `--output` ever touches the disk. If `--output` is omitted, results go to a directory named
@@ -636,18 +639,19 @@ settings.
636
639
 
637
640
  ### Choosing a queue size
638
641
 
639
- `queue_size` controls how many files are grouped into one processing chunk. It is a memory/durability knob, not a
642
+ `queue_size` controls how many files are grouped into one processing chunk. It is a memory knob, not a
640
643
  concurrency one: parallelism toward the GROBID server is set by `-n`, and each chunk is processed `n` files at a time.
641
644
  When `queue_size` is not set, the client picks a sensible default per input type (see the table above); set it
642
645
  explicitly only if you need to override that. A few guidelines:
643
646
 
644
647
  - **Never set it below `n`.** Effective parallelism is `min(n, queue_size)`: a queue smaller than the thread pool
645
648
  leaves workers idle. A bit of headroom above `n` (the default streaming value is 1.2 × `n`) keeps the pool busy.
646
- - **Results are written per chunk.** Output files land on disk only once a whole chunk has been processed, so a
647
- larger queue means results are written less frequently and an interrupted run loses at most one chunk of work
649
+ - **Results are written as they complete.** Each output file lands on disk as soon as its document has been
650
+ processed, whatever the queue size, so an interrupted run only loses the documents that were in flight
648
651
  (already-written results are skipped on re-run unless `--force` is used).
649
- - **Local PDF directories:** the queue holds only file paths, so large values are essentially free — the default is
650
- 1000. Lower it if you want results flushed to disk more often on long runs.
652
+ - **Local PDF directories:** the queue holds only file paths (each file is opened when it is sent), so large values
653
+ are essentially free — the default is 1000. At the end of each chunk the pool waits for its slowest document, so a
654
+ large queue keeps the workers busy.
651
655
  - **Archives (zip/tar) and S3:** each chunk is read or downloaded *into memory* before processing starts, so peak RAM
652
656
  grows with `queue_size × average file size`. Keep it moderate — the 1.2 × `n` default is a safe floor; going up to
653
657
  a few multiples of `n` (e.g. 2–5 ×) trades memory for slightly better throughput around chunk boundaries.
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.2.2",
3
+ "distance": 0,
4
+ "node": "g53114ab6c37ab5421b22efbeceeddd171cbff8f3",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-11-06"
8
+ }
@@ -3,7 +3,7 @@ requires = ["setuptools", "setuptools-scm"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [tool.bumpversion]
6
- current_version = "0.2.0"
6
+ current_version = "0.2.2"
7
7
  commit = "true"
8
8
  tag = "true"
9
9
  tag_name = "v{new_version}"
@@ -226,6 +226,17 @@ class TestGrobidClient:
226
226
 
227
227
  assert result == expected
228
228
 
229
+ def test_output_file_name_preserves_subdirectories(self):
230
+ """The path relative to the input directory is mirrored under the output."""
231
+ with patch('grobid_client.grobid_client.GrobidClient._test_server_connection'):
232
+ with patch('grobid_client.grobid_client.GrobidClient._configure_logging'):
233
+ client = GrobidClient(check_server=False)
234
+
235
+ result = client._output_file_name('/input/path/sub/deeper/document.pdf', '/input/path', '/output/path')
236
+ expected = os.path.join('/output/path', 'sub', 'deeper', 'document.grobid.tei.xml')
237
+
238
+ assert result == expected
239
+
229
240
  def test_output_file_name_without_output_path(self):
230
241
  """Test _output_file_name method without output path."""
231
242
  with patch('grobid_client.grobid_client.GrobidClient._test_server_connection'):
@@ -753,11 +764,13 @@ class TestArchiveInput:
753
764
 
754
765
  @staticmethod
755
766
  def _tei_outputs(output_dir):
767
+ """TEI outputs under output_dir, as paths relative to it (posix separators)."""
756
768
  found = []
757
769
  for root, _, files in os.walk(output_dir):
758
770
  for f in files:
759
771
  if f.endswith('.grobid.tei.xml'):
760
- found.append(f)
772
+ rel = os.path.relpath(os.path.join(root, f), output_dir)
773
+ found.append(rel.replace(os.sep, '/'))
761
774
  return sorted(found)
762
775
 
763
776
  def test_is_archive(self):
@@ -798,7 +811,7 @@ class TestArchiveInput:
798
811
  posted = self._run(client, zip_path, out)
799
812
 
800
813
  # all 3 PDFs processed, the .txt ignored
801
- assert self._tei_outputs(out) == ['a.grobid.tei.xml', 'b.grobid.tei.xml', 'c.grobid.tei.xml']
814
+ assert self._tei_outputs(out) == ['a.grobid.tei.xml', 'c.grobid.tei.xml', 'sub/b.grobid.tei.xml']
802
815
  # each entry was posted straight from memory, under its archive name
803
816
  assert sorted(posted) == [
804
817
  ('a.pdf', b'%PDF-a'),
@@ -813,7 +826,8 @@ class TestArchiveInput:
813
826
  self._make_targz(tar_path, {'x.pdf': b'%PDF-x', 'nested/y.pdf': b'%PDF-y'}, d)
814
827
  out = os.path.join(d, 'out')
815
828
  posted = self._run(client, tar_path, out)
816
- assert self._tei_outputs(out) == ['x.grobid.tei.xml', 'y.grobid.tei.xml']
829
+ # the entry's path inside the archive is mirrored in the output
830
+ assert self._tei_outputs(out) == ['nested/y.grobid.tei.xml', 'x.grobid.tei.xml']
817
831
  assert sorted(posted) == [
818
832
  (os.path.join('nested', 'y.pdf'), b'%PDF-y'),
819
833
  ('x.pdf', b'%PDF-x'),
@@ -855,7 +869,7 @@ class TestArchiveInput:
855
869
  client.process('processFulltextDocument', zip_path, output=out, force=True)
856
870
 
857
871
  mock_mkdtemp.assert_not_called()
858
- assert self._tei_outputs(out) == ['a.grobid.tei.xml', 'b.grobid.tei.xml']
872
+ assert self._tei_outputs(out) == ['a.grobid.tei.xml', 'sub/b.grobid.tei.xml']
859
873
 
860
874
  def test_citation_lists_are_still_extracted_to_disk(self):
861
875
  """process_txt reads from a path, so citation lists keep the temp-dir route."""
@@ -1039,11 +1053,13 @@ class TestGlobInput:
1039
1053
 
1040
1054
  @staticmethod
1041
1055
  def _tei_outputs(output_dir):
1056
+ """TEI outputs under output_dir, as paths relative to it (posix separators)."""
1042
1057
  found = []
1043
1058
  for root, _, files in os.walk(output_dir):
1044
1059
  for f in files:
1045
1060
  if f.endswith('.grobid.tei.xml'):
1046
- found.append(f)
1061
+ rel = os.path.relpath(os.path.join(root, f), output_dir)
1062
+ found.append(rel.replace(os.sep, '/'))
1047
1063
  return sorted(found)
1048
1064
 
1049
1065
  def _run(self, client, pattern, output):
@@ -1085,7 +1101,8 @@ class TestGlobInput:
1085
1101
  open(os.path.join(d, 'sub2', 'note.txt'), 'wb').close()
1086
1102
  out = os.path.join(d, 'out')
1087
1103
  self._run(client, os.path.join(d, '**', '*.pdf'), out)
1088
- assert self._tei_outputs(out) == ['a.grobid.tei.xml', 'b.grobid.tei.xml']
1104
+ # loose glob matches are placed relative to their common ancestor
1105
+ assert self._tei_outputs(out) == ['sub1/a.grobid.tei.xml', 'sub2/b.grobid.tei.xml']
1089
1106
 
1090
1107
  def test_glob_no_match_warns(self):
1091
1108
  client = self._client()
@@ -1107,6 +1124,107 @@ class TestGlobInput:
1107
1124
  assert f1.startswith(base) and f2.startswith(base)
1108
1125
 
1109
1126
 
1127
+ class TestDirectoryInput:
1128
+ """A directory input is mirrored under --output (see issue about flat outputs).
1129
+
1130
+ The TEI of ``<in>/2023/p.pdf`` goes to ``<out>/2023/p.grobid.tei.xml``: the
1131
+ subdirectories are kept, so two documents sharing a basename in different
1132
+ subdirectories do not collide on the same output file.
1133
+ """
1134
+
1135
+ def _client(self, queue_size=50):
1136
+ with patch('grobid_client.grobid_client.GrobidClient._test_server_connection'):
1137
+ with patch('grobid_client.grobid_client.GrobidClient._configure_logging'):
1138
+ client = GrobidClient(check_server=False)
1139
+ client.logger = Mock()
1140
+ client.config['queue_size'] = queue_size
1141
+ return client
1142
+
1143
+ @staticmethod
1144
+ def _touch(*parts):
1145
+ path = os.path.join(*parts)
1146
+ os.makedirs(os.path.dirname(path), exist_ok=True)
1147
+ open(path, 'wb').close()
1148
+ return path
1149
+
1150
+ @staticmethod
1151
+ def _tei_outputs(output_dir):
1152
+ found = []
1153
+ for root, _, files in os.walk(output_dir):
1154
+ for f in files:
1155
+ if f.endswith('.grobid.tei.xml'):
1156
+ rel = os.path.relpath(os.path.join(root, f), output_dir)
1157
+ found.append(rel.replace(os.sep, '/'))
1158
+ return sorted(found)
1159
+
1160
+ def _run(self, client, input_dir, output, force=True):
1161
+ posted = []
1162
+
1163
+ def fake_post(url, files=None, data=None, headers=None, timeout=None):
1164
+ posted.append(files['input'][0])
1165
+ resp = Mock()
1166
+ resp.text = '<TEI>ok</TEI>'
1167
+ return (resp, 200)
1168
+
1169
+ with patch.object(GrobidClient, 'post', side_effect=fake_post):
1170
+ client.process('processFulltextDocument', input_dir, output=output, force=force)
1171
+ return posted
1172
+
1173
+ def test_output_mirrors_input_tree(self):
1174
+ client = self._client()
1175
+ with tempfile.TemporaryDirectory() as d:
1176
+ self._touch(d, 'in', 'top.pdf')
1177
+ self._touch(d, 'in', '2023', 'p.pdf')
1178
+ self._touch(d, 'in', '2024', 'p.pdf') # same basename as 2023/p.pdf
1179
+ self._touch(d, 'in', '2024', 'deep', 'q.pdf')
1180
+ out = os.path.join(d, 'out')
1181
+ posted = self._run(client, os.path.join(d, 'in'), out)
1182
+ assert len(posted) == 4
1183
+ assert self._tei_outputs(out) == [
1184
+ '2023/p.grobid.tei.xml',
1185
+ '2024/deep/q.grobid.tei.xml',
1186
+ '2024/p.grobid.tei.xml',
1187
+ 'top.grobid.tei.xml',
1188
+ ]
1189
+
1190
+ def test_same_basename_in_subdirectories_both_skipped_on_rerun(self):
1191
+ """With flat outputs the second p.pdf used to be reported as already done."""
1192
+ client = self._client()
1193
+ with tempfile.TemporaryDirectory() as d:
1194
+ self._touch(d, 'in', '2023', 'p.pdf')
1195
+ self._touch(d, 'in', '2024', 'p.pdf')
1196
+ out = os.path.join(d, 'out')
1197
+ assert len(self._run(client, os.path.join(d, 'in'), out)) == 2
1198
+ # second run without --force: both outputs exist, nothing is posted
1199
+ assert self._run(client, os.path.join(d, 'in'), out, force=False) == []
1200
+ assert self._tei_outputs(out) == ['2023/p.grobid.tei.xml', '2024/p.grobid.tei.xml']
1201
+
1202
+ def test_single_subdirectory_keeps_its_level(self):
1203
+ """The base is the directory given as input, not the common path of the files."""
1204
+ client = self._client()
1205
+ with tempfile.TemporaryDirectory() as d:
1206
+ self._touch(d, 'in', '2024', 'a.pdf')
1207
+ self._touch(d, 'in', '2024', 'b.pdf')
1208
+ out = os.path.join(d, 'out')
1209
+ self._run(client, os.path.join(d, 'in'), out)
1210
+ assert self._tei_outputs(out) == ['2024/a.grobid.tei.xml', '2024/b.grobid.tei.xml']
1211
+
1212
+ def test_trailing_slash_and_relative_input(self):
1213
+ client = self._client()
1214
+ with tempfile.TemporaryDirectory() as d:
1215
+ self._touch(d, 'in', 'sub', 'a.pdf')
1216
+ out = os.path.join(d, 'out')
1217
+ self._run(client, os.path.join(d, 'in') + os.sep, out)
1218
+ assert self._tei_outputs(out) == ['sub/a.grobid.tei.xml']
1219
+
1220
+ def test_no_output_writes_next_to_input(self):
1221
+ client = self._client()
1222
+ with tempfile.TemporaryDirectory() as d:
1223
+ self._touch(d, 'in', 'sub', 'a.pdf')
1224
+ self._run(client, os.path.join(d, 'in'), None)
1225
+ assert os.path.isfile(os.path.join(d, 'in', 'sub', 'a.grobid.tei.xml'))
1226
+
1227
+
1110
1228
  class TestAtomicWrite:
1111
1229
  """A killed task must never leave a partial output that resume accepts.
1112
1230
 
@@ -1378,6 +1496,43 @@ class TestSkipErrors:
1378
1496
  assert (processed, errors, skipped) == (0, 0, 1)
1379
1497
 
1380
1498
 
1499
+ class TestIncrementalWrites:
1500
+ """Results are written as each document finishes, not once the whole queue is done."""
1501
+
1502
+ def test_finished_document_is_written_while_others_are_in_flight(self):
1503
+ with patch('grobid_client.grobid_client.GrobidClient._test_server_connection'):
1504
+ with patch('grobid_client.grobid_client.GrobidClient._configure_logging'):
1505
+ client = GrobidClient(check_server=False)
1506
+ client.logger = Mock()
1507
+
1508
+ with tempfile.TemporaryDirectory() as d:
1509
+ inp = os.path.join(d, 'in')
1510
+ out = os.path.join(d, 'out')
1511
+ os.makedirs(inp)
1512
+ for name in ('a.pdf', 'b.pdf'):
1513
+ with open(os.path.join(inp, name), 'wb') as f:
1514
+ f.write(b'%PDF')
1515
+ a_output = os.path.join(out, 'a.grobid.tei.xml')
1516
+ seen_while_in_flight = []
1517
+
1518
+ def fake_post(url, files=None, data=None, headers=None, timeout=None):
1519
+ if os.path.basename(files['input'][0]) == 'b.pdf':
1520
+ # Hold b until a's result reaches the disk (or give up)
1521
+ deadline = time.time() + 5
1522
+ while not os.path.isfile(a_output) and time.time() < deadline:
1523
+ time.sleep(0.01)
1524
+ seen_while_in_flight.append(os.path.isfile(a_output))
1525
+ resp = Mock()
1526
+ resp.text = '<TEI>ok</TEI>'
1527
+ return (resp, 200)
1528
+
1529
+ with patch.object(GrobidClient, 'post', side_effect=fake_post):
1530
+ client.process('processFulltextDocument', inp, output=out, n=2)
1531
+
1532
+ assert seen_while_in_flight == [True]
1533
+ assert os.path.isfile(os.path.join(out, 'b.grobid.tei.xml'))
1534
+
1535
+
1381
1536
  class TestInMemoryDocuments:
1382
1537
  """Processing a PDF that is already in memory, without writing it to disk.
1383
1538
 
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.2.0",
3
- "distance": 0,
4
- "node": "gfd1858e863d62f7a4ee0d1a2d8dc4f96d1e407c9",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-22"
8
- }