gitflow-analytics 1.0.1__py3-none-any.whl → 1.3.6__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.
- gitflow_analytics/__init__.py +11 -11
- gitflow_analytics/_version.py +2 -2
- gitflow_analytics/classification/__init__.py +31 -0
- gitflow_analytics/classification/batch_classifier.py +752 -0
- gitflow_analytics/classification/classifier.py +464 -0
- gitflow_analytics/classification/feature_extractor.py +725 -0
- gitflow_analytics/classification/linguist_analyzer.py +574 -0
- gitflow_analytics/classification/model.py +455 -0
- gitflow_analytics/cli.py +4490 -378
- gitflow_analytics/cli_rich.py +503 -0
- gitflow_analytics/config/__init__.py +43 -0
- gitflow_analytics/config/errors.py +261 -0
- gitflow_analytics/config/loader.py +904 -0
- gitflow_analytics/config/profiles.py +264 -0
- gitflow_analytics/config/repository.py +124 -0
- gitflow_analytics/config/schema.py +441 -0
- gitflow_analytics/config/validator.py +154 -0
- gitflow_analytics/config.py +44 -398
- gitflow_analytics/core/analyzer.py +1320 -172
- gitflow_analytics/core/branch_mapper.py +132 -132
- gitflow_analytics/core/cache.py +1554 -175
- gitflow_analytics/core/data_fetcher.py +1193 -0
- gitflow_analytics/core/identity.py +571 -185
- gitflow_analytics/core/metrics_storage.py +526 -0
- gitflow_analytics/core/progress.py +372 -0
- gitflow_analytics/core/schema_version.py +269 -0
- gitflow_analytics/extractors/base.py +13 -11
- gitflow_analytics/extractors/ml_tickets.py +1100 -0
- gitflow_analytics/extractors/story_points.py +77 -59
- gitflow_analytics/extractors/tickets.py +841 -89
- gitflow_analytics/identity_llm/__init__.py +6 -0
- gitflow_analytics/identity_llm/analysis_pass.py +231 -0
- gitflow_analytics/identity_llm/analyzer.py +464 -0
- gitflow_analytics/identity_llm/models.py +76 -0
- gitflow_analytics/integrations/github_integration.py +258 -87
- gitflow_analytics/integrations/jira_integration.py +572 -123
- gitflow_analytics/integrations/orchestrator.py +206 -82
- gitflow_analytics/metrics/activity_scoring.py +322 -0
- gitflow_analytics/metrics/branch_health.py +470 -0
- gitflow_analytics/metrics/dora.py +542 -179
- gitflow_analytics/models/database.py +986 -59
- gitflow_analytics/pm_framework/__init__.py +115 -0
- gitflow_analytics/pm_framework/adapters/__init__.py +50 -0
- gitflow_analytics/pm_framework/adapters/jira_adapter.py +1845 -0
- gitflow_analytics/pm_framework/base.py +406 -0
- gitflow_analytics/pm_framework/models.py +211 -0
- gitflow_analytics/pm_framework/orchestrator.py +652 -0
- gitflow_analytics/pm_framework/registry.py +333 -0
- gitflow_analytics/qualitative/__init__.py +29 -0
- gitflow_analytics/qualitative/chatgpt_analyzer.py +259 -0
- gitflow_analytics/qualitative/classifiers/__init__.py +13 -0
- gitflow_analytics/qualitative/classifiers/change_type.py +742 -0
- gitflow_analytics/qualitative/classifiers/domain_classifier.py +506 -0
- gitflow_analytics/qualitative/classifiers/intent_analyzer.py +535 -0
- gitflow_analytics/qualitative/classifiers/llm/__init__.py +35 -0
- gitflow_analytics/qualitative/classifiers/llm/base.py +193 -0
- gitflow_analytics/qualitative/classifiers/llm/batch_processor.py +383 -0
- gitflow_analytics/qualitative/classifiers/llm/cache.py +479 -0
- gitflow_analytics/qualitative/classifiers/llm/cost_tracker.py +435 -0
- gitflow_analytics/qualitative/classifiers/llm/openai_client.py +403 -0
- gitflow_analytics/qualitative/classifiers/llm/prompts.py +373 -0
- gitflow_analytics/qualitative/classifiers/llm/response_parser.py +287 -0
- gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +607 -0
- gitflow_analytics/qualitative/classifiers/risk_analyzer.py +438 -0
- gitflow_analytics/qualitative/core/__init__.py +13 -0
- gitflow_analytics/qualitative/core/llm_fallback.py +657 -0
- gitflow_analytics/qualitative/core/nlp_engine.py +382 -0
- gitflow_analytics/qualitative/core/pattern_cache.py +479 -0
- gitflow_analytics/qualitative/core/processor.py +673 -0
- gitflow_analytics/qualitative/enhanced_analyzer.py +2236 -0
- gitflow_analytics/qualitative/example_enhanced_usage.py +420 -0
- gitflow_analytics/qualitative/models/__init__.py +25 -0
- gitflow_analytics/qualitative/models/schemas.py +306 -0
- gitflow_analytics/qualitative/utils/__init__.py +13 -0
- gitflow_analytics/qualitative/utils/batch_processor.py +339 -0
- gitflow_analytics/qualitative/utils/cost_tracker.py +345 -0
- gitflow_analytics/qualitative/utils/metrics.py +361 -0
- gitflow_analytics/qualitative/utils/text_processing.py +285 -0
- gitflow_analytics/reports/__init__.py +100 -0
- gitflow_analytics/reports/analytics_writer.py +550 -18
- gitflow_analytics/reports/base.py +648 -0
- gitflow_analytics/reports/branch_health_writer.py +322 -0
- gitflow_analytics/reports/classification_writer.py +924 -0
- gitflow_analytics/reports/cli_integration.py +427 -0
- gitflow_analytics/reports/csv_writer.py +1700 -216
- gitflow_analytics/reports/data_models.py +504 -0
- gitflow_analytics/reports/database_report_generator.py +427 -0
- gitflow_analytics/reports/example_usage.py +344 -0
- gitflow_analytics/reports/factory.py +499 -0
- gitflow_analytics/reports/formatters.py +698 -0
- gitflow_analytics/reports/html_generator.py +1116 -0
- gitflow_analytics/reports/interfaces.py +489 -0
- gitflow_analytics/reports/json_exporter.py +2770 -0
- gitflow_analytics/reports/narrative_writer.py +2289 -158
- gitflow_analytics/reports/story_point_correlation.py +1144 -0
- gitflow_analytics/reports/weekly_trends_writer.py +389 -0
- gitflow_analytics/training/__init__.py +5 -0
- gitflow_analytics/training/model_loader.py +377 -0
- gitflow_analytics/training/pipeline.py +550 -0
- gitflow_analytics/tui/__init__.py +5 -0
- gitflow_analytics/tui/app.py +724 -0
- gitflow_analytics/tui/screens/__init__.py +8 -0
- gitflow_analytics/tui/screens/analysis_progress_screen.py +496 -0
- gitflow_analytics/tui/screens/configuration_screen.py +523 -0
- gitflow_analytics/tui/screens/loading_screen.py +348 -0
- gitflow_analytics/tui/screens/main_screen.py +321 -0
- gitflow_analytics/tui/screens/results_screen.py +722 -0
- gitflow_analytics/tui/widgets/__init__.py +7 -0
- gitflow_analytics/tui/widgets/data_table.py +255 -0
- gitflow_analytics/tui/widgets/export_modal.py +301 -0
- gitflow_analytics/tui/widgets/progress_widget.py +187 -0
- gitflow_analytics-1.3.6.dist-info/METADATA +1015 -0
- gitflow_analytics-1.3.6.dist-info/RECORD +122 -0
- gitflow_analytics-1.0.1.dist-info/METADATA +0 -463
- gitflow_analytics-1.0.1.dist-info/RECORD +0 -31
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/WHEEL +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,7 @@
|
|
|
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"]
|
|
@@ -0,0 +1,255 @@
|
|
|
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)
|
|
@@ -0,0 +1,301 @@
|
|
|
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}"
|