gitflow-analytics 3.6.2__py3-none-any.whl → 3.7.0__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.
Files changed (26) hide show
  1. gitflow_analytics/__init__.py +8 -12
  2. gitflow_analytics/_version.py +1 -1
  3. gitflow_analytics/cli.py +151 -170
  4. gitflow_analytics/cli_wizards/install_wizard.py +5 -5
  5. gitflow_analytics/models/database.py +229 -8
  6. gitflow_analytics/security/reports/__init__.py +5 -0
  7. gitflow_analytics/security/reports/security_report.py +358 -0
  8. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/METADATA +2 -4
  9. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/RECORD +13 -24
  10. gitflow_analytics/tui/__init__.py +0 -5
  11. gitflow_analytics/tui/app.py +0 -726
  12. gitflow_analytics/tui/progress_adapter.py +0 -313
  13. gitflow_analytics/tui/screens/__init__.py +0 -8
  14. gitflow_analytics/tui/screens/analysis_progress_screen.py +0 -857
  15. gitflow_analytics/tui/screens/configuration_screen.py +0 -523
  16. gitflow_analytics/tui/screens/loading_screen.py +0 -348
  17. gitflow_analytics/tui/screens/main_screen.py +0 -321
  18. gitflow_analytics/tui/screens/results_screen.py +0 -735
  19. gitflow_analytics/tui/widgets/__init__.py +0 -7
  20. gitflow_analytics/tui/widgets/data_table.py +0 -255
  21. gitflow_analytics/tui/widgets/export_modal.py +0 -301
  22. gitflow_analytics/tui/widgets/progress_widget.py +0 -187
  23. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/WHEEL +0 -0
  24. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/entry_points.txt +0 -0
  25. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/licenses/LICENSE +0 -0
  26. {gitflow_analytics-3.6.2.dist-info → gitflow_analytics-3.7.0.dist-info}/top_level.txt +0 -0
@@ -1,726 +0,0 @@
1
- """Main TUI application for GitFlow Analytics."""
2
-
3
- import contextlib
4
- from pathlib import Path
5
- from typing import Optional
6
-
7
- from textual.app import App, ComposeResult
8
- from textual.binding import Binding
9
-
10
- from gitflow_analytics.config import Config, ConfigLoader
11
-
12
- from .screens.analysis_progress_screen import AnalysisProgressScreen
13
- from .screens.configuration_screen import ConfigurationScreen
14
- from .screens.loading_screen import InitializationLoadingScreen
15
- from .screens.main_screen import MainScreen
16
-
17
-
18
- class GitFlowAnalyticsApp(App):
19
- """
20
- Main Terminal User Interface application for GitFlow Analytics.
21
-
22
- WHY: Provides a comprehensive TUI that guides users through the entire
23
- analytics workflow from configuration to results analysis. Designed to
24
- be more user-friendly than command-line interface while maintaining
25
- the power and flexibility of the core analysis engine.
26
-
27
- DESIGN DECISION: Uses a screen-based navigation model where each major
28
- workflow step (configuration, analysis, results) has its own dedicated
29
- screen. This provides clear context separation and allows for complex
30
- interactions within each workflow step.
31
- """
32
-
33
- TITLE = "GitFlow Analytics"
34
- SUB_TITLE = "Developer Productivity Analysis"
35
-
36
- CSS = """
37
- /* Global styles */
38
- .screen-title {
39
- text-align: center;
40
- text-style: bold;
41
- color: $accent;
42
- margin: 1;
43
- padding: 1;
44
- }
45
-
46
- .section-title {
47
- text-style: bold;
48
- color: $secondary;
49
- margin: 1 0;
50
- }
51
-
52
- .subsection-title {
53
- text-style: bold;
54
- color: $warning;
55
- margin: 1 0 0 0;
56
- }
57
-
58
- .help-text {
59
- color: $text-muted;
60
- text-style: italic;
61
- margin: 0 0 1 0;
62
- }
63
-
64
- .info-message {
65
- color: $warning;
66
- text-style: italic;
67
- margin: 1 0;
68
- }
69
-
70
- /* Panel styles */
71
- .status-panel {
72
- border: solid $primary;
73
- margin: 1;
74
- padding: 1;
75
- height: auto;
76
- }
77
-
78
- .actions-panel {
79
- border: solid $secondary;
80
- margin: 1;
81
- padding: 1;
82
- height: auto;
83
- }
84
-
85
- .stats-panel {
86
- border: solid $accent;
87
- margin: 1;
88
- padding: 1;
89
- height: auto;
90
- }
91
-
92
- .progress-panel {
93
- height: 8;
94
- border: solid $primary;
95
- margin: 1;
96
- padding: 1;
97
- }
98
-
99
- .log-panel {
100
- border: solid $accent;
101
- margin: 1;
102
- padding: 1;
103
- min-height: 10;
104
- }
105
-
106
- .panel-title {
107
- text-style: bold;
108
- color: $primary;
109
- margin-bottom: 1;
110
- }
111
-
112
- /* Form styles */
113
- .form-row {
114
- height: 3;
115
- margin: 1 0;
116
- }
117
-
118
- .form-label {
119
- width: 25;
120
- padding: 1 0;
121
- }
122
-
123
- .form-input {
124
- width: 1fr;
125
- }
126
-
127
- /* Button styles */
128
- .button-bar {
129
- dock: bottom;
130
- height: 3;
131
- align: center middle;
132
- margin: 1;
133
- }
134
-
135
- .action-bar {
136
- height: 3;
137
- align: center middle;
138
- margin: 1 0;
139
- }
140
-
141
- /* Modal styles */
142
- #config-modal {
143
- background: $surface;
144
- border: thick $primary;
145
- width: 90%;
146
- height: 85%;
147
- padding: 1;
148
- }
149
-
150
- .modal-title {
151
- text-align: center;
152
- text-style: bold;
153
- color: $primary;
154
- margin-bottom: 1;
155
- }
156
-
157
- /* Validation styles */
158
- .validation-error {
159
- color: $error;
160
- text-style: bold;
161
- }
162
-
163
- .validation-success {
164
- color: $success;
165
- text-style: bold;
166
- }
167
-
168
- /* Table styles */
169
- EnhancedDataTable {
170
- height: auto;
171
- min-height: 20;
172
- }
173
-
174
- EnhancedDataTable > .datatable--header {
175
- background: $primary 10%;
176
- color: $primary;
177
- text-style: bold;
178
- }
179
-
180
- EnhancedDataTable > .datatable--row-hover {
181
- background: $accent 20%;
182
- }
183
-
184
- EnhancedDataTable > .datatable--row-cursor {
185
- background: $primary 30%;
186
- }
187
-
188
- /* Progress widget styles */
189
- AnalysisProgressWidget {
190
- height: auto;
191
- border: solid $primary;
192
- margin: 1;
193
- padding: 1;
194
- }
195
-
196
- .progress-title {
197
- text-style: bold;
198
- color: $primary;
199
- margin-bottom: 1;
200
- }
201
-
202
- .progress-status {
203
- color: $text;
204
- margin-top: 1;
205
- }
206
-
207
- .progress-eta {
208
- color: $accent;
209
- text-style: italic;
210
- }
211
-
212
- /* Loading screen styles */
213
- #loading-container {
214
- height: 100%;
215
- align: center middle;
216
- }
217
-
218
- #loading-content {
219
- width: 80%;
220
- max-width: 100;
221
- align: center middle;
222
- margin: 2;
223
- }
224
-
225
- #main-spinner {
226
- margin: 1;
227
- align: center middle;
228
- }
229
-
230
- #loading-steps {
231
- margin-top: 1;
232
- }
233
-
234
- #loading-steps > Static {
235
- margin: 0 0 0 2;
236
- padding: 0;
237
- }
238
-
239
- /* Utility classes */
240
- .hidden {
241
- display: none;
242
- }
243
-
244
- .center {
245
- text-align: center;
246
- }
247
-
248
- .bold {
249
- text-style: bold;
250
- }
251
-
252
- .italic {
253
- text-style: italic;
254
- }
255
-
256
- .error {
257
- color: $error;
258
- }
259
-
260
- .success {
261
- color: $success;
262
- }
263
-
264
- .warning {
265
- color: $warning;
266
- }
267
- """
268
-
269
- BINDINGS = [
270
- Binding("ctrl+q", "quit", "Quit", priority=True),
271
- Binding("ctrl+c", "quit", "Quit", priority=True),
272
- Binding("f1", "help", "Help"),
273
- Binding("ctrl+d", "toggle_dark", "Toggle Dark Mode"),
274
- ]
275
-
276
- def __init__(self) -> None:
277
- super().__init__()
278
- self.config: Optional[Config] = None
279
- self.config_path: Optional[Path] = None
280
- self.initialization_complete = False
281
- self._nlp_engine = None
282
- self.dark = True # Initialize dark mode state
283
- self.default_weeks = 12 # Default weeks for analysis, can be overridden from CLI
284
-
285
- def compose(self) -> ComposeResult:
286
- """
287
- Compose the main application. We don't yield screens here during startup
288
- to avoid screen stack management issues.
289
-
290
- WHY: Instead of yielding screens in compose, we handle the initial screen
291
- setup in on_mount to ensure proper screen stack management and avoid
292
- IndexError when transitioning between screens.
293
- """
294
- # Don't yield screens here - handle in on_mount for proper screen management
295
- # Return empty to avoid NoneType iteration error
296
- return iter([])
297
-
298
- def on_mount(self) -> None:
299
- """
300
- Handle application startup with loading screen.
301
-
302
- WHY: Shows loading screen immediately while heavy initialization
303
- happens in the background, providing better user experience.
304
- Using push_screen instead of compose to avoid screen stack issues.
305
- """
306
- # Set up application title
307
- self.title = "GitFlow Analytics"
308
- self.sub_title = "Developer Productivity Analysis"
309
-
310
- # Start with loading screen during initialization
311
- if not self.initialization_complete:
312
- loading_screen = InitializationLoadingScreen(
313
- config_loader_func=self._load_default_config,
314
- nlp_init_func=self._initialize_nlp_engine,
315
- loading_message="Initializing GitFlow Analytics...",
316
- id="loading-screen",
317
- )
318
- self.push_screen(loading_screen)
319
- else:
320
- # Show main screen if already initialized
321
- main_screen = MainScreen(self.config, self.config_path, id="main-screen")
322
- self.push_screen(main_screen)
323
-
324
- def _load_default_config(self) -> Optional[tuple]:
325
- """
326
- Attempt to load configuration from default locations.
327
-
328
- WHY: Provides automatic configuration discovery to reduce setup
329
- friction for users who have configurations in standard locations.
330
- This version is designed to be called from the loading screen.
331
-
332
- @return: Tuple of (config, config_path) if found, None otherwise
333
- """
334
- default_config_paths = [
335
- Path("config.yaml"),
336
- Path("gitflow.yaml"),
337
- Path(".gitflow/config.yaml"),
338
- Path("~/.gitflow/config.yaml").expanduser(),
339
- ]
340
-
341
- for config_path in default_config_paths:
342
- if config_path.exists():
343
- try:
344
- config = ConfigLoader.load(config_path)
345
- return (config, config_path)
346
-
347
- except Exception:
348
- # Log error but continue trying other paths
349
- continue
350
-
351
- return None
352
-
353
- def _initialize_nlp_engine(self, config: Optional[Config] = None) -> Optional[object]:
354
- """
355
- Initialize the NLP engine for qualitative analysis.
356
-
357
- WHY: spaCy model loading can be slow and should happen during
358
- the loading screen to provide user feedback. This method handles
359
- the heavy NLP initialization work.
360
-
361
- @param config: Configuration object with qualitative settings
362
- @return: Initialized NLP engine object or None if not needed/failed
363
- """
364
- if not config or not getattr(config, "qualitative", None):
365
- return None
366
-
367
- if not config.qualitative.enabled:
368
- return None
369
-
370
- try:
371
- # Import here to avoid slow imports at module level
372
- from gitflow_analytics.qualitative.core.nlp_engine import NLPEngine
373
-
374
- # Initialize the NLP engine (this loads spaCy models)
375
- nlp_engine = NLPEngine(config.qualitative.nlp)
376
-
377
- # Validate the setup
378
- is_valid, issues = nlp_engine.validate_setup()
379
- if not is_valid:
380
- # Return None if validation fails, but don't raise exception
381
- return None
382
-
383
- return nlp_engine
384
-
385
- except ImportError:
386
- # Qualitative analysis dependencies not available
387
- return None
388
- except Exception:
389
- # Other initialization errors
390
- return None
391
-
392
- def on_initialization_loading_screen_initialization_complete(
393
- self, message: InitializationLoadingScreen.InitializationComplete
394
- ) -> None:
395
- """
396
- Handle completion of initialization loading.
397
-
398
- WHY: When the loading screen completes initialization, we need to
399
- update the app state and transition to the main screen. Using pop_screen
400
- and push_screen for proper screen stack management.
401
- """
402
- initialization_data = message.data
403
-
404
- # Update app state with loaded configuration
405
- config_result = initialization_data.get("config")
406
- if config_result:
407
- self.config, self.config_path = config_result
408
-
409
- # Store NLP engine for later use
410
- self._nlp_engine = initialization_data.get("nlp")
411
-
412
- # Mark initialization as complete
413
- self.initialization_complete = True
414
-
415
- # Transition to main screen by popping loading screen and pushing main screen
416
- with contextlib.suppress(Exception):
417
- self.pop_screen() # Remove loading screen
418
-
419
- main_screen = MainScreen(self.config, self.config_path, id="main-screen")
420
- self.push_screen(main_screen)
421
-
422
- # Show success notification if config was loaded
423
- if self.config and self.config_path:
424
- self.notify(f"Loaded configuration from {self.config_path}", severity="info")
425
-
426
- def on_initialization_loading_screen_loading_cancelled(
427
- self, message: InitializationLoadingScreen.LoadingCancelled
428
- ) -> None:
429
- """
430
- Handle cancellation of initialization loading.
431
-
432
- WHY: If user cancels loading, we should still show the main screen
433
- but without the loaded configuration. Using pop_screen and push_screen
434
- for proper screen stack management.
435
- """
436
- # Mark initialization as complete (even if cancelled)
437
- self.initialization_complete = True
438
-
439
- # Transition to main screen without configuration
440
- with contextlib.suppress(Exception):
441
- self.pop_screen() # Remove loading screen
442
-
443
- main_screen = MainScreen(None, None, id="main-screen")
444
- self.push_screen(main_screen)
445
-
446
- self.notify(
447
- "Initialization cancelled. You can load configuration manually.", severity="warning"
448
- )
449
-
450
- def on_main_screen_new_analysis_requested(
451
- self, message: MainScreen.NewAnalysisRequested
452
- ) -> None:
453
- """Handle new analysis request from main screen."""
454
- if not self.config:
455
- self.notify("Please load or create a configuration first", severity="error")
456
- return
457
-
458
- # Launch analysis progress screen
459
- analysis_screen = AnalysisProgressScreen(
460
- config=self.config,
461
- weeks=getattr(self, "default_weeks", 12), # Use CLI parameter or default to 12
462
- enable_qualitative=getattr(self.config, "qualitative", None)
463
- and self.config.qualitative.enabled,
464
- )
465
-
466
- self.push_screen(analysis_screen)
467
-
468
- def on_main_screen_configuration_requested(
469
- self, message: MainScreen.ConfigurationRequested
470
- ) -> None:
471
- """Handle configuration request from main screen."""
472
- config_screen = ConfigurationScreen(config_path=self.config_path, config=self.config)
473
-
474
- def handle_config_result(config: Optional[Config]) -> None:
475
- if config:
476
- self.config = config
477
- # TODO: Set config_path if provided
478
-
479
- # Update main screen
480
- main_screen = self.query_one("#main-screen", MainScreen)
481
- main_screen.update_config(self.config, self.config_path)
482
-
483
- self.notify("Configuration updated successfully", severity="success")
484
-
485
- self.push_screen(config_screen, handle_config_result)
486
-
487
- def on_main_screen_cache_status_requested(
488
- self, message: MainScreen.CacheStatusRequested
489
- ) -> None:
490
- """Handle cache status request from main screen."""
491
- if not self.config:
492
- self.notify("No configuration loaded", severity="error")
493
- return
494
-
495
- try:
496
- from gitflow_analytics.core.cache import GitAnalysisCache
497
-
498
- cache = GitAnalysisCache(self.config.cache.directory)
499
- stats = cache.get_cache_stats()
500
-
501
- # Calculate cache size
502
- import os
503
-
504
- cache_size = 0
505
- try:
506
- for root, _dirs, files in os.walk(self.config.cache.directory):
507
- for f in files:
508
- cache_size += os.path.getsize(os.path.join(root, f))
509
- cache_size_mb = cache_size / 1024 / 1024
510
- except Exception:
511
- cache_size_mb = 0
512
-
513
- message_text = f"""Cache Statistics:
514
- • Location: {self.config.cache.directory}
515
- • Cached commits: {stats['cached_commits']:,}
516
- • Cached PRs: {stats['cached_prs']:,}
517
- • Cached issues: {stats['cached_issues']:,}
518
- • Stale entries: {stats['stale_commits']:,}
519
- • Cache size: {cache_size_mb:.1f} MB
520
- • TTL: {self.config.cache.ttl_hours} hours"""
521
-
522
- self.notify(message_text, severity="info")
523
-
524
- except Exception as e:
525
- self.notify(f"Failed to get cache statistics: {e}", severity="error")
526
-
527
- def on_main_screen_identity_management_requested(
528
- self, message: MainScreen.IdentityManagementRequested
529
- ) -> None:
530
- """Handle identity management request from main screen."""
531
- if not self.config:
532
- self.notify("No configuration loaded", severity="error")
533
- return
534
-
535
- try:
536
- from gitflow_analytics.core.identity import DeveloperIdentityResolver
537
-
538
- identity_resolver = DeveloperIdentityResolver(
539
- self.config.cache.directory / "identities.db"
540
- )
541
-
542
- developers = identity_resolver.get_developer_stats()
543
-
544
- if not developers:
545
- self.notify("No developer identities found. Run analysis first.", severity="info")
546
- return
547
-
548
- # Show top developers
549
- top_devs = sorted(developers, key=lambda d: d["total_commits"], reverse=True)[:10]
550
-
551
- dev_list = []
552
- for dev in top_devs:
553
- dev_list.append(
554
- f"• {dev['primary_name']}: {dev['total_commits']} commits, {dev['alias_count']} aliases"
555
- )
556
-
557
- message_text = f"""Developer Identity Statistics:
558
- • Total unique developers: {len(developers)}
559
- • Manual mappings: {len(self.config.analysis.manual_identity_mappings) if self.config.analysis.manual_identity_mappings else 0}
560
-
561
- Top Contributors:
562
- {chr(10).join(dev_list)}
563
-
564
- Use the CLI 'merge-identity' command to merge duplicate identities."""
565
-
566
- self.notify(message_text, severity="info")
567
-
568
- except Exception as e:
569
- self.notify(f"Failed to get identity information: {e}", severity="error")
570
-
571
- def on_main_screen_help_requested(self, message: MainScreen.HelpRequested) -> None:
572
- """Handle help request from main screen."""
573
- help_text = """GitFlow Analytics - Terminal UI Help
574
-
575
- 🚀 Getting Started:
576
- 1. Load or create a configuration file (API keys, repositories)
577
- 2. Configure analysis settings (time period, options)
578
- 3. Run analysis to process your repositories
579
- 4. Explore results and export reports
580
-
581
- ⌨️ Key Bindings:
582
- • Ctrl+Q / Ctrl+C: Quit application
583
- • F1: Show this help
584
- • Ctrl+D: Toggle dark/light mode
585
- • Escape: Go back/cancel current action
586
-
587
- 📁 Configuration:
588
- • GitHub Personal Access Token required
589
- • OpenRouter API key for qualitative analysis
590
- • JIRA optional for enhanced ticket tracking
591
-
592
- 🔧 Analysis Features:
593
- • Git commit analysis with developer identity resolution
594
- • Pull request metrics and timing analysis
595
- • Qualitative analysis using AI for commit categorization
596
- • DORA metrics calculation
597
- • Story point tracking from commit messages
598
-
599
- 📊 Export Options:
600
- • CSV reports for spreadsheet analysis
601
- • JSON export for API integration
602
- • Markdown reports for documentation
603
-
604
- 💡 Tips:
605
- • Use organization auto-discovery for multiple repositories
606
- • Enable qualitative analysis for deeper insights
607
- • Review identity mappings for accurate attribution
608
-
609
- For more information: https://github.com/bobmatnyc/gitflow-analytics"""
610
-
611
- self.notify(help_text, severity="info")
612
-
613
- def action_quit(self) -> None:
614
- """
615
- Quit the application with confirmation if analysis is running.
616
-
617
- WHY: Provides safe exit that checks for running operations to
618
- prevent data loss or corruption from incomplete analysis.
619
- """
620
- # Check if analysis is running
621
- try:
622
- analysis_screen = self.query("AnalysisProgressScreen")
623
- if analysis_screen:
624
- # TODO: Show confirmation dialog
625
- pass
626
- except Exception:
627
- pass
628
-
629
- self.exit()
630
-
631
- def action_help(self) -> None:
632
- """Show application help."""
633
- # Trigger help from main screen if available
634
- try:
635
- main_screen = self.query_one("#main-screen", MainScreen)
636
- main_screen.action_help()
637
- except Exception:
638
- # Fallback to direct help
639
- self.on_main_screen_help_requested(MainScreen.HelpRequested())
640
-
641
- def action_toggle_dark(self) -> None:
642
- """
643
- Toggle between dark and light themes.
644
-
645
- WHY: Provides theme flexibility for different user preferences
646
- and working environments (bright vs dim lighting conditions).
647
- """
648
- self.dark = not self.dark
649
- theme = "dark" if self.dark else "light"
650
- self.notify(f"Switched to {theme} theme", severity="info")
651
-
652
- def get_current_config(self) -> Optional[Config]:
653
- """Get the currently loaded configuration."""
654
- return self.config
655
-
656
- def get_current_config_path(self) -> Optional[Path]:
657
- """Get the path of the currently loaded configuration."""
658
- return self.config_path
659
-
660
- def get_nlp_engine(self) -> Optional[object]:
661
- """
662
- Get the initialized NLP engine for qualitative analysis.
663
-
664
- WHY: Provides access to the pre-loaded NLP engine to avoid
665
- re-initialization overhead during analysis operations.
666
-
667
- @return: Initialized NLP engine or None if not available
668
- """
669
- return self._nlp_engine
670
-
671
- def update_config(self, config: Config, config_path: Optional[Path] = None) -> None:
672
- """
673
- Update the application configuration and refresh relevant screens.
674
-
675
- WHY: Provides centralized configuration updates that can be called
676
- from any screen to ensure all parts of the application stay in sync.
677
- """
678
- self.config = config
679
- self.config_path = config_path
680
-
681
- # Update main screen if visible
682
- try:
683
- main_screen = self.query_one("#main-screen", MainScreen)
684
- main_screen.update_config(config, config_path)
685
- except Exception:
686
- pass
687
-
688
- async def run_analysis_async(
689
- self, weeks: int = 12, enable_qualitative: bool = True
690
- ) -> Optional[dict]:
691
- """
692
- Run analysis asynchronously and return results.
693
-
694
- WHY: Provides programmatic access to analysis functionality
695
- for integration with other systems or automated workflows.
696
-
697
- @param weeks: Number of weeks to analyze
698
- @param enable_qualitative: Whether to enable qualitative analysis
699
- @return: Analysis results dictionary or None if failed
700
- """
701
- if not self.config:
702
- raise ValueError("No configuration loaded")
703
-
704
- # Create analysis screen
705
- AnalysisProgressScreen(
706
- config=self.config, weeks=weeks, enable_qualitative=enable_qualitative
707
- )
708
-
709
- # Run analysis (this would need to be implemented properly)
710
- # For now, return None to indicate not implemented
711
- return None
712
-
713
-
714
- def main() -> None:
715
- """
716
- Main entry point for the TUI application.
717
-
718
- WHY: Provides a clean entry point that can be called from the CLI
719
- or used as a standalone application launcher.
720
- """
721
- app = GitFlowAnalyticsApp()
722
- app.run()
723
-
724
-
725
- if __name__ == "__main__":
726
- main()