risforge 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {risforge-0.2.0/src/risforge.egg-info → risforge-0.3.0}/PKG-INFO +20 -1
  2. {risforge-0.2.0 → risforge-0.3.0}/README.md +14 -0
  3. {risforge-0.2.0 → risforge-0.3.0}/pyproject.toml +14 -1
  4. {risforge-0.2.0 → risforge-0.3.0}/src/risforge/__init__.py +1 -1
  5. {risforge-0.2.0 → risforge-0.3.0}/src/risforge/enrichment.py +14 -1
  6. {risforge-0.2.0 → risforge-0.3.0}/src/risforge/pipeline.py +29 -1
  7. {risforge-0.2.0 → risforge-0.3.0/src/risforge.egg-info}/PKG-INFO +20 -1
  8. risforge-0.3.0/src/risforge.egg-info/SOURCES.txt +36 -0
  9. risforge-0.3.0/src/risforge.egg-info/entry_points.txt +5 -0
  10. {risforge-0.2.0 → risforge-0.3.0}/src/risforge.egg-info/requires.txt +7 -0
  11. risforge-0.3.0/src/risforge.egg-info/top_level.txt +2 -0
  12. risforge-0.3.0/src/risforge_gui/__init__.py +21 -0
  13. risforge-0.3.0/src/risforge_gui/app.py +34 -0
  14. risforge-0.3.0/src/risforge_gui/dialogs.py +49 -0
  15. risforge-0.3.0/src/risforge_gui/file_counter.py +41 -0
  16. risforge-0.3.0/src/risforge_gui/main_window.py +373 -0
  17. risforge-0.3.0/src/risforge_gui/models.py +135 -0
  18. risforge-0.3.0/src/risforge_gui/theme.py +236 -0
  19. risforge-0.3.0/src/risforge_gui/widgets/__init__.py +1 -0
  20. risforge-0.3.0/src/risforge_gui/widgets/config_panel.py +139 -0
  21. risforge-0.3.0/src/risforge_gui/widgets/input_panel.py +161 -0
  22. risforge-0.3.0/src/risforge_gui/widgets/log_panel.py +36 -0
  23. risforge-0.3.0/src/risforge_gui/widgets/output_panel.py +112 -0
  24. risforge-0.3.0/src/risforge_gui/widgets/progress_panel.py +134 -0
  25. risforge-0.3.0/src/risforge_gui/widgets/results_panel.py +119 -0
  26. risforge-0.3.0/src/risforge_gui/worker.py +234 -0
  27. risforge-0.2.0/src/risforge.egg-info/SOURCES.txt +0 -21
  28. risforge-0.2.0/src/risforge.egg-info/entry_points.txt +0 -2
  29. risforge-0.2.0/src/risforge.egg-info/top_level.txt +0 -1
  30. {risforge-0.2.0 → risforge-0.3.0}/LICENSE +0 -0
  31. {risforge-0.2.0 → risforge-0.3.0}/setup.cfg +0 -0
  32. {risforge-0.2.0 → risforge-0.3.0}/src/risforge/cleaning.py +0 -0
  33. {risforge-0.2.0 → risforge-0.3.0}/src/risforge/cli.py +0 -0
  34. {risforge-0.2.0 → risforge-0.3.0}/src/risforge/exceptions.py +0 -0
  35. {risforge-0.2.0 → risforge-0.3.0}/src/risforge/merging.py +0 -0
  36. {risforge-0.2.0 → risforge-0.3.0}/src/risforge.egg-info/dependency_links.txt +0 -0
  37. {risforge-0.2.0 → risforge-0.3.0}/tests/test_cleaning.py +0 -0
  38. {risforge-0.2.0 → risforge-0.3.0}/tests/test_cli.py +0 -0
  39. {risforge-0.2.0 → risforge-0.3.0}/tests/test_enrichment.py +0 -0
  40. {risforge-0.2.0 → risforge-0.3.0}/tests/test_merging.py +0 -0
  41. {risforge-0.2.0 → risforge-0.3.0}/tests/test_pipeline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: risforge
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews.
5
5
  Author-email: Amyr <amyrhexa@gmail.com>
6
6
  License: MIT
@@ -32,6 +32,11 @@ Requires-Dist: pytest>=8.0; extra == "dev"
32
32
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
33
33
  Requires-Dist: build>=1.2; extra == "dev"
34
34
  Requires-Dist: twine>=5.0; extra == "dev"
35
+ Provides-Extra: gui
36
+ Requires-Dist: PySide6>=6.5; extra == "gui"
37
+ Provides-Extra: gui-dev
38
+ Requires-Dist: risforge[gui]; extra == "gui-dev"
39
+ Requires-Dist: pytest-qt>=4.4; extra == "gui-dev"
35
40
  Dynamic: license-file
36
41
 
37
42
  # risforge
@@ -90,6 +95,8 @@ overlapping database exports — whether that's one export or several.
90
95
  enriched.
91
96
  - **Library or CLI** — use it as `import risforge` in a script/notebook,
92
97
  or as a single `risforge` command.
98
+ - **Optional desktop GUI** — a cross-platform PySide6 app (`risforge-gui`)
99
+ for drag-and-drop use, with no command line required.
93
100
 
94
101
  ## Installation
95
102
 
@@ -99,6 +106,18 @@ pip install risforge
99
106
 
100
107
  Requires Python 3.10+.
101
108
 
109
+ There's also an optional desktop GUI, for researchers who'd rather not use
110
+ the command line:
111
+
112
+ ```bash
113
+ pip install "risforge[gui]"
114
+ risforge-gui
115
+ ```
116
+
117
+ It's a thin PySide6 interface over this same package -- see
118
+ [docs/gui.md](docs/gui.md) for the full walkthrough. The base install above
119
+ never pulls in GUI dependencies.
120
+
102
121
  ## Quick start
103
122
 
104
123
  ### Command line
@@ -54,6 +54,8 @@ overlapping database exports — whether that's one export or several.
54
54
  enriched.
55
55
  - **Library or CLI** — use it as `import risforge` in a script/notebook,
56
56
  or as a single `risforge` command.
57
+ - **Optional desktop GUI** — a cross-platform PySide6 app (`risforge-gui`)
58
+ for drag-and-drop use, with no command line required.
57
59
 
58
60
  ## Installation
59
61
 
@@ -63,6 +65,18 @@ pip install risforge
63
65
 
64
66
  Requires Python 3.10+.
65
67
 
68
+ There's also an optional desktop GUI, for researchers who'd rather not use
69
+ the command line:
70
+
71
+ ```bash
72
+ pip install "risforge[gui]"
73
+ risforge-gui
74
+ ```
75
+
76
+ It's a thin PySide6 interface over this same package -- see
77
+ [docs/gui.md](docs/gui.md) for the full walkthrough. The base install above
78
+ never pulls in GUI dependencies.
79
+
66
80
  ## Quick start
67
81
 
68
82
  ### Command line
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "risforge"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -50,6 +50,13 @@ dev = [
50
50
  "build>=1.2",
51
51
  "twine>=5.0",
52
52
  ]
53
+ gui = [
54
+ "PySide6>=6.5",
55
+ ]
56
+ gui-dev = [
57
+ "risforge[gui]",
58
+ "pytest-qt>=4.4",
59
+ ]
53
60
 
54
61
  [project.urls]
55
62
  Homepage = "https://github.com/amyr/risforge"
@@ -60,6 +67,9 @@ Changelog = "https://github.com/amyr/risforge/blob/main/CHANGELOG.md"
60
67
  [project.scripts]
61
68
  risforge = "risforge.cli:main"
62
69
 
70
+ [project.gui-scripts]
71
+ risforge-gui = "risforge_gui.app:main"
72
+
63
73
  [tool.setuptools.packages.find]
64
74
  where = ["src"]
65
75
 
@@ -68,3 +78,6 @@ where = ["src"]
68
78
 
69
79
  [tool.pytest.ini_options]
70
80
  testpaths = ["tests"]
81
+
82
+ [tool.coverage.run]
83
+ concurrency = ["thread"]
@@ -51,7 +51,7 @@ from risforge.exceptions import RisForgeError, RisParsingError
51
51
  from risforge.merging import MergeResult, merge_ris_files
52
52
  from risforge.pipeline import PipelineResult, risforge, run_pipeline
53
53
 
54
- __version__ = "0.2.0"
54
+ __version__ = "0.3.0"
55
55
 
56
56
  __all__ = [
57
57
  "clean_ris_file",
@@ -40,6 +40,7 @@ import json
40
40
  import logging
41
41
  import re
42
42
  import time
43
+ from collections.abc import Callable
43
44
  from difflib import SequenceMatcher
44
45
  from pathlib import Path
45
46
  from typing import Any
@@ -375,6 +376,7 @@ class RisEnricher:
375
376
  output_path: str | Path,
376
377
  fail_report_path: str | Path = "failed_records.json",
377
378
  request_delay_seconds: float = 0.1,
379
+ progress_callback: Callable[[int, int], None] | None = None,
378
380
  ) -> dict[str, Any]:
379
381
  """Enrich every record in a RIS file and write the result.
380
382
 
@@ -387,6 +389,14 @@ class RisEnricher:
387
389
  request_delay_seconds: Delay between records, to stay
388
390
  within provider rate limits beyond the built-in retry
389
391
  policy.
392
+ progress_callback: Optional callback invoked as
393
+ ``progress_callback(processed_count, total_count)``
394
+ after each record is processed. Added for callers
395
+ (such as a GUI) that want to report determinate
396
+ progress during a potentially slow, network-bound
397
+ operation, without polling or duplicating this loop
398
+ elsewhere. Never called when omitted -- zero behavior
399
+ change for existing callers.
390
400
 
391
401
  Returns:
392
402
  The accumulated ``self.stats`` dict.
@@ -403,9 +413,12 @@ class RisEnricher:
403
413
  logger.error("Failed to parse RIS file: %s", error)
404
414
  return self.stats
405
415
 
416
+ total = len(records)
406
417
  enriched_records = []
407
- for record in records:
418
+ for index, record in enumerate(records):
408
419
  enriched_records.append(self.enrich_record(record))
420
+ if progress_callback is not None:
421
+ progress_callback(index + 1, total)
409
422
  if request_delay_seconds:
410
423
  time.sleep(request_delay_seconds)
411
424
 
@@ -24,7 +24,7 @@ pre-0.2.0 API -- including callers who used the original
24
24
  from __future__ import annotations
25
25
 
26
26
  import logging
27
- from collections.abc import Sequence
27
+ from collections.abc import Callable, Sequence
28
28
  from dataclasses import dataclass, field
29
29
  from pathlib import Path
30
30
  from typing import Any
@@ -93,6 +93,8 @@ def risforge(
93
93
  email: str,
94
94
  merge_path: str | Path | None = None,
95
95
  fail_report_path: str | Path = "failed_records.json",
96
+ on_stage: Callable[[str, str], None] | None = None,
97
+ enrichment_progress: Callable[[int, int], None] | None = None,
96
98
  ) -> PipelineResult:
97
99
  """Run the full merge(optional) -> clean -> enrich pipeline end to end.
98
100
 
@@ -118,6 +120,19 @@ def risforge(
118
120
  Ignored for a single input, since no merge step runs.
119
121
  fail_report_path: Where a JSON report of unresolved records is
120
122
  written, if any.
123
+ on_stage: Optional callback invoked as ``on_stage(stage,
124
+ status)`` around each phase, with ``stage`` one of
125
+ ``"merge"`` (only when multiple inputs are given),
126
+ ``"clean"``, or ``"enrich"``, and ``status`` one of
127
+ ``"started"`` or ``"completed"``. Added for callers (such
128
+ as a GUI) that want to reflect pipeline progress without
129
+ polling the filesystem or reimplementing this function's
130
+ control flow. Never called when omitted.
131
+ enrichment_progress: Optional callback forwarded to
132
+ :meth:`risforge.enrichment.RisEnricher.enrich_file` as
133
+ ``progress_callback``, invoked as
134
+ ``enrichment_progress(processed_count, total_count)``
135
+ after each record. Never called when omitted.
121
136
 
122
137
  Returns:
123
138
  A :class:`PipelineResult` summarizing every phase that ran.
@@ -139,7 +154,11 @@ def risforge(
139
154
  Path(merge_path) if merge_path is not None else _default_merge_path(dedup_path)
140
155
  )
141
156
  logger.info("Phase 0: Merging %d input files into %s", len(paths), resolved_merge_path)
157
+ if on_stage is not None:
158
+ on_stage("merge", "started")
142
159
  merge_result = merge_ris_files(paths, resolved_merge_path)
160
+ if on_stage is not None:
161
+ on_stage("merge", "completed")
143
162
  logger.info(
144
163
  "Phase 0 complete: merged %d record(s) from %d file(s).",
145
164
  merge_result.record_count,
@@ -150,18 +169,27 @@ def risforge(
150
169
  clean_input = paths[0]
151
170
 
152
171
  logger.info("Phase 1: Deduplicating %s", clean_input)
172
+ if on_stage is not None:
173
+ on_stage("clean", "started")
153
174
  records, errors = clean_ris_file(clean_input, dedup_path)
175
+ if on_stage is not None:
176
+ on_stage("clean", "completed")
154
177
  logger.info("Phase 1 complete: generated %d clean records.", len(records))
155
178
  if errors:
156
179
  logger.warning("Encountered %d parsing errors during Phase 1.", len(errors))
157
180
 
158
181
  logger.info("Phase 2: Initializing metadata enrichment via APIs")
182
+ if on_stage is not None:
183
+ on_stage("enrich", "started")
159
184
  enricher = RisEnricher(email=email)
160
185
  stats = enricher.enrich_file(
161
186
  input_path=dedup_path,
162
187
  output_path=enriched_path,
163
188
  fail_report_path=fail_report_path,
189
+ progress_callback=enrichment_progress,
164
190
  )
191
+ if on_stage is not None:
192
+ on_stage("enrich", "completed")
165
193
  logger.info(
166
194
  "Phase 2 complete: enriched %d/%d records.",
167
195
  stats.get("enriched", 0),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: risforge
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews.
5
5
  Author-email: Amyr <amyrhexa@gmail.com>
6
6
  License: MIT
@@ -32,6 +32,11 @@ Requires-Dist: pytest>=8.0; extra == "dev"
32
32
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
33
33
  Requires-Dist: build>=1.2; extra == "dev"
34
34
  Requires-Dist: twine>=5.0; extra == "dev"
35
+ Provides-Extra: gui
36
+ Requires-Dist: PySide6>=6.5; extra == "gui"
37
+ Provides-Extra: gui-dev
38
+ Requires-Dist: risforge[gui]; extra == "gui-dev"
39
+ Requires-Dist: pytest-qt>=4.4; extra == "gui-dev"
35
40
  Dynamic: license-file
36
41
 
37
42
  # risforge
@@ -90,6 +95,8 @@ overlapping database exports — whether that's one export or several.
90
95
  enriched.
91
96
  - **Library or CLI** — use it as `import risforge` in a script/notebook,
92
97
  or as a single `risforge` command.
98
+ - **Optional desktop GUI** — a cross-platform PySide6 app (`risforge-gui`)
99
+ for drag-and-drop use, with no command line required.
93
100
 
94
101
  ## Installation
95
102
 
@@ -99,6 +106,18 @@ pip install risforge
99
106
 
100
107
  Requires Python 3.10+.
101
108
 
109
+ There's also an optional desktop GUI, for researchers who'd rather not use
110
+ the command line:
111
+
112
+ ```bash
113
+ pip install "risforge[gui]"
114
+ risforge-gui
115
+ ```
116
+
117
+ It's a thin PySide6 interface over this same package -- see
118
+ [docs/gui.md](docs/gui.md) for the full walkthrough. The base install above
119
+ never pulls in GUI dependencies.
120
+
102
121
  ## Quick start
103
122
 
104
123
  ### Command line
@@ -0,0 +1,36 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/risforge/__init__.py
5
+ src/risforge/cleaning.py
6
+ src/risforge/cli.py
7
+ src/risforge/enrichment.py
8
+ src/risforge/exceptions.py
9
+ src/risforge/merging.py
10
+ src/risforge/pipeline.py
11
+ src/risforge.egg-info/PKG-INFO
12
+ src/risforge.egg-info/SOURCES.txt
13
+ src/risforge.egg-info/dependency_links.txt
14
+ src/risforge.egg-info/entry_points.txt
15
+ src/risforge.egg-info/requires.txt
16
+ src/risforge.egg-info/top_level.txt
17
+ src/risforge_gui/__init__.py
18
+ src/risforge_gui/app.py
19
+ src/risforge_gui/dialogs.py
20
+ src/risforge_gui/file_counter.py
21
+ src/risforge_gui/main_window.py
22
+ src/risforge_gui/models.py
23
+ src/risforge_gui/theme.py
24
+ src/risforge_gui/worker.py
25
+ src/risforge_gui/widgets/__init__.py
26
+ src/risforge_gui/widgets/config_panel.py
27
+ src/risforge_gui/widgets/input_panel.py
28
+ src/risforge_gui/widgets/log_panel.py
29
+ src/risforge_gui/widgets/output_panel.py
30
+ src/risforge_gui/widgets/progress_panel.py
31
+ src/risforge_gui/widgets/results_panel.py
32
+ tests/test_cleaning.py
33
+ tests/test_cli.py
34
+ tests/test_enrichment.py
35
+ tests/test_merging.py
36
+ tests/test_pipeline.py
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ risforge = risforge.cli:main
3
+
4
+ [gui_scripts]
5
+ risforge-gui = risforge_gui.app:main
@@ -8,3 +8,10 @@ pytest>=8.0
8
8
  pytest-cov>=5.0
9
9
  build>=1.2
10
10
  twine>=5.0
11
+
12
+ [gui]
13
+ PySide6>=6.5
14
+
15
+ [gui-dev]
16
+ risforge[gui]
17
+ pytest-qt>=4.4
@@ -0,0 +1,2 @@
1
+ risforge
2
+ risforge_gui
@@ -0,0 +1,21 @@
1
+ """risforge_gui: a desktop GUI for the risforge package.
2
+
3
+ This is a thin PySide6 presentation layer over the ``risforge`` core
4
+ library -- it contains no merging, cleaning, deduplication, or
5
+ enrichment logic of its own. Every operation it performs is a direct
6
+ call into the public ``risforge`` API (:func:`risforge.risforge`,
7
+ :func:`risforge.merge_ris_files`, :func:`risforge.clean_ris_file`,
8
+ :class:`risforge.RisEnricher`).
9
+
10
+ Deliberately not imported here: PySide6. Importing this top-level
11
+ package should never require the GUI extra to be installed; only
12
+ :mod:`risforge_gui.app` (the entry point) and the widget modules it
13
+ pulls in do that. This keeps ``import risforge_gui`` itself cheap and
14
+ side-effect-free, consistent with how ``risforge`` (the core package)
15
+ never imports GUI dependencies.
16
+
17
+ Install with the GUI extra and launch via the console entry point::
18
+
19
+ pip install "risforge[gui]"
20
+ risforge-gui
21
+ """
@@ -0,0 +1,34 @@
1
+ """Entry point for the ``risforge-gui`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main() -> None:
9
+ try:
10
+ from PySide6.QtWidgets import QApplication
11
+ except ImportError:
12
+ print(
13
+ "risforge-gui requires the GUI extras, which aren't installed.\n\n"
14
+ ' pip install "risforge[gui]"\n',
15
+ file=sys.stderr,
16
+ )
17
+ raise SystemExit(1) from None
18
+
19
+ from risforge_gui.main_window import MainWindow
20
+ from risforge_gui.theme import Theme, apply_theme
21
+
22
+ app = QApplication(sys.argv)
23
+ app.setApplicationName("risforge")
24
+ apply_theme(app, Theme.SYSTEM)
25
+
26
+ window = MainWindow()
27
+ window.resize(1100, 780)
28
+ window.show()
29
+
30
+ sys.exit(app.exec())
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1,49 @@
1
+ """Error and confirmation dialogs.
2
+
3
+ Errors are always shown with a plain-language message first. Technical
4
+ detail (exception repr / traceback) is available but hidden behind
5
+ Qt's built-in "Show Details..." expander -- never shown by default.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from PySide6.QtWidgets import QMessageBox, QWidget
13
+
14
+
15
+ def show_error_dialog(
16
+ parent: QWidget | None,
17
+ title: str,
18
+ message: str,
19
+ details: str = "",
20
+ affected_file: str = "",
21
+ ) -> None:
22
+ """Show a human-readable error, with technical details available on demand."""
23
+ box = QMessageBox(parent)
24
+ box.setIcon(QMessageBox.Icon.Critical)
25
+ box.setWindowTitle("risforge")
26
+ box.setText(title)
27
+
28
+ informative = message
29
+ if affected_file:
30
+ informative = f"File: {affected_file}\n\n{message}"
31
+ box.setInformativeText(informative)
32
+
33
+ if details:
34
+ box.setDetailedText(details)
35
+
36
+ box.setStandardButtons(QMessageBox.StandardButton.Ok)
37
+ box.exec()
38
+
39
+
40
+ def confirm_overwrite(parent: QWidget | None, path: Path) -> bool:
41
+ """Ask before overwriting an existing output file. Defaults to No (safe)."""
42
+ result = QMessageBox.question(
43
+ parent,
44
+ "File already exists",
45
+ f"A file already exists at:\n{path}\n\nOverwrite it?",
46
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
47
+ QMessageBox.StandardButton.No,
48
+ )
49
+ return result == QMessageBox.StandardButton.Yes
@@ -0,0 +1,41 @@
1
+ """Asynchronous record counting for the input files table.
2
+
3
+ Counting records requires actually parsing the file, so it's done off
4
+ the GUI thread via QThreadPool. The parsing itself is not
5
+ reimplemented here -- it's the exact same
6
+ ``risforge.cleaning.parse_ris_records`` used by cleaning and merging,
7
+ so a file's reported record count always matches what merge/clean
8
+ will actually do with it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from PySide6.QtCore import QObject, QRunnable, Signal
16
+
17
+ from risforge.cleaning import parse_ris_records
18
+
19
+
20
+ class _CounterSignals(QObject):
21
+ finished = Signal(Path, int, int) # path, record_count, malformed_block_count
22
+ failed = Signal(Path, str) # path, human-readable error message
23
+
24
+
25
+ class RecordCounter(QRunnable):
26
+ """Counts records in one RIS file on a background thread pool."""
27
+
28
+ def __init__(self, path: Path) -> None:
29
+ super().__init__()
30
+ self._path = path
31
+ self.signals = _CounterSignals()
32
+
33
+ def run(self) -> None: # noqa: D102 -- QRunnable interface
34
+ try:
35
+ records, errors = parse_ris_records(self._path)
36
+ except FileNotFoundError:
37
+ self.signals.failed.emit(self._path, "File not found.")
38
+ except OSError as error:
39
+ self.signals.failed.emit(self._path, f"Could not read file: {error}")
40
+ else:
41
+ self.signals.finished.emit(self._path, len(records), len(errors))