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