risforge 0.3.2__tar.gz → 0.3.3__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 (39) hide show
  1. {risforge-0.3.2/src/risforge.egg-info → risforge-0.3.3}/PKG-INFO +5 -5
  2. {risforge-0.3.2 → risforge-0.3.3}/pyproject.toml +12 -5
  3. {risforge-0.3.2 → risforge-0.3.3}/src/risforge/__init__.py +7 -7
  4. {risforge-0.3.2 → risforge-0.3.3}/src/risforge/cleaning.py +2 -8
  5. {risforge-0.3.2 → risforge-0.3.3}/src/risforge/cli.py +2 -6
  6. {risforge-0.3.2 → risforge-0.3.3}/src/risforge/enrichment.py +10 -11
  7. {risforge-0.3.2 → risforge-0.3.3}/src/risforge/merging.py +1 -3
  8. {risforge-0.3.2 → risforge-0.3.3/src/risforge.egg-info}/PKG-INFO +5 -5
  9. risforge-0.3.3/src/risforge_gui/app.py +48 -0
  10. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/file_counter.py +11 -2
  11. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/main_window.py +22 -10
  12. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/models.py +20 -5
  13. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/theme.py +43 -41
  14. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/config_panel.py +5 -2
  15. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/input_panel.py +14 -6
  16. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/log_panel.py +8 -0
  17. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/output_panel.py +1 -1
  18. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/progress_panel.py +18 -1
  19. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/results_panel.py +9 -1
  20. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/worker.py +20 -5
  21. {risforge-0.3.2 → risforge-0.3.3}/tests/test_cli.py +3 -9
  22. {risforge-0.3.2 → risforge-0.3.3}/tests/test_enrichment.py +1 -4
  23. {risforge-0.3.2 → risforge-0.3.3}/tests/test_merging.py +1 -5
  24. {risforge-0.3.2 → risforge-0.3.3}/tests/test_pipeline.py +2 -6
  25. risforge-0.3.2/src/risforge_gui/app.py +0 -34
  26. {risforge-0.3.2 → risforge-0.3.3}/LICENSE +0 -0
  27. {risforge-0.3.2 → risforge-0.3.3}/README.md +0 -0
  28. {risforge-0.3.2 → risforge-0.3.3}/setup.cfg +0 -0
  29. {risforge-0.3.2 → risforge-0.3.3}/src/risforge/exceptions.py +0 -0
  30. {risforge-0.3.2 → risforge-0.3.3}/src/risforge/pipeline.py +0 -0
  31. {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/SOURCES.txt +0 -0
  32. {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/dependency_links.txt +0 -0
  33. {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/entry_points.txt +0 -0
  34. {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/requires.txt +0 -0
  35. {risforge-0.3.2 → risforge-0.3.3}/src/risforge.egg-info/top_level.txt +0 -0
  36. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/__init__.py +0 -0
  37. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/dialogs.py +0 -0
  38. {risforge-0.3.2 → risforge-0.3.3}/src/risforge_gui/widgets/__init__.py +0 -0
  39. {risforge-0.3.2 → risforge-0.3.3}/tests/test_cleaning.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: risforge
3
- Version: 0.3.2
3
+ Version: 0.3.3
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
7
- Project-URL: Homepage, https://github.com/pythyn/risforge
8
- Project-URL: Repository, https://github.com/pythyn/risforge
9
- Project-URL: Issues, https://github.com/pythyn/risforge/issues
10
- Project-URL: Changelog, https://github.com/pythyn/risforge/blob/main/CHANGELOG.md
7
+ Project-URL: Homepage, https://github.com/amyr/risforge
8
+ Project-URL: Repository, https://github.com/amyr/risforge
9
+ Project-URL: Issues, https://github.com/amyr/risforge/issues
10
+ Project-URL: Changelog, https://github.com/amyr/risforge/blob/main/CHANGELOG.md
11
11
  Keywords: ris,bibliography,systematic-review,deduplication,crossref,openalex,citation-management,prisma
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Science/Research
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "risforge"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Clean, deduplicate, and enrich RIS bibliographic files for systematic reviews."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -59,10 +59,10 @@ gui-dev = [
59
59
  ]
60
60
 
61
61
  [project.urls]
62
- Homepage = "https://github.com/pythyn/risforge"
63
- Repository = "https://github.com/pythyn/risforge"
64
- Issues = "https://github.com/pythyn/risforge/issues"
65
- Changelog = "https://github.com/pythyn/risforge/blob/main/CHANGELOG.md"
62
+ Homepage = "https://github.com/amyr/risforge"
63
+ Repository = "https://github.com/amyr/risforge"
64
+ Issues = "https://github.com/amyr/risforge/issues"
65
+ Changelog = "https://github.com/amyr/risforge/blob/main/CHANGELOG.md"
66
66
 
67
67
  [project.scripts]
68
68
  risforge = "risforge.cli:main"
@@ -81,3 +81,10 @@ testpaths = ["tests"]
81
81
 
82
82
  [tool.coverage.run]
83
83
  concurrency = ["thread"]
84
+
85
+ [tool.black]
86
+ line-length = 100
87
+ target-version = ["py310", "py311", "py312"]
88
+
89
+ [tool.ruff]
90
+ line-length = 100
@@ -51,17 +51,17 @@ 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.3.2"
54
+ __version__ = "0.3.3"
55
55
 
56
56
  __all__ = [
57
- "clean_ris_file",
58
- "process_ris_file",
59
- "RisEnricher",
60
- "merge_ris_files",
61
57
  "MergeResult",
62
- "risforge",
63
- "run_pipeline",
64
58
  "PipelineResult",
59
+ "RisEnricher",
65
60
  "RisForgeError",
66
61
  "RisParsingError",
62
+ "clean_ris_file",
63
+ "merge_ris_files",
64
+ "process_ris_file",
65
+ "risforge",
66
+ "run_pipeline",
67
67
  ]
@@ -147,11 +147,7 @@ def merge_cluster(cluster: list[RisRecord]) -> RisRecord:
147
147
  for item in value:
148
148
  if item not in merged[key]:
149
149
  merged[key].append(item)
150
- elif (
151
- isinstance(merged[key], str)
152
- and not merged[key]
153
- and isinstance(value, str)
154
- ):
150
+ elif isinstance(merged[key], str) and not merged[key] and isinstance(value, str):
155
151
  merged[key] = value
156
152
 
157
153
  return merged
@@ -346,9 +342,7 @@ def clean_ris_file(
346
342
  for index in range(total_records):
347
343
  clusters[union_find.find(index)].append(index)
348
344
 
349
- final_records = [
350
- merge_cluster([records[i] for i in indices]) for indices in clusters.values()
351
- ]
345
+ final_records = [merge_cluster([records[i] for i in indices]) for indices in clusters.values()]
352
346
 
353
347
  total_duplicates_removed = doi_duplicates_removed + title_author_duplicates_removed
354
348
 
@@ -48,9 +48,7 @@ def _configure_logging(verbose: bool) -> None:
48
48
 
49
49
 
50
50
  def _add_common_args(parser: argparse.ArgumentParser) -> None:
51
- parser.add_argument(
52
- "-v", "--verbose", action="store_true", help="Enable debug-level logging."
53
- )
51
+ parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug-level logging.")
54
52
 
55
53
 
56
54
  def _cmd_merge(args: argparse.Namespace) -> int:
@@ -188,9 +186,7 @@ def build_parser() -> argparse.ArgumentParser:
188
186
  _add_common_args(merge_parser)
189
187
  merge_parser.set_defaults(func=_cmd_merge)
190
188
 
191
- clean_parser = subparsers.add_parser(
192
- "clean", help="Deduplicate and normalize a RIS file."
193
- )
189
+ clean_parser = subparsers.add_parser("clean", help="Deduplicate and normalize a RIS file.")
194
190
  clean_parser.add_argument("input", help="Input RIS file path.")
195
191
  clean_parser.add_argument("output", help="Output cleaned RIS file path.")
196
192
  clean_parser.add_argument(
@@ -74,6 +74,7 @@ from risforge.cleaning import parse_ris_records
74
74
 
75
75
  TITLE_MATCH_THRESHOLD = 0.90
76
76
  CACHE_EXPIRE_DAYS = 7
77
+ REQUEST_TIMEOUT_SECONDS = 10 # Applied to every provider HTTP call; retries/backoff are separate.
77
78
 
78
79
  logger = logging.getLogger(__name__)
79
80
 
@@ -155,9 +156,7 @@ class RisEnricher:
155
156
  }
156
157
  self.failed_records: list[dict[str, Any]] = []
157
158
  self.session = session or _build_default_session(str(cache_name))
158
- self.session.headers.update(
159
- {"User-Agent": f"risforge/1.0 (mailto:{self.email})"}
160
- )
159
+ self.session.headers.update({"User-Agent": f"risforge/1.0 (mailto:{self.email})"})
161
160
 
162
161
  # --- Core identification -------------------------------------------------
163
162
 
@@ -191,7 +190,7 @@ class RisEnricher:
191
190
  f"&select=DOI,title&rows=3&mailto={self.email}"
192
191
  )
193
192
  try:
194
- response = self.session.get(url, timeout=10)
193
+ response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
195
194
  self.stats["api_calls"]["crossref"] += 1
196
195
 
197
196
  if response.status_code == 200:
@@ -216,7 +215,7 @@ class RisEnricher:
216
215
  """Fetch authoritative bibliographic metadata from Crossref."""
217
216
  url = f"https://api.crossref.org/works/{quote(doi)}?mailto={self.email}"
218
217
  try:
219
- response = self.session.get(url, timeout=10)
218
+ response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
220
219
  self.stats["api_calls"]["crossref"] += 1
221
220
 
222
221
  if response.status_code == 200:
@@ -273,15 +272,15 @@ class RisEnricher:
273
272
  """Fetch open-access status and subject concepts from OpenAlex."""
274
273
  url = f"https://api.openalex.org/works/doi:{quote(doi)}?mailto={self.email}"
275
274
  try:
276
- response = self.session.get(url, timeout=10)
275
+ response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
277
276
  self.stats["api_calls"]["openalex"] += 1
278
277
 
279
278
  if response.status_code == 200:
280
279
  payload = response.json()
281
280
  concepts = [
282
- c.get("display_name")
283
- for c in payload.get("concepts", [])
284
- if c.get("level", 99) <= 1 and c.get("display_name")
281
+ concept.get("display_name")
282
+ for concept in payload.get("concepts", [])
283
+ if concept.get("level", 99) <= 1 and concept.get("display_name")
285
284
  ]
286
285
  oa_url = payload.get("open_access", {}).get("oa_url")
287
286
  return {
@@ -302,7 +301,7 @@ class RisEnricher:
302
301
  f"?fields=abstract,referenceCount,citationCount"
303
302
  )
304
303
  try:
305
- response = self.session.get(url, timeout=10)
304
+ response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
306
305
  self.stats["api_calls"]["semanticscholar"] += 1
307
306
 
308
307
  if response.status_code == 200:
@@ -318,7 +317,7 @@ class RisEnricher:
318
317
  """Fetch the best open-access PDF location from Unpaywall."""
319
318
  url = f"https://api.unpaywall.org/v2/{quote(doi)}?email={self.email}"
320
319
  try:
321
- response = self.session.get(url, timeout=10)
320
+ response = self.session.get(url, timeout=REQUEST_TIMEOUT_SECONDS)
322
321
  self.stats["api_calls"]["unpaywall"] += 1
323
322
 
324
323
  if response.status_code == 200:
@@ -106,9 +106,7 @@ def merge_ris_files(
106
106
  len(errors),
107
107
  path,
108
108
  )
109
- all_errors.extend(
110
- (path, block_number, message) for block_number, message in errors
111
- )
109
+ all_errors.extend((path, block_number, message) for block_number, message in errors)
112
110
  all_records.extend(records)
113
111
  logger.info("%s: %d record(s) parsed.", path, len(records))
114
112
 
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: risforge
3
- Version: 0.3.2
3
+ Version: 0.3.3
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
7
- Project-URL: Homepage, https://github.com/pythyn/risforge
8
- Project-URL: Repository, https://github.com/pythyn/risforge
9
- Project-URL: Issues, https://github.com/pythyn/risforge/issues
10
- Project-URL: Changelog, https://github.com/pythyn/risforge/blob/main/CHANGELOG.md
7
+ Project-URL: Homepage, https://github.com/amyr/risforge
8
+ Project-URL: Repository, https://github.com/amyr/risforge
9
+ Project-URL: Issues, https://github.com/amyr/risforge/issues
10
+ Project-URL: Changelog, https://github.com/amyr/risforge/blob/main/CHANGELOG.md
11
11
  Keywords: ris,bibliography,systematic-review,deduplication,crossref,openalex,citation-management,prisma
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Science/Research
@@ -0,0 +1,48 @@
1
+ """Entry point for the ``risforge-gui`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def main() -> None:
12
+ """Launch the risforge desktop application.
13
+
14
+ Registered as the ``risforge-gui`` console entry point. The GUI
15
+ extra (PySide6) is optional at the package level, so this is the
16
+ one place that has to tolerate it being absent -- everything past
17
+ the import guard below assumes it's installed.
18
+ """
19
+ try:
20
+ from PySide6.QtWidgets import QApplication
21
+ except ImportError:
22
+ # No logging handler is configured yet at this point (that's
23
+ # normally the CLI's or the GUI's own job, and we haven't
24
+ # reached either), but an unconfigured logger's "last resort"
25
+ # handler still writes ERROR+ to stderr, so this is exactly as
26
+ # visible to the user as a plain print() would have been.
27
+ logger.error(
28
+ "risforge-gui requires the GUI extras, which aren't installed.\n\n"
29
+ ' pip install "risforge[gui]"\n'
30
+ )
31
+ raise SystemExit(1) from None
32
+
33
+ from risforge_gui.main_window import MainWindow
34
+ from risforge_gui.theme import Theme, apply_theme
35
+
36
+ app = QApplication(sys.argv)
37
+ app.setApplicationName("risforge")
38
+ apply_theme(app, Theme.SYSTEM)
39
+
40
+ window = MainWindow()
41
+ window.resize(1100, 780)
42
+ window.show()
43
+
44
+ sys.exit(app.exec())
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -30,12 +30,21 @@ class RecordCounter(QRunnable):
30
30
  self._path = path
31
31
  self.signals = _CounterSignals()
32
32
 
33
- def run(self) -> None: # noqa: D102 -- QRunnable interface
33
+ def run(self) -> None:
34
+ """Parse the file and report success/failure via signals.
35
+
36
+ Catches ValueError alongside OSError specifically because
37
+ ``parse_ris_records`` raises ``RisParsingError`` (a ValueError
38
+ subclass) for files that can't be decoded as text -- without
39
+ this, such a file would raise out of a QThreadPool worker
40
+ uncaught, and its row would be stuck at "Counting..." forever
41
+ with no visible error at all.
42
+ """
34
43
  try:
35
44
  records, errors = parse_ris_records(self._path)
36
45
  except FileNotFoundError:
37
46
  self.signals.failed.emit(self._path, "File not found.")
38
- except OSError as error:
47
+ except (OSError, ValueError) as error:
39
48
  self.signals.failed.emit(self._path, f"Could not read file: {error}")
40
49
  else:
41
50
  self.signals.finished.emit(self._path, len(records), len(errors))
@@ -11,7 +11,6 @@ from __future__ import annotations
11
11
 
12
12
  from pathlib import Path
13
13
 
14
- from PySide6.QtCore import Qt
15
14
  from PySide6.QtWidgets import (
16
15
  QComboBox,
17
16
  QGroupBox,
@@ -201,9 +200,16 @@ class MainWindow(QMainWindow):
201
200
  if input_count > 1
202
201
  else "Deduplicates and enriches your input file."
203
202
  ),
204
- OperationMode.MERGE: "Combines every input file's records into one file. Does not deduplicate or enrich.",
205
- OperationMode.CLEAN: "Deduplicates a single RIS file. Requires exactly one input file.",
206
- OperationMode.ENRICH: "Fills in missing metadata for a single, already-clean RIS file. Requires exactly one input file.",
203
+ OperationMode.MERGE: (
204
+ "Combines every input file's records into one file. Does not deduplicate or enrich."
205
+ ),
206
+ OperationMode.CLEAN: (
207
+ "Deduplicates a single RIS file. Requires exactly one input file."
208
+ ),
209
+ OperationMode.ENRICH: (
210
+ "Fills in missing metadata for a single, already-clean RIS file. "
211
+ "Requires exactly one input file."
212
+ ),
207
213
  }
208
214
  self.mode_hint_label.setText(hints[mode])
209
215
 
@@ -238,15 +244,17 @@ class MainWindow(QMainWindow):
238
244
  input_paths = self.input_panel.model.paths()
239
245
 
240
246
  if not input_paths:
241
- QMessageBox.warning(self, "No input files", "Add at least one .ris file before starting.")
247
+ QMessageBox.warning(
248
+ self, "No input files", "Add at least one .ris file before starting."
249
+ )
242
250
  return None
243
251
 
244
252
  if mode is OperationMode.CLEAN and len(input_paths) != 1:
245
253
  QMessageBox.warning(
246
254
  self,
247
255
  "Too many input files",
248
- "\"Clean only\" works on exactly one file. Either remove the extra files, "
249
- "or use \"Merge only\" first to combine them into one.",
256
+ '"Clean only" works on exactly one file. Either remove the extra files, '
257
+ 'or use "Merge only" first to combine them into one.',
250
258
  )
251
259
  return None
252
260
 
@@ -254,7 +262,7 @@ class MainWindow(QMainWindow):
254
262
  QMessageBox.warning(
255
263
  self,
256
264
  "Too many input files",
257
- "\"Enrich only\" works on exactly one file. Remove the extra files first.",
265
+ '"Enrich only" works on exactly one file. Remove the extra files first.',
258
266
  )
259
267
  return None
260
268
 
@@ -280,11 +288,15 @@ class MainWindow(QMainWindow):
280
288
  )
281
289
  return None
282
290
 
283
- result_name = self.output_panel.final_name_edit.text().strip() or _DEFAULT_NAME_BY_MODE[mode]
291
+ result_name = (
292
+ self.output_panel.final_name_edit.text().strip() or _DEFAULT_NAME_BY_MODE[mode]
293
+ )
284
294
  result_path = output_dir / result_name
285
295
  merge_path = output_dir / "merged.ris"
286
296
  dedup_path = output_dir / "clean.ris"
287
- fail_report_path = _resolve_relative_to(self.enrichment_panel.fail_report_name(), output_dir)
297
+ fail_report_path = _resolve_relative_to(
298
+ self.enrichment_panel.fail_report_name(), output_dir
299
+ )
288
300
 
289
301
  outputs_to_check = {result_path}
290
302
  if mode is OperationMode.PIPELINE:
@@ -16,6 +16,8 @@ from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
16
16
 
17
17
  @dataclass
18
18
  class InputFileEntry:
19
+ """One row's state: a file path plus its (possibly still-pending) record count."""
20
+
19
21
  path: Path
20
22
  record_count: int | None = None # None while counting is in progress.
21
23
  status: str = "Counting..."
@@ -33,18 +35,30 @@ class InputFilesModel(QAbstractTableModel):
33
35
 
34
36
  # --- Qt model interface -----------------------------------------------
35
37
 
36
- def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
38
+ def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: B008
39
+ # QModelIndex() as a default is the standard Qt Model/View idiom
40
+ # (used verbatim in Qt's own documentation): it's an immutable
41
+ # "invalid index" value, not mutable state that could leak
42
+ # between calls, so constructing it once at import time is safe.
37
43
  return 0 if parent.isValid() else len(self._entries)
38
44
 
39
- def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
45
+ def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: B008
40
46
  return 0 if parent.isValid() else len(self.COLUMNS)
41
47
 
42
- def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): # noqa: N802
48
+ def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
43
49
  if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
44
50
  return self.COLUMNS[section]
45
51
  return None
46
52
 
47
53
  def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
54
+ """Return display/tooltip/alignment data for one cell.
55
+
56
+ Branches on ``role`` because Qt's view asks the model the same
57
+ question ("what goes in this cell?") several times with
58
+ different intents -- text to show, tooltip to show, how to
59
+ align it -- rather than the model exposing separate methods
60
+ per concern.
61
+ """
48
62
  if not index.isValid() or not (0 <= index.row() < len(self._entries)):
49
63
  return None
50
64
  entry = self._entries[index.row()]
@@ -66,7 +80,8 @@ class InputFilesModel(QAbstractTableModel):
66
80
  return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
67
81
  return None
68
82
 
69
- def flags(self, index: QModelIndex): # noqa: N802
83
+ def flags(self, index: QModelIndex):
84
+ """Selectable but never editable -- this table is for review, not inline editing."""
70
85
  if not index.isValid():
71
86
  return Qt.ItemFlag.NoItemFlags
72
87
  return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
@@ -129,7 +144,7 @@ class InputFilesModel(QAbstractTableModel):
129
144
  return
130
145
 
131
146
  def total_known_records(self) -> int | None:
132
- """Sum of record counts for entries that finished counting, or None if any are still pending/errored."""
147
+ """Sum of counted records, or None while any entry is still pending/errored."""
133
148
  if any(entry.record_count is None for entry in self._entries):
134
149
  return None
135
150
  return sum(entry.record_count or 0 for entry in self._entries)
@@ -19,6 +19,8 @@ from PySide6.QtWidgets import QApplication
19
19
 
20
20
 
21
21
  class Theme(str, Enum):
22
+ """Selectable appearance modes; SYSTEM defers to the OS light/dark preference."""
23
+
22
24
  LIGHT = "light"
23
25
  DARK = "dark"
24
26
  SYSTEM = "system"
@@ -61,7 +63,7 @@ DARK_PALETTE: dict[str, str] = {
61
63
  }
62
64
 
63
65
 
64
- def _build_stylesheet(p: dict[str, str]) -> str:
66
+ def _build_stylesheet(palette: dict[str, str]) -> str:
65
67
  """Render a palette dict into a Qt stylesheet.
66
68
 
67
69
  Kept as one template so the two palettes can never drift into
@@ -69,18 +71,18 @@ def _build_stylesheet(p: dict[str, str]) -> str:
69
71
  """
70
72
  return f"""
71
73
  QWidget {{
72
- background-color: {p['bg']};
73
- color: {p['text']};
74
+ background-color: {palette["bg"]};
75
+ color: {palette["text"]};
74
76
  font-size: 13px;
75
77
  }}
76
78
 
77
79
  QMainWindow, #SetupPage, #ProgressPage, #ResultsPage {{
78
- background-color: {p['bg']};
80
+ background-color: {palette["bg"]};
79
81
  }}
80
82
 
81
83
  QGroupBox {{
82
- background-color: {p['surface']};
83
- border: 1px solid {p['border']};
84
+ background-color: {palette["surface"]};
85
+ border: 1px solid {palette["border"]};
84
86
  border-radius: 6px;
85
87
  margin-top: 14px;
86
88
  padding: 12px;
@@ -90,7 +92,7 @@ def _build_stylesheet(p: dict[str, str]) -> str:
90
92
  subcontrol-origin: margin;
91
93
  left: 10px;
92
94
  padding: 0 4px;
93
- color: {p['text']};
95
+ color: {palette["text"]};
94
96
  }}
95
97
 
96
98
  QLabel#HeaderTitle {{
@@ -98,10 +100,10 @@ def _build_stylesheet(p: dict[str, str]) -> str:
98
100
  font-weight: 700;
99
101
  }}
100
102
  QLabel#HeaderSubtitle, QLabel#MutedLabel, QLabel#HelperText {{
101
- color: {p['text_muted']};
103
+ color: {palette["text_muted"]};
102
104
  }}
103
105
  QLabel#VersionLabel {{
104
- color: {p['text_muted']};
106
+ color: {palette["text_muted"]};
105
107
  font-size: 11px;
106
108
  }}
107
109
  QLabel#SectionHeading {{
@@ -110,82 +112,82 @@ def _build_stylesheet(p: dict[str, str]) -> str:
110
112
  }}
111
113
 
112
114
  QPushButton {{
113
- background-color: {p['surface']};
114
- border: 1px solid {p['border']};
115
+ background-color: {palette["surface"]};
116
+ border: 1px solid {palette["border"]};
115
117
  border-radius: 5px;
116
118
  padding: 6px 14px;
117
119
  }}
118
120
  QPushButton:hover {{
119
- border-color: {p['accent']};
121
+ border-color: {palette["accent"]};
120
122
  }}
121
123
  QPushButton:pressed {{
122
- background-color: {p['surface_alt']};
124
+ background-color: {palette["surface_alt"]};
123
125
  }}
124
126
  QPushButton:disabled {{
125
- color: {p['text_muted']};
127
+ color: {palette["text_muted"]};
126
128
  }}
127
129
  QPushButton#PrimaryButton {{
128
- background-color: {p['accent']};
129
- color: {p['accent_text']};
130
+ background-color: {palette["accent"]};
131
+ color: {palette["accent_text"]};
130
132
  border: none;
131
133
  font-weight: 600;
132
134
  padding: 8px 20px;
133
135
  }}
134
136
  QPushButton#PrimaryButton:hover {{
135
- background-color: {p['accent_hover']};
137
+ background-color: {palette["accent_hover"]};
136
138
  }}
137
139
  QPushButton#PrimaryButton:disabled {{
138
- background-color: {p['border']};
139
- color: {p['text_muted']};
140
+ background-color: {palette["border"]};
141
+ color: {palette["text_muted"]};
140
142
  }}
141
143
 
142
144
  QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {{
143
- background-color: {p['surface']};
144
- border: 1px solid {p['border']};
145
+ background-color: {palette["surface"]};
146
+ border: 1px solid {palette["border"]};
145
147
  border-radius: 4px;
146
148
  padding: 4px 6px;
147
- selection-background-color: {p['accent']};
149
+ selection-background-color: {palette["accent"]};
148
150
  }}
149
151
  QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus {{
150
- border: 1px solid {p['focus']};
152
+ border: 1px solid {palette["focus"]};
151
153
  }}
152
154
  QLineEdit:read-only {{
153
- color: {p['text_muted']};
155
+ color: {palette["text_muted"]};
154
156
  }}
155
157
 
156
158
  QTableView {{
157
- background-color: {p['surface']};
158
- alternate-background-color: {p['surface_alt']};
159
- gridline-color: {p['border']};
160
- border: 1px solid {p['border']};
159
+ background-color: {palette["surface"]};
160
+ alternate-background-color: {palette["surface_alt"]};
161
+ gridline-color: {palette["border"]};
162
+ border: 1px solid {palette["border"]};
161
163
  border-radius: 4px;
162
- selection-background-color: {p['accent']};
163
- selection-color: {p['accent_text']};
164
+ selection-background-color: {palette["accent"]};
165
+ selection-color: {palette["accent_text"]};
164
166
  }}
165
167
  QHeaderView::section {{
166
- background-color: {p['surface_alt']};
167
- color: {p['text']};
168
+ background-color: {palette["surface_alt"]};
169
+ color: {palette["text"]};
168
170
  border: none;
169
- border-bottom: 1px solid {p['border']};
171
+ border-bottom: 1px solid {palette["border"]};
170
172
  padding: 4px 6px;
171
173
  font-weight: 600;
172
174
  }}
173
175
 
174
176
  QProgressBar {{
175
- background-color: {p['surface_alt']};
176
- border: 1px solid {p['border']};
177
+ background-color: {palette["surface_alt"]};
178
+ border: 1px solid {palette["border"]};
177
179
  border-radius: 4px;
178
180
  text-align: center;
179
181
  height: 18px;
180
182
  }}
181
183
  QProgressBar::chunk {{
182
- background-color: {p['accent']};
184
+ background-color: {palette["accent"]};
183
185
  border-radius: 3px;
184
186
  }}
185
187
 
186
188
  QPlainTextEdit#LogPanel {{
187
- background-color: {p['surface']};
188
- border: 1px solid {p['border']};
189
+ background-color: {palette["surface"]};
190
+ border: 1px solid {palette["border"]};
189
191
  border-radius: 4px;
190
192
  font-family: "Menlo", "Consolas", monospace;
191
193
  font-size: 12px;
@@ -193,10 +195,10 @@ def _build_stylesheet(p: dict[str, str]) -> str:
193
195
 
194
196
  QToolButton {{
195
197
  border: none;
196
- color: {p['text_muted']};
198
+ color: {palette["text_muted"]};
197
199
  }}
198
200
  QToolButton:hover {{
199
- color: {p['text']};
201
+ color: {palette["text"]};
200
202
  }}
201
203
 
202
204
  QScrollArea {{
@@ -208,7 +210,7 @@ def _build_stylesheet(p: dict[str, str]) -> str:
208
210
  outline: none;
209
211
  }}
210
212
  QPushButton:focus, QComboBox:focus, QCheckBox:focus {{
211
- border: 2px solid {p['focus']};
213
+ border: 2px solid {palette["focus"]};
212
214
  }}
213
215
  """
214
216
 
@@ -29,7 +29,7 @@ def default_cache_path() -> Path:
29
29
 
30
30
 
31
31
  class EnrichmentConfigPanel(QGroupBox):
32
- """"Enrichment settings" group box."""
32
+ """ "Enrichment settings" group box."""
33
33
 
34
34
  changed = Signal()
35
35
 
@@ -119,7 +119,10 @@ class EnrichmentConfigPanel(QGroupBox):
119
119
 
120
120
  def _browse_fail_report(self) -> None:
121
121
  path, _filter = QFileDialog.getSaveFileName(
122
- self, "Choose failure report location", self.fail_report_edit.text(), "JSON files (*.json)"
122
+ self,
123
+ "Choose failure report location",
124
+ self.fail_report_edit.text(),
125
+ "JSON files (*.json)",
123
126
  )
124
127
  if path:
125
128
  self.fail_report_edit.setText(path)
@@ -25,7 +25,7 @@ from risforge_gui.models import InputFilesModel
25
25
 
26
26
 
27
27
  class InputPanel(QGroupBox):
28
- """"Input files" group box: table + add/remove controls + drag-and-drop."""
28
+ """ "Input files" group box: table + add/remove controls + drag-and-drop."""
29
29
 
30
30
  files_changed = Signal()
31
31
 
@@ -57,8 +57,12 @@ class InputPanel(QGroupBox):
57
57
  self.table.setAlternatingRowColors(True)
58
58
  self.table.horizontalHeader().setStretchLastSection(False)
59
59
  self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
60
- self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
61
- self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
60
+ self.table.horizontalHeader().setSectionResizeMode(
61
+ 1, QHeaderView.ResizeMode.ResizeToContents
62
+ )
63
+ self.table.horizontalHeader().setSectionResizeMode(
64
+ 2, QHeaderView.ResizeMode.ResizeToContents
65
+ )
62
66
  self.table.verticalHeader().setVisible(False)
63
67
  self.table.setMinimumHeight(140)
64
68
  self.table.setAccessibleName("Input files table")
@@ -103,7 +107,9 @@ class InputPanel(QGroupBox):
103
107
  folder = QFileDialog.getExistingDirectory(self, "Add folder of RIS files", str(Path.home()))
104
108
  if not folder:
105
109
  return
106
- found = sorted(p for p in Path(folder).iterdir() if p.is_file() and p.suffix.lower() == ".ris")
110
+ found = sorted(
111
+ p for p in Path(folder).iterdir() if p.is_file() and p.suffix.lower() == ".ris"
112
+ )
107
113
  if not found:
108
114
  QMessageBox.information(
109
115
  self, "No RIS files found", f"No .ris files were found directly inside:\n{folder}"
@@ -150,11 +156,13 @@ class InputPanel(QGroupBox):
150
156
 
151
157
  # --- Drag and drop -------------------------------------------------------------
152
158
 
153
- def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
159
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
160
+ """Accept any drag containing file URLs; actual .ris filtering happens on drop."""
154
161
  if event.mimeData().hasUrls():
155
162
  event.acceptProposedAction()
156
163
 
157
- def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
164
+ def dropEvent(self, event: QDropEvent) -> None:
165
+ """Route dropped files through add_dropped_paths() for consistent .ris filtering."""
158
166
  paths = [Path(url.toLocalFile()) for url in event.mimeData().urls() if url.isLocalFile()]
159
167
  if paths:
160
168
  self.add_dropped_paths(paths)
@@ -27,6 +27,14 @@ class LogPanel(QPlainTextEdit):
27
27
  self.setAccessibleName("Activity log")
28
28
 
29
29
  def append_log(self, level: str, message: str) -> None:
30
+ """Append one timestamped, level-colored line.
31
+
32
+ Renders as HTML (not plain text) so WARNING/ERROR lines can be
33
+ colored -- ``message`` is escaped first since it may contain
34
+ arbitrary file paths or error text from risforge itself.
35
+ ``setMaximumBlockCount`` above caps memory use on a very long
36
+ or noisy run by silently dropping the oldest lines.
37
+ """
30
38
  timestamp = datetime.now().strftime("%H:%M:%S")
31
39
  safe_message = html.escape(message)
32
40
  color = _LEVEL_COLORS.get(level.upper())
@@ -35,7 +35,7 @@ def default_output_dir() -> Path:
35
35
 
36
36
 
37
37
  class OutputConfigPanel(QGroupBox):
38
- """"Output" group box: destination directory, final filename, optional intermediates."""
38
+ """ "Output" group box: destination directory, final filename, optional intermediates."""
39
39
 
40
40
  changed = Signal()
41
41
 
@@ -81,11 +81,19 @@ class ProgressPanel(QWidget):
81
81
 
82
82
  def _toggle_log(self, checked: bool) -> None:
83
83
  self.log_panel.setVisible(checked)
84
- self.log_toggle.setText("Hide activity log \u25b4" if checked else "Show activity log \u25be")
84
+ self.log_toggle.setText(
85
+ "Hide activity log \u25b4" if checked else "Show activity log \u25be"
86
+ )
85
87
 
86
88
  # --- Public update API, called from MainWindow's signal-connected slots ---
87
89
 
88
90
  def reset(self, stages_in_use: list[str]) -> None:
91
+ """Prepare the panel for a new run.
92
+
93
+ Only the stages actually applicable to this run's mode are
94
+ made visible (e.g. no "Merge" bar for a single-file pipeline)
95
+ so the layout doesn't imply a step that isn't going to happen.
96
+ """
89
97
  for stage_key, bar in self._bars.items():
90
98
  in_use = stage_key in stages_in_use
91
99
  bar.setVisible(in_use)
@@ -98,6 +106,7 @@ class ProgressPanel(QWidget):
98
106
  self.current_activity_label.setText("")
99
107
 
100
108
  def set_stage_status(self, stage: str, status: str) -> None:
109
+ """Reflect a merge/clean/enrich stage's started/completed transition."""
101
110
  bar = self._bars.get(stage)
102
111
  if bar is None:
103
112
  return
@@ -115,6 +124,7 @@ class ProgressPanel(QWidget):
115
124
  self.current_activity_label.setText(f"{title} complete.")
116
125
 
117
126
  def set_enrichment_progress(self, processed: int, total: int) -> None:
127
+ """Show real, determinate progress -- unlike merge/clean, enrichment reports per-record."""
118
128
  bar = self._bars.get("enrich")
119
129
  if bar is None or total <= 0:
120
130
  return
@@ -124,6 +134,13 @@ class ProgressPanel(QWidget):
124
134
  self.current_activity_label.setText(f"Enriching record {processed:,} of {total:,}...")
125
135
 
126
136
  def update_stats(self, stats: dict) -> None:
137
+ """Update whichever stat labels this run's stats dict has values for.
138
+
139
+ Silently ignores keys the panel doesn't display and ``None``
140
+ values (used for stats that don't apply to the current mode,
141
+ e.g. "merged records" for a single-file run) rather than
142
+ overwriting the placeholder dash with "None".
143
+ """
127
144
  for key, value in stats.items():
128
145
  label = self._stat_value_labels.get(key)
129
146
  if label is None or value is None:
@@ -94,6 +94,12 @@ class ResultsPanel(QWidget):
94
94
  final_ris_path: Path | None,
95
95
  fail_report_path: Path | None,
96
96
  ) -> None:
97
+ """Populate the summary and wire up the action buttons to real, existing files.
98
+
99
+ A path is only offered to "Open ..." if it actually exists on
100
+ disk -- e.g. no failure-report button when nothing failed, so
101
+ the buttons can't lead to a broken/missing-file open attempt.
102
+ """
97
103
  for key, label in self._value_labels.items():
98
104
  value = summary.get(key)
99
105
  label.setText(f"{value:,}" if isinstance(value, int) else "\u2014")
@@ -107,7 +113,9 @@ class ResultsPanel(QWidget):
107
113
  self._summary_form.setRowVisible(row, bool(skipped))
108
114
 
109
115
  self._output_dir = output_dir
110
- self._final_ris_path = final_ris_path if final_ris_path and final_ris_path.exists() else None
116
+ self._final_ris_path = (
117
+ final_ris_path if final_ris_path and final_ris_path.exists() else None
118
+ )
111
119
  self._fail_report_path = (
112
120
  fail_report_path if fail_report_path and fail_report_path.exists() else None
113
121
  )
@@ -23,7 +23,6 @@ import logging
23
23
  from dataclasses import dataclass, field
24
24
  from enum import Enum
25
25
  from pathlib import Path
26
- from typing import Any
27
26
 
28
27
  from PySide6.QtCore import QThread, Signal
29
28
 
@@ -69,9 +68,18 @@ class _LogBridge(logging.Handler):
69
68
  self._log_signal = log_signal
70
69
 
71
70
  def emit(self, record: logging.LogRecord) -> None:
72
- level = "ERROR" if record.levelno >= logging.ERROR else (
73
- "WARNING" if record.levelno >= logging.WARNING else "INFO"
74
- )
71
+ """Map a stdlib log level to the three-tier severity the GUI displays.
72
+
73
+ The log panel only distinguishes INFO/WARNING/ERROR (matching
74
+ the UI's three status colors); DEBUG-and-below is never
75
+ forwarded at all (the handler is installed at level INFO).
76
+ """
77
+ if record.levelno >= logging.ERROR:
78
+ level = "ERROR"
79
+ elif record.levelno >= logging.WARNING:
80
+ level = "WARNING"
81
+ else:
82
+ level = "INFO"
75
83
  self._log_signal.emit(level, record.getMessage())
76
84
 
77
85
 
@@ -89,7 +97,14 @@ class PipelineWorker(QThread):
89
97
  super().__init__(parent)
90
98
  self._config = config
91
99
 
92
- def run(self) -> None: # noqa: D102 -- QThread interface
100
+ def run(self) -> None:
101
+ """Execute the configured operation and always emit a terminal signal.
102
+
103
+ Every exception type risforge's own API documents raising is
104
+ translated into a human-readable error_occurred signal instead
105
+ of propagating out of the thread (which would terminate it
106
+ silently, with nothing visible to the user at all).
107
+ """
93
108
  log_bridge = _LogBridge(self.log_message)
94
109
  risforge_logger = logging.getLogger("risforge")
95
110
  risforge_logger.addHandler(log_bridge)
@@ -60,9 +60,7 @@ class TestBuildParser:
60
60
 
61
61
  def test_enrich_requires_email(self) -> None:
62
62
  parser = build_parser()
63
- args = parser.parse_args(
64
- ["enrich", "in.ris", "out.ris", "--email", "you@example.com"]
65
- )
63
+ args = parser.parse_args(["enrich", "in.ris", "out.ris", "--email", "you@example.com"])
66
64
  assert args.email == "you@example.com"
67
65
 
68
66
  def test_pipeline_defaults_are_none(self) -> None:
@@ -96,9 +94,7 @@ class TestBuildParser:
96
94
 
97
95
  def test_merge_subcommand_parses_inputs_and_trailing_output(self) -> None:
98
96
  parser = build_parser()
99
- args = parser.parse_args(
100
- ["merge", "scopus.ris", "pubmed.ris", "wos.ris", "merged.ris"]
101
- )
97
+ args = parser.parse_args(["merge", "scopus.ris", "pubmed.ris", "wos.ris", "merged.ris"])
102
98
  assert args.command == "merge"
103
99
  assert args.inputs == ["scopus.ris", "pubmed.ris", "wos.ris"]
104
100
  assert args.output == "merged.ris"
@@ -167,9 +163,7 @@ class TestMergeCommandExecution:
167
163
 
168
164
 
169
165
  class TestPipelineCommandExecution:
170
- def test_single_input_pipeline_runs(
171
- self, sample_ris_path, mock_enrichment, tmp_path
172
- ) -> None:
166
+ def test_single_input_pipeline_runs(self, sample_ris_path, mock_enrichment, tmp_path) -> None:
173
167
  parser = build_parser()
174
168
  dedup_path = tmp_path / "clean.ris"
175
169
  enriched_path = tmp_path / "enriched.ris"
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
3
  from typing import Any
5
4
 
6
5
  import rispy
@@ -56,9 +55,7 @@ OPENALEX_PAYLOAD = {
56
55
  "open_access": {"oa_url": "https://example.org/paper.pdf"},
57
56
  }
58
57
  SEMANTIC_SCHOLAR_PAYLOAD = {"abstract": "An abstract from Semantic Scholar."}
59
- UNPAYWALL_PAYLOAD = {
60
- "best_oa_location": {"url_for_pdf": "https://example.org/oa-version.pdf"}
61
- }
58
+ UNPAYWALL_PAYLOAD = {"best_oa_location": {"url_for_pdf": "https://example.org/oa-version.pdf"}}
62
59
 
63
60
 
64
61
  def _make_enricher() -> tuple[RisEnricher, _MockSession]:
@@ -1,7 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- from pathlib import Path
4
-
5
3
  import pytest
6
4
  import rispy
7
5
 
@@ -173,9 +171,7 @@ class TestDeterministicOrdering:
173
171
  merge_ris_files(multi_source_paths, forward_path)
174
172
  merge_ris_files(list(reversed(multi_source_paths)), reversed_path)
175
173
 
176
- forward_titles = [
177
- r.get("title") for r in rispy.load(open(forward_path, encoding="utf-8"))
178
- ]
174
+ forward_titles = [r.get("title") for r in rispy.load(open(forward_path, encoding="utf-8"))]
179
175
  reversed_titles = [
180
176
  r.get("title") for r in rispy.load(open(reversed_path, encoding="utf-8"))
181
177
  ]
@@ -99,9 +99,7 @@ class TestMultiInputPipeline:
99
99
  )
100
100
 
101
101
  cleaned_records = list(rispy.load(open(dedup_path, encoding="utf-8")))
102
- shared_paper = next(
103
- r for r in cleaned_records if "Tractography" in (r.get("title") or "")
104
- )
102
+ shared_paper = next(r for r in cleaned_records if "Tractography" in (r.get("title") or ""))
105
103
  # abstract came from pubmed.ris, keywords came from wos.ris --
106
104
  # merge_cluster() is what combines them into the one surviving record.
107
105
  assert shared_paper["abstract"] == (
@@ -129,9 +127,7 @@ class TestMultiInputPipeline:
129
127
  )
130
128
 
131
129
  enriched_records = list(rispy.load(open(enriched_path, encoding="utf-8")))
132
- shared_paper = next(
133
- r for r in enriched_records if "Tractography" in (r.get("title") or "")
134
- )
130
+ shared_paper = next(r for r in enriched_records if "Tractography" in (r.get("title") or ""))
135
131
  # Kept from the original cleaned record, not overwritten by the
136
132
  # mocked Crossref payload's different title/journal:
137
133
  assert shared_paper["title"] == "Deep Learning for Diffusion MRI Tractography"
@@ -1,34 +0,0 @@
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()
File without changes
File without changes
File without changes