pysfi 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,596 @@
1
+ """PySide2 GUI version of docscan application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import sys
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from PySide2.QtCore import QThread, Signal
13
+ from PySide2.QtWidgets import (
14
+ QApplication,
15
+ QCheckBox,
16
+ QFileDialog,
17
+ QGroupBox,
18
+ QHBoxLayout,
19
+ QLabel,
20
+ QLineEdit,
21
+ QMessageBox,
22
+ QProgressBar,
23
+ QPushButton,
24
+ QSpinBox,
25
+ QTableWidget,
26
+ QTableWidgetItem,
27
+ QTabWidget,
28
+ QTextEdit,
29
+ QVBoxLayout,
30
+ QWidget,
31
+ )
32
+
33
+ # Import from docscan module
34
+ try:
35
+ from docscan import DocumentScanner, Rule
36
+ except ImportError:
37
+ from sfi.docscan.docscan import DocumentScanner, Rule
38
+
39
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ class ScanWorker(QThread):
44
+ """Worker thread for running document scan in background."""
45
+
46
+ progress = Signal(str)
47
+ finished = Signal(dict)
48
+ error = Signal(str)
49
+ progress_update = Signal(int, int) # current, total
50
+
51
+ def __init__(self, scanner: DocumentScanner, threads: int):
52
+ """Initialize worker thread.
53
+
54
+ Args:
55
+ scanner: DocumentScanner instance
56
+ threads: Number of worker threads
57
+ """
58
+ super().__init__()
59
+ self.scanner = scanner
60
+ self.threads = threads
61
+
62
+ def run(self):
63
+ """Run the document scan."""
64
+ try:
65
+ self.progress.emit("Starting scan...") # type: ignore
66
+
67
+ # Set up custom logger to capture messages
68
+ class ProgressHandler(logging.Handler):
69
+ def __init__(self, signal: Signal):
70
+ super().__init__()
71
+ self.signal = signal
72
+
73
+ def emit(self, record: logging.LogRecord) -> None:
74
+ self.signal.emit(lambda: self.format(record)) # type: ignore
75
+
76
+ progress_handler = ProgressHandler(self.progress)
77
+ progress_handler.setFormatter(logging.Formatter("%(message)s"))
78
+ logger.addHandler(progress_handler)
79
+
80
+ # Set progress callback
81
+ def progress_callback(current: int, total: int) -> None:
82
+ self.progress_update.emit(current, total) # pyright: ignore[reportAttributeAccessIssue]
83
+
84
+ self.scanner.set_progress_callback(progress_callback)
85
+
86
+ # Run scan
87
+ results = self.scanner.scan(threads=self.threads, show_progress=True)
88
+
89
+ logger.removeHandler(progress_handler)
90
+ self.progress.emit("Scan complete!") # pyright: ignore[reportAttributeAccessIssue]
91
+ self.finished.emit(results) # pyright: ignore[reportAttributeAccessIssue]
92
+ except Exception as e:
93
+ self.error.emit(str(e)) # pyright: ignore[reportAttributeAccessIssue]
94
+
95
+
96
+ class DocScanGUI(QWidget):
97
+ """Main GUI window for document scanner application."""
98
+
99
+ def __init__(self):
100
+ """Initialize GUI components."""
101
+ super().__init__()
102
+ self.scan_results = None
103
+ self.scan_worker = None
104
+ self.is_scanning = False
105
+ self.init_ui()
106
+
107
+ def init_ui(self):
108
+ """Initialize user interface."""
109
+ self.setWindowTitle("Document Scanner GUI")
110
+ self.setMinimumSize(1000, 700)
111
+
112
+ # Main layout
113
+ main_layout = QVBoxLayout()
114
+ self.setLayout(main_layout)
115
+
116
+ # Create tab widget for options
117
+ tab_widget = QTabWidget()
118
+ self._create_input_tab(tab_widget)
119
+ self._create_options_tab(tab_widget)
120
+ main_layout.addWidget(tab_widget)
121
+
122
+ # Create other sections
123
+ self._create_actions_section(main_layout)
124
+ self._create_results_section(main_layout)
125
+
126
+ def _create_input_tab(self, tab_widget: QTabWidget) -> None:
127
+ """Create input configuration tab.
128
+
129
+ Args:
130
+ tab_widget: Tab widget to add tab to
131
+ """
132
+ input_tab = QWidget()
133
+ input_layout = QVBoxLayout()
134
+ input_tab.setLayout(input_layout)
135
+
136
+ # Input directory
137
+ dir_layout = QHBoxLayout()
138
+ dir_label = QLabel("Input Directory:")
139
+ self.dir_edit = QLineEdit(str(Path.cwd()))
140
+ dir_browse_btn = QPushButton("Browse...")
141
+ dir_browse_btn.clicked.connect(self._browse_directory) # pyright: ignore[reportAttributeAccessIssue]
142
+ self.dir_edit.textChanged.connect(self._on_directory_changed) # pyright: ignore[reportAttributeAccessIssue]
143
+ dir_layout.addWidget(dir_label)
144
+ dir_layout.addWidget(self.dir_edit)
145
+ dir_layout.addWidget(dir_browse_btn)
146
+ input_layout.addLayout(dir_layout)
147
+
148
+ # Rules file
149
+ rules_layout = QHBoxLayout()
150
+ rules_label = QLabel("Rules File:")
151
+ self.rules_edit = QLineEdit("rules.json")
152
+ rules_browse_btn = QPushButton("Browse...")
153
+ rules_browse_btn.clicked.connect(self._browse_rules_file) # pyright: ignore[reportAttributeAccessIssue]
154
+ rules_layout.addWidget(rules_label)
155
+ rules_layout.addWidget(self.rules_edit)
156
+ rules_layout.addWidget(rules_browse_btn)
157
+ input_layout.addLayout(rules_layout)
158
+
159
+ # File types
160
+ types_layout = QVBoxLayout()
161
+ types_label = QLabel("File Types:")
162
+ self.types_edit = QLineEdit("pdf,docx,xlsx,pptx,txt,odt,rtf,epub,csv,xml,html,md")
163
+ types_layout.addWidget(types_label)
164
+ types_layout.addWidget(self.types_edit)
165
+ input_layout.addLayout(types_layout)
166
+
167
+ input_layout.addStretch()
168
+
169
+ tab_widget.addTab(input_tab, "Input Configuration")
170
+
171
+ def _create_options_tab(self, tab_widget: QTabWidget) -> None:
172
+ """Create scan options tab.
173
+
174
+ Args:
175
+ tab_widget: Tab widget to add tab to
176
+ """
177
+ options_tab = QWidget()
178
+ options_layout = QVBoxLayout()
179
+ options_tab.setLayout(options_layout)
180
+
181
+ # Checkboxes
182
+ self.ocr_checkbox = QCheckBox("Use PDF OCR")
183
+ self.process_pool_checkbox = QCheckBox("Use Process Pool (CPU-intensive)")
184
+ options_layout.addWidget(self.ocr_checkbox)
185
+ options_layout.addWidget(self.process_pool_checkbox)
186
+
187
+ # Thread count
188
+ thread_layout = QHBoxLayout()
189
+ thread_label = QLabel("Threads:")
190
+ self.thread_spin = QSpinBox()
191
+ self.thread_spin.setMinimum(1)
192
+ self.thread_spin.setMaximum(16)
193
+ self.thread_spin.setValue(4)
194
+ thread_layout.addWidget(thread_label)
195
+ thread_layout.addWidget(self.thread_spin)
196
+ thread_layout.addStretch()
197
+ options_layout.addLayout(thread_layout)
198
+
199
+ # Batch size
200
+ batch_layout = QHBoxLayout()
201
+ batch_label = QLabel("Batch Size:")
202
+ self.batch_spin = QSpinBox()
203
+ self.batch_spin.setMinimum(1)
204
+ self.batch_spin.setMaximum(1000)
205
+ self.batch_spin.setValue(50)
206
+ batch_layout.addWidget(batch_label)
207
+ batch_layout.addWidget(self.batch_spin)
208
+ batch_layout.addStretch()
209
+ options_layout.addLayout(batch_layout)
210
+
211
+ options_layout.addStretch()
212
+
213
+ tab_widget.addTab(options_tab, "Scan Options")
214
+
215
+ def _create_actions_section(self, parent_layout: QVBoxLayout) -> None:
216
+ """Create action buttons section.
217
+
218
+ Args:
219
+ parent_layout: Parent layout to add this section to
220
+ """
221
+ actions_layout = QHBoxLayout()
222
+
223
+ self.scan_btn = QPushButton("Start Scan")
224
+ self.scan_btn.clicked.connect(self._start_scan) # pyright: ignore[reportAttributeAccessIssue]
225
+ self.scan_btn.setMinimumHeight(40)
226
+
227
+ self.pause_btn = QPushButton("Pause")
228
+ self.pause_btn.clicked.connect(self._pause_scan) # pyright: ignore[reportAttributeAccessIssue]
229
+ self.pause_btn.setEnabled(False)
230
+ self.pause_btn.setMinimumHeight(40)
231
+
232
+ self.stop_btn = QPushButton("Stop")
233
+ self.stop_btn.clicked.connect(self._stop_scan) # pyright: ignore[reportAttributeAccessIssue]
234
+ self.stop_btn.setEnabled(False)
235
+ self.stop_btn.setMinimumHeight(40)
236
+
237
+ self.save_btn = QPushButton("Save Results")
238
+ self.save_btn.clicked.connect(self._save_results) # pyright: ignore[reportAttributeAccessIssue]
239
+ self.save_btn.setEnabled(False)
240
+ self.save_btn.setMinimumHeight(40)
241
+
242
+ self.clear_btn = QPushButton("Clear Results")
243
+ self.clear_btn.clicked.connect(self._clear_results) # pyright: ignore[reportAttributeAccessIssue]
244
+ self.clear_btn.setMinimumHeight(40)
245
+
246
+ actions_layout.addWidget(self.scan_btn)
247
+ actions_layout.addWidget(self.pause_btn)
248
+ actions_layout.addWidget(self.stop_btn)
249
+ actions_layout.addWidget(self.save_btn)
250
+ actions_layout.addWidget(self.clear_btn)
251
+
252
+ parent_layout.addLayout(actions_layout)
253
+
254
+ def _create_results_section(self, parent_layout: QVBoxLayout) -> None:
255
+ """Create results display section.
256
+
257
+ Args:
258
+ parent_layout: Parent layout to add this section to
259
+ """
260
+ results_group = QGroupBox("Results")
261
+ results_layout = QVBoxLayout()
262
+ results_group.setLayout(results_layout)
263
+
264
+ # Summary labels
265
+ summary_layout = QHBoxLayout()
266
+ self.files_label = QLabel("Files Scanned: 0")
267
+ self.matches_label = QLabel("Files with Matches: 0")
268
+ summary_layout.addWidget(self.files_label)
269
+ summary_layout.addWidget(self.matches_label)
270
+ results_layout.addLayout(summary_layout)
271
+
272
+ # Progress bar
273
+ self.progress_bar = QProgressBar()
274
+ self.progress_bar.setMinimum(0)
275
+ self.progress_bar.setMaximum(100)
276
+ self.progress_bar.setValue(0)
277
+ results_layout.addWidget(self.progress_bar)
278
+
279
+ # Progress/Log text
280
+ self.log_text = QTextEdit()
281
+ self.log_text.setReadOnly(True)
282
+ self.log_text.setMaximumHeight(150)
283
+ results_layout.addWidget(QLabel("Progress Log:"))
284
+ results_layout.addWidget(self.log_text)
285
+
286
+ # Results table
287
+ self.results_table = QTableWidget()
288
+ self.results_table.setColumnCount(4)
289
+ self.results_table.setHorizontalHeaderLabels(["File", "Type", "Matches", "Time (s)"])
290
+ self.results_table.horizontalHeader().setStretchLastSection(True)
291
+ results_layout.addWidget(QLabel("Match Details:"))
292
+ results_layout.addWidget(self.results_table)
293
+
294
+ # Match details text
295
+ self.details_text = QTextEdit()
296
+ self.details_text.setReadOnly(True)
297
+ self.details_text.setMaximumHeight(200)
298
+ results_layout.addWidget(QLabel("Selected Match Context:"))
299
+ results_layout.addWidget(self.details_text)
300
+
301
+ # Connect table selection
302
+ self.results_table.itemSelectionChanged.connect(self._show_match_details) # pyright: ignore[reportAttributeAccessIssue]
303
+
304
+ parent_layout.addWidget(results_group)
305
+
306
+ def _browse_directory(self) -> None:
307
+ """Open directory browser dialog."""
308
+ dir_path = QFileDialog.getExistingDirectory(self, "Select Input Directory")
309
+ if dir_path:
310
+ self.dir_edit.setText(str(Path(dir_path)))
311
+
312
+ def _on_directory_changed(self) -> None:
313
+ """Handle directory text change - auto-search for rules.json."""
314
+ dir_text = self.dir_edit.text()
315
+ if not dir_text:
316
+ return
317
+
318
+ try:
319
+ input_dir = Path(dir_text)
320
+ if input_dir.exists() and input_dir.is_dir():
321
+ # Search for rules.json or rules*.json files
322
+ rule_files = list(input_dir.glob("rules.json")) + list(input_dir.glob("rules*.json"))
323
+
324
+ if rule_files:
325
+ # Use the first matching file, prefer exact "rules.json"
326
+ exact_match = next((f for f in rule_files if f.name == "rules.json"), None)
327
+ rules_file = exact_match if exact_match else rule_files[0]
328
+ self.rules_edit.setText(str(rules_file.resolve()))
329
+ except Exception:
330
+ # Ignore errors during directory change handling
331
+ pass
332
+
333
+ def _browse_rules_file(self) -> None:
334
+ """Open file browser dialog for rules file."""
335
+ file_path, _ = QFileDialog.getOpenFileName(self, "Select Rules File", "", "JSON Files (*.json)")
336
+ if file_path:
337
+ self.rules_edit.setText(str(Path(file_path)))
338
+
339
+ def _load_rules(self) -> list[Rule]:
340
+ """Load rules from JSON file.
341
+
342
+ Returns:
343
+ List of Rule objects
344
+ """
345
+ rules_file = Path(self.rules_edit.text())
346
+ if not rules_file.exists():
347
+ # Try finding rules in input directory
348
+ input_dir = Path(self.dir_edit.text())
349
+ rule_files = list(input_dir.glob("rules*.json"))
350
+ if rule_files:
351
+ rules_file = rule_files[0]
352
+ self.rules_edit.setText(str(rules_file.resolve()))
353
+ else:
354
+ raise FileNotFoundError(f"Rules file not found: {rules_file}")
355
+
356
+ with open(rules_file, encoding="utf-8") as f:
357
+ rules_data = json.load(f)
358
+
359
+ rules = []
360
+ if isinstance(rules_data, list):
361
+ rules = [Rule(rule) for rule in rules_data]
362
+ elif isinstance(rules_data, dict) and "rules" in rules_data:
363
+ rules = [Rule(rule) for rule in rules_data["rules"]]
364
+
365
+ return rules
366
+
367
+ def _start_scan(self) -> None:
368
+ """Start the document scan."""
369
+ # Validate inputs
370
+ input_dir = Path(self.dir_edit.text())
371
+ if not input_dir.exists() or not input_dir.is_dir():
372
+ QMessageBox.warning(self, "Error", "Invalid input directory")
373
+ return
374
+
375
+ try:
376
+ rules = self._load_rules()
377
+ if not rules:
378
+ QMessageBox.warning(self, "Error", "No valid rules found")
379
+ return
380
+ except Exception as e:
381
+ QMessageBox.warning(self, "Error", f"Failed to load rules: {e}")
382
+ return
383
+
384
+ # Parse file types
385
+ file_types = [ft.strip() for ft in self.types_edit.text().split(",")]
386
+
387
+ # Clear previous results
388
+ self._clear_results()
389
+
390
+ # Set scanning state
391
+ self.is_scanning = True
392
+
393
+ # Disable scan button during scan, enable pause and stop
394
+ self.scan_btn.setEnabled(False)
395
+ self.pause_btn.setEnabled(True)
396
+ self.stop_btn.setEnabled(True)
397
+ self.pause_btn.setText("Pause")
398
+
399
+ # Create scanner
400
+ scanner = DocumentScanner(
401
+ input_dir=input_dir,
402
+ rules=rules,
403
+ file_types=file_types,
404
+ use_pdf_ocr=self.ocr_checkbox.isChecked(),
405
+ use_process_pool=self.process_pool_checkbox.isChecked(),
406
+ batch_size=self.batch_spin.value(),
407
+ )
408
+
409
+ # Create and start worker thread
410
+ self.scan_worker = ScanWorker(scanner, self.thread_spin.value())
411
+ self.scan_worker.progress.connect(self._log_message) # pyright: ignore[reportAttributeAccessIssue]
412
+ self.scan_worker.progress_update.connect(self._update_progress) # pyright: ignore[reportAttributeAccessIssue]
413
+ self.scan_worker.finished.connect(self._scan_finished) # pyright: ignore[reportAttributeAccessIssue]
414
+ self.scan_worker.error.connect(self._scan_error) # pyright: ignore[reportAttributeAccessIssue]
415
+ self.scan_worker.start()
416
+
417
+ def _scan_finished(self, results: dict[str, Any]) -> None:
418
+ """Handle scan completion.
419
+
420
+ Args:
421
+ results: Scan results dictionary
422
+ """
423
+ self.scan_results = results
424
+ self.is_scanning = False
425
+ self.scan_btn.setEnabled(True)
426
+ self.pause_btn.setEnabled(False)
427
+ self.stop_btn.setEnabled(False)
428
+ self.save_btn.setEnabled(True)
429
+
430
+ # Update summary
431
+ scan_info = results.get("scan_info", {})
432
+ processed = scan_info.get("files_processed", scan_info.get("total_files", 0))
433
+ self.files_label.setText(f"Files Scanned: {processed}/{scan_info.get('total_files', 0)}")
434
+ self.matches_label.setText(f"Files with Matches: {scan_info.get('files_with_matches', 0)}")
435
+
436
+ # Update progress bar to 100%
437
+ self.progress_bar.setValue(100)
438
+
439
+ # Populate results table
440
+ matches = results.get("matches", [])
441
+ self.results_table.setRowCount(len(matches))
442
+
443
+ for row, match_data in enumerate(matches):
444
+ file_path = match_data.get("file_path", "")
445
+ file_type = match_data.get("file_type", "")
446
+ match_count = len(match_data.get("matches", []))
447
+ proc_time = match_data.get("metadata", {}).get("processing_time_seconds", 0)
448
+
449
+ self.results_table.setItem(row, 0, QTableWidgetItem(Path(file_path).name))
450
+ self.results_table.setItem(row, 1, QTableWidgetItem(file_type))
451
+ self.results_table.setItem(row, 2, QTableWidgetItem(str(match_count)))
452
+ self.results_table.setItem(row, 3, QTableWidgetItem(f"{proc_time:.3f}"))
453
+
454
+ status = "completed" if not results.get("stopped") else "stopped"
455
+ self._log_message(f"Scan {status}. Found matches in {len(matches)} files.")
456
+
457
+ def _scan_error(self, error_msg: str) -> None:
458
+ """Handle scan error.
459
+
460
+ Args:
461
+ error_msg: Error message
462
+ """
463
+ self.is_scanning = False
464
+ self.scan_btn.setEnabled(True)
465
+ self.pause_btn.setEnabled(False)
466
+ self.stop_btn.setEnabled(False)
467
+ self._log_message(f"Error: {error_msg}")
468
+ QMessageBox.critical(self, "Error", f"Scan failed: {error_msg}")
469
+
470
+ def _pause_scan(self) -> None:
471
+ """Pause or resume the document scan."""
472
+ if self.scan_worker and self.scan_worker.scanner:
473
+ scanner = self.scan_worker.scanner
474
+ if scanner.is_paused():
475
+ # Resume
476
+ scanner.resume()
477
+ self.pause_btn.setText("Pause")
478
+ self._log_message("Scan resumed")
479
+ else:
480
+ # Pause
481
+ scanner.pause()
482
+ self.pause_btn.setText("Resume")
483
+ self._log_message("Scan paused")
484
+
485
+ def _stop_scan(self) -> None:
486
+ """Stop the document scan."""
487
+ if not self.is_scanning:
488
+ return
489
+
490
+ if self.scan_worker and self.scan_worker.scanner:
491
+ scanner = self.scan_worker.scanner
492
+ scanner.stop()
493
+
494
+ # Disable pause and stop buttons immediately
495
+ self.pause_btn.setEnabled(False)
496
+ self.stop_btn.setEnabled(False)
497
+
498
+ # Log the stop action
499
+ self._log_message("Stopping scan...")
500
+
501
+ # Force UI update
502
+ QApplication.processEvents()
503
+
504
+ def _update_progress(self, current: int, total: int) -> None:
505
+ """Update progress bar and file count.
506
+
507
+ Args:
508
+ current: Current number of files processed
509
+ total: Total number of files
510
+ """
511
+ if total > 0:
512
+ percentage = int((current / total) * 100)
513
+ self.progress_bar.setValue(percentage)
514
+ self.files_label.setText(f"Files Scanned: {current}/{total}")
515
+
516
+ def _show_match_details(self) -> None:
517
+ """Show details of selected match in the results table."""
518
+ selected_rows = self.results_table.selectionModel().selectedRows()
519
+ if not selected_rows or not self.scan_results:
520
+ return
521
+
522
+ row = selected_rows[0].row()
523
+ matches = self.scan_results.get("matches", [])
524
+
525
+ if row >= len(matches):
526
+ return
527
+
528
+ match_data = matches[row]
529
+ details = []
530
+
531
+ # File info
532
+ details.append(f"File: {match_data.get('file_path', '')}")
533
+ details.append(f"Type: {match_data.get('file_type', '')}")
534
+ details.append(f"Size: {match_data.get('file_size', 0)} bytes\n")
535
+
536
+ # Match info
537
+ for match in match_data.get("matches", []):
538
+ details.append(f"Rule: {match.get('rule_name', '')}")
539
+ details.append(f"Description: {match.get('rule_description', '')}")
540
+ details.append(f"Line {match.get('line_number', 0)}: {match.get('match', '')}")
541
+ details.append("\nContext:")
542
+ for ctx_line in match.get("context", []):
543
+ details.append(f" {ctx_line}")
544
+ details.append("-" * 50)
545
+
546
+ self.details_text.setText("\n".join(details))
547
+
548
+ def _save_results(self) -> None:
549
+ """Save scan results to JSON file."""
550
+ if not self.scan_results:
551
+ QMessageBox.warning(self, "Warning", "No results to save")
552
+ return
553
+
554
+ default_name = f"scan_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
555
+ file_path, _ = QFileDialog.getSaveFileName(self, "Save Results", default_name, "JSON Files (*.json)")
556
+
557
+ if file_path:
558
+ try:
559
+ with open(file_path, "w", encoding="utf-8") as f:
560
+ json.dump(self.scan_results, f, indent=2, ensure_ascii=False)
561
+ self._log_message(f"Results saved to: {file_path}")
562
+ QMessageBox.information(self, "Success", f"Results saved to:\n{file_path}")
563
+ except Exception as e:
564
+ QMessageBox.critical(self, "Error", f"Failed to save results: {e}")
565
+
566
+ def _clear_results(self) -> None:
567
+ """Clear all results and logs."""
568
+ self.scan_results = None
569
+ self.log_text.clear()
570
+ self.results_table.setRowCount(0)
571
+ self.details_text.clear()
572
+ self.files_label.setText("Files Scanned: 0")
573
+ self.matches_label.setText("Files with Matches: 0")
574
+ self.progress_bar.setValue(0)
575
+ self.save_btn.setEnabled(False)
576
+
577
+ def _log_message(self, message: str) -> None:
578
+ """Add message to log text area.
579
+
580
+ Args:
581
+ message: Message to log
582
+ """
583
+ timestamp = datetime.now().strftime("%H:%M:%S")
584
+ self.log_text.append(f"[{timestamp}] {message}")
585
+
586
+
587
+ def main():
588
+ """Main entry point for GUI application."""
589
+ app = QApplication(sys.argv)
590
+ window = DocScanGUI()
591
+ window.show()
592
+ sys.exit(app.exec_())
593
+
594
+
595
+ if __name__ == "__main__":
596
+ main()
File without changes