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,7 +0,0 @@
1
- """Custom widgets for the GitFlow Analytics TUI."""
2
-
3
- from .data_table import EnhancedDataTable
4
- from .export_modal import ExportModal
5
- from .progress_widget import AnalysisProgressWidget
6
-
7
- __all__ = ["AnalysisProgressWidget", "EnhancedDataTable", "ExportModal"]
@@ -1,255 +0,0 @@
1
- """Enhanced data table widget for GitFlow Analytics TUI."""
2
-
3
- from datetime import datetime
4
- from typing import Any, Optional, Union
5
-
6
- from textual.reactive import reactive
7
- from textual.widgets import DataTable
8
-
9
-
10
- class EnhancedDataTable(DataTable):
11
- """
12
- Enhanced data table with sorting, filtering, and formatting capabilities.
13
-
14
- WHY: The standard DataTable widget lacks features needed for displaying
15
- complex analytics data like sorting, filtering, and intelligent formatting
16
- of different data types (dates, numbers, etc.).
17
-
18
- DESIGN DECISION: Extends DataTable rather than creating from scratch to
19
- maintain compatibility with Textual's data table features while adding
20
- the necessary enhancements for analytics display.
21
- """
22
-
23
- DEFAULT_CSS = """
24
- EnhancedDataTable {
25
- height: auto;
26
- }
27
-
28
- EnhancedDataTable > .datatable--header {
29
- background: $primary 10%;
30
- color: $primary;
31
- text-style: bold;
32
- }
33
-
34
- EnhancedDataTable > .datatable--row-hover {
35
- background: $accent 20%;
36
- }
37
-
38
- EnhancedDataTable > .datatable--row-cursor {
39
- background: $primary 30%;
40
- }
41
- """
42
-
43
- # Reactive attributes for dynamic updates
44
- sort_column = reactive("")
45
- sort_reverse = reactive(False)
46
- filter_text = reactive("")
47
-
48
- def __init__(
49
- self,
50
- data: Optional[list[dict[str, Any]]] = None,
51
- *,
52
- name: Optional[str] = None,
53
- id: Optional[str] = None,
54
- classes: Optional[str] = None,
55
- ) -> None:
56
- super().__init__(name=name, id=id, classes=classes)
57
- self._raw_data = data or []
58
- self._filtered_data = []
59
- self._column_formatters = {}
60
-
61
- def set_data(self, data: list[dict[str, Any]]) -> None:
62
- """
63
- Set table data with automatic column detection and formatting.
64
-
65
- WHY: Automatically handles different data types and formats them
66
- appropriately for display, reducing the need for manual formatting
67
- in calling code.
68
- """
69
- self._raw_data = data
70
- if not data:
71
- return
72
-
73
- # Clear existing data
74
- self.clear()
75
-
76
- # Get columns from first row
77
- columns = list(data[0].keys())
78
-
79
- # Add columns with appropriate widths
80
- for col in columns:
81
- width = self._calculate_column_width(col, data)
82
- self.add_column(self._format_column_header(col), width=width, key=col)
83
-
84
- # Set up formatters based on data types
85
- self._setup_formatters(data)
86
-
87
- # Add data rows
88
- self._apply_filter_and_sort()
89
-
90
- def _calculate_column_width(self, column: str, data: list[dict[str, Any]]) -> int:
91
- """
92
- Calculate appropriate column width based on content.
93
-
94
- WHY: Dynamically sizes columns based on actual content to optimize
95
- display space while ensuring all content is visible.
96
- """
97
- # Start with header width
98
- max_width = len(self._format_column_header(column))
99
-
100
- # Check sample of data for width
101
- sample_size = min(50, len(data))
102
- for row in data[:sample_size]:
103
- value = row.get(column, "")
104
- formatted_value = self._format_cell_value(column, value)
105
- max_width = max(max_width, len(str(formatted_value)))
106
-
107
- # Set reasonable bounds
108
- return min(max(max_width + 2, 8), 50)
109
-
110
- def _format_column_header(self, column: str) -> str:
111
- """Format column header for display."""
112
- # Convert snake_case to Title Case
113
- return column.replace("_", " ").title()
114
-
115
- def _setup_formatters(self, data: list[dict[str, Any]]) -> None:
116
- """
117
- Set up column formatters based on data types.
118
-
119
- WHY: Automatically detects data types and applies appropriate formatting
120
- (dates, numbers, percentages) to improve readability.
121
- """
122
- if not data:
123
- return
124
-
125
- sample_row = data[0]
126
-
127
- for column, value in sample_row.items():
128
- if isinstance(value, datetime):
129
- self._column_formatters[column] = self._format_datetime
130
- elif isinstance(value, (int, float)):
131
- # Check if it looks like a percentage
132
- if any(
133
- "pct" in column.lower()
134
- or "percent" in column.lower()
135
- or "rate" in column.lower()
136
- for _ in [column]
137
- ):
138
- self._column_formatters[column] = self._format_percentage
139
- else:
140
- self._column_formatters[column] = self._format_number
141
- elif isinstance(value, str) and len(value) > 50:
142
- self._column_formatters[column] = self._format_long_text
143
-
144
- def _format_cell_value(self, column: str, value: Any) -> str:
145
- """Format individual cell value."""
146
- if value is None:
147
- return ""
148
-
149
- formatter = self._column_formatters.get(column, str)
150
- return formatter(value)
151
-
152
- def _format_datetime(self, value: datetime) -> str:
153
- """Format datetime values."""
154
- if isinstance(value, str):
155
- try:
156
- value = datetime.fromisoformat(value.replace("Z", "+00:00"))
157
- except Exception:
158
- return str(value)
159
- return value.strftime("%Y-%m-%d %H:%M")
160
-
161
- def _format_number(self, value: Union[int, float]) -> str:
162
- """Format numeric values."""
163
- if isinstance(value, float):
164
- if value >= 1000:
165
- return f"{value:,.1f}"
166
- else:
167
- return f"{value:.2f}"
168
- return f"{value:,}"
169
-
170
- def _format_percentage(self, value: Union[int, float]) -> str:
171
- """Format percentage values."""
172
- return f"{value:.1f}%"
173
-
174
- def _format_long_text(self, value: str) -> str:
175
- """Format long text values."""
176
- if len(value) > 47:
177
- return value[:44] + "..."
178
- return value
179
-
180
- def _apply_filter_and_sort(self) -> None:
181
- """
182
- Apply current filter and sort settings to data.
183
-
184
- WHY: Provides real-time filtering and sorting capabilities that are
185
- essential for exploring large datasets in the analytics results.
186
- """
187
- # Start with all data
188
- filtered_data = self._raw_data.copy()
189
-
190
- # Apply filter if set
191
- if self.filter_text:
192
- filtered_data = [
193
- row
194
- for row in filtered_data
195
- if any(self.filter_text.lower() in str(value).lower() for value in row.values())
196
- ]
197
-
198
- # Apply sort if set
199
- if self.sort_column and self.sort_column in (filtered_data[0] if filtered_data else {}):
200
- try:
201
- filtered_data.sort(
202
- key=lambda x: x.get(self.sort_column, ""), reverse=self.sort_reverse
203
- )
204
- except TypeError:
205
- # Handle mixed types by converting to string
206
- filtered_data.sort(
207
- key=lambda x: str(x.get(self.sort_column, "")), reverse=self.sort_reverse
208
- )
209
-
210
- self._filtered_data = filtered_data
211
-
212
- # Clear and repopulate table
213
- self.clear(columns=False) # Keep columns, clear rows
214
-
215
- for row in filtered_data:
216
- formatted_row = [self._format_cell_value(col, row.get(col, "")) for col in row]
217
- self.add_row(*formatted_row)
218
-
219
- def sort_by_column(self, column: str, reverse: bool = False) -> None:
220
- """Sort table by specified column."""
221
- self.sort_column = column
222
- self.sort_reverse = reverse
223
- self._apply_filter_and_sort()
224
-
225
- def filter_data(self, filter_text: str) -> None:
226
- """Filter table data by text search."""
227
- self.filter_text = filter_text
228
- self._apply_filter_and_sort()
229
-
230
- def export_to_csv(self, filename: str) -> None:
231
- """
232
- Export current filtered/sorted data to CSV.
233
-
234
- WHY: Allows users to export the specific view they've filtered and
235
- sorted, maintaining their exploration context.
236
- """
237
- import csv
238
-
239
- if not self._filtered_data:
240
- return
241
-
242
- with open(filename, "w", newline="", encoding="utf-8") as csvfile:
243
- if self._filtered_data:
244
- fieldnames = list(self._filtered_data[0].keys())
245
- writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
246
- writer.writeheader()
247
- writer.writerows(self._filtered_data)
248
-
249
- def get_row_count(self) -> int:
250
- """Get number of rows currently displayed."""
251
- return len(self._filtered_data)
252
-
253
- def get_total_count(self) -> int:
254
- """Get total number of rows in dataset."""
255
- return len(self._raw_data)
@@ -1,301 +0,0 @@
1
- """Export modal dialog for GitFlow Analytics TUI."""
2
-
3
- from pathlib import Path
4
- from typing import Any, Optional
5
-
6
- from textual.binding import Binding
7
- from textual.containers import Container, Horizontal
8
- from textual.message import Message
9
- from textual.screen import ModalScreen
10
- from textual.widgets import Button, Input, Label, Select, Static, Switch
11
-
12
-
13
- class ExportModal(ModalScreen[Optional[dict[str, Any]]]):
14
- """
15
- Modal dialog for configuring and executing data exports.
16
-
17
- WHY: Provides a comprehensive export interface that allows users to
18
- choose format, location, and export options without cluttering the
19
- main interface. Modal design ensures focused interaction.
20
-
21
- DESIGN DECISION: Returns export configuration as a dictionary rather
22
- than executing export directly, allowing the calling code to handle
23
- the actual export operation with proper error handling and progress feedback.
24
- """
25
-
26
- DEFAULT_CSS = """
27
- ExportModal {
28
- align: center middle;
29
- }
30
-
31
- #export-dialog {
32
- background: $surface;
33
- border: thick $primary;
34
- width: 80;
35
- height: 25;
36
- padding: 1;
37
- }
38
-
39
- .modal-title {
40
- text-align: center;
41
- text-style: bold;
42
- color: $primary;
43
- margin-bottom: 1;
44
- }
45
-
46
- .section-title {
47
- text-style: bold;
48
- color: $secondary;
49
- margin: 1 0;
50
- }
51
-
52
- .button-bar {
53
- dock: bottom;
54
- height: 3;
55
- align: center middle;
56
- }
57
-
58
- .form-row {
59
- height: 3;
60
- margin: 1 0;
61
- }
62
-
63
- .form-label {
64
- width: 20;
65
- padding: 1 0;
66
- }
67
- """
68
-
69
- BINDINGS = [
70
- Binding("escape", "cancel", "Cancel"),
71
- Binding("ctrl+s", "export", "Export"),
72
- ]
73
-
74
- class ExportRequested(Message):
75
- """Message sent when export is requested."""
76
-
77
- def __init__(self, config: dict[str, Any]) -> None:
78
- super().__init__()
79
- self.config = config
80
-
81
- def __init__(
82
- self,
83
- available_formats: Optional[list[str]] = None,
84
- default_path: Optional[Path] = None,
85
- data_info: Optional[dict[str, Any]] = None,
86
- ) -> None:
87
- super().__init__()
88
- self.available_formats = available_formats or ["CSV", "JSON", "Markdown"]
89
- self.default_path = default_path or Path("./reports")
90
- self.data_info = data_info or {}
91
-
92
- def compose(self):
93
- """Compose the export modal dialog."""
94
- with Container(id="export-dialog"):
95
- yield Label("Export Data", classes="modal-title")
96
-
97
- # Format selection
98
- yield Label("Export Format:", classes="section-title")
99
- format_options = [(fmt, fmt.lower()) for fmt in self.available_formats]
100
- yield Select(format_options, value=format_options[0][1], id="format-select")
101
-
102
- # File path
103
- yield Label("Export Location:", classes="section-title")
104
- with Horizontal(classes="form-row"):
105
- yield Label("Directory:", classes="form-label")
106
- yield Input(
107
- value=str(self.default_path),
108
- placeholder="Path to export directory",
109
- id="path-input",
110
- )
111
-
112
- with Horizontal(classes="form-row"):
113
- yield Label("Filename:", classes="form-label")
114
- yield Input(
115
- value=self._generate_default_filename(),
116
- placeholder="Export filename",
117
- id="filename-input",
118
- )
119
-
120
- # Export options
121
- yield Label("Export Options:", classes="section-title")
122
-
123
- with Horizontal(classes="form-row"):
124
- yield Label("Include headers:", classes="form-label")
125
- yield Switch(value=True, id="include-headers")
126
-
127
- with Horizontal(classes="form-row"):
128
- yield Label("Anonymize data:", classes="form-label")
129
- yield Switch(value=False, id="anonymize-data")
130
-
131
- # Data info
132
- if self.data_info:
133
- yield Label("Data Summary:", classes="section-title")
134
- info_text = self._format_data_info()
135
- yield Static(info_text, id="data-info")
136
-
137
- # Button bar
138
- with Horizontal(classes="button-bar"):
139
- yield Button("Cancel", variant="default", id="cancel-btn")
140
- yield Button("Export", variant="primary", id="export-btn")
141
-
142
- def _generate_default_filename(self) -> str:
143
- """
144
- Generate default filename based on export format and current date.
145
-
146
- WHY: Provides sensible defaults to reduce user input while ensuring
147
- unique filenames that won't accidentally overwrite existing files.
148
- """
149
- from datetime import datetime
150
-
151
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
152
- data_type = self.data_info.get("type", "export")
153
- return f"{data_type}_{timestamp}"
154
-
155
- def _format_data_info(self) -> str:
156
- """Format data information for display."""
157
- info_lines = []
158
-
159
- if "row_count" in self.data_info:
160
- info_lines.append(f"• Rows: {self.data_info['row_count']:,}")
161
-
162
- if "column_count" in self.data_info:
163
- info_lines.append(f"• Columns: {self.data_info['column_count']}")
164
-
165
- if "date_range" in self.data_info:
166
- info_lines.append(f"• Date range: {self.data_info['date_range']}")
167
-
168
- if "data_types" in self.data_info:
169
- types_str = ", ".join(self.data_info["data_types"])
170
- info_lines.append(f"• Data types: {types_str}")
171
-
172
- return "\n".join(info_lines)
173
-
174
- def on_button_pressed(self, event: Button.Pressed) -> None:
175
- """Handle button press events."""
176
- if event.button.id == "cancel-btn":
177
- self.action_cancel()
178
- elif event.button.id == "export-btn":
179
- self.action_export()
180
-
181
- def action_cancel(self) -> None:
182
- """Cancel the export operation."""
183
- self.dismiss(None)
184
-
185
- def action_export(self) -> None:
186
- """
187
- Validate inputs and request export operation.
188
-
189
- WHY: Performs comprehensive validation before submitting export
190
- request to prevent errors and provide immediate feedback to users.
191
- """
192
- try:
193
- # Collect form data
194
- format_select = self.query_one("#format-select", Select)
195
- path_input = self.query_one("#path-input", Input)
196
- filename_input = self.query_one("#filename-input", Input)
197
- include_headers = self.query_one("#include-headers", Switch)
198
- anonymize_data = self.query_one("#anonymize-data", Switch)
199
-
200
- # Validate inputs
201
- export_path = Path(path_input.value.strip())
202
- filename = filename_input.value.strip()
203
-
204
- if not filename:
205
- self.notify("Please enter a filename", severity="error")
206
- return
207
-
208
- # Add extension if not present
209
- selected_format = format_select.value
210
- if selected_format == "csv" and not filename.lower().endswith(".csv"):
211
- filename += ".csv"
212
- elif selected_format == "json" and not filename.lower().endswith(".json"):
213
- filename += ".json"
214
- elif selected_format == "markdown" and not filename.lower().endswith(".md"):
215
- filename += ".md"
216
-
217
- full_path = export_path / filename
218
-
219
- # Check if file exists
220
- if full_path.exists():
221
- # In a real implementation, you'd show a confirmation dialog
222
- # For now, we'll just proceed with overwrite warning
223
- pass
224
-
225
- # Create export configuration
226
- export_config = {
227
- "format": selected_format,
228
- "path": full_path,
229
- "include_headers": include_headers.value,
230
- "anonymize": anonymize_data.value,
231
- }
232
-
233
- # Send export request message
234
- self.post_message(self.ExportRequested(export_config))
235
- self.dismiss(export_config)
236
-
237
- except Exception as e:
238
- self.notify(f"Export configuration error: {e}", severity="error")
239
-
240
- def on_select_changed(self, event: Select.Changed) -> None:
241
- """Handle format selection changes."""
242
- if event.select.id == "format-select":
243
- # Update filename extension when format changes
244
- filename_input = self.query_one("#filename-input", Input)
245
- current_filename = filename_input.value
246
-
247
- # Remove existing extension
248
- name_without_ext = current_filename.rsplit(".", 1)[0]
249
-
250
- # Add new extension
251
- new_format = event.value
252
- if new_format == "csv":
253
- new_filename = f"{name_without_ext}.csv"
254
- elif new_format == "json":
255
- new_filename = f"{name_without_ext}.json"
256
- elif new_format == "markdown":
257
- new_filename = f"{name_without_ext}.md"
258
- else:
259
- new_filename = name_without_ext
260
-
261
- filename_input.value = new_filename
262
-
263
- def validate_export_path(self, path: Path) -> tuple[bool, str]:
264
- """
265
- Validate export path and return validation result.
266
-
267
- WHY: Prevents export failures by validating paths before attempting
268
- to write files, providing clear error messages to users.
269
-
270
- @param path: Path to validate
271
- @return: Tuple of (is_valid, error_message)
272
- """
273
- try:
274
- # Check if parent directory exists or can be created
275
- parent_dir = path.parent
276
- if not parent_dir.exists():
277
- try:
278
- parent_dir.mkdir(parents=True, exist_ok=True)
279
- except PermissionError:
280
- return False, f"Permission denied: Cannot create directory {parent_dir}"
281
- except Exception as e:
282
- return False, f"Cannot create directory {parent_dir}: {e}"
283
-
284
- # Check write permissions
285
- if not parent_dir.exists():
286
- return False, f"Directory does not exist: {parent_dir}"
287
-
288
- # Try creating a test file to check permissions
289
- test_file = parent_dir / f".test_write_{hash(str(path))}"
290
- try:
291
- test_file.touch()
292
- test_file.unlink()
293
- except PermissionError:
294
- return False, f"Permission denied: Cannot write to {parent_dir}"
295
- except Exception as e:
296
- return False, f"Cannot write to {parent_dir}: {e}"
297
-
298
- return True, ""
299
-
300
- except Exception as e:
301
- return False, f"Path validation error: {e}"