insyte 0.1.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 (122) hide show
  1. insyte/__init__.py +12 -0
  2. insyte/analytics/__init__.py +21 -0
  3. insyte/analytics/charts.py +67 -0
  4. insyte/analytics/comparison.py +64 -0
  5. insyte/analytics/engine.py +195 -0
  6. insyte/analytics/forecast.py +58 -0
  7. insyte/analytics/models.py +90 -0
  8. insyte/analytics/periods.py +46 -0
  9. insyte/analytics/segmentation.py +40 -0
  10. insyte/cli/__init__.py +1 -0
  11. insyte/cli/_project.py +43 -0
  12. insyte/cli/_stubs.py +42 -0
  13. insyte/cli/analyze_command.py +165 -0
  14. insyte/cli/app.py +84 -0
  15. insyte/cli/chat_command.py +68 -0
  16. insyte/cli/connect_command.py +147 -0
  17. insyte/cli/doctor_command.py +127 -0
  18. insyte/cli/history_command.py +89 -0
  19. insyte/cli/init_command.py +268 -0
  20. insyte/cli/mcp_command.py +150 -0
  21. insyte/cli/metrics_command.py +95 -0
  22. insyte/cli/profile_command.py +128 -0
  23. insyte/cli/query_command.py +115 -0
  24. insyte/cli/scan_command.py +101 -0
  25. insyte/cli/schema_command.py +189 -0
  26. insyte/cli/semantic_command.py +101 -0
  27. insyte/cli/status_command.py +88 -0
  28. insyte/cli/studio_command.py +110 -0
  29. insyte/cli/sync_command.py +187 -0
  30. insyte/config/__init__.py +1 -0
  31. insyte/config/loader.py +96 -0
  32. insyte/config/models.py +153 -0
  33. insyte/config/paths.py +119 -0
  34. insyte/config/secrets.py +82 -0
  35. insyte/connectors/__init__.py +19 -0
  36. insyte/connectors/base.py +96 -0
  37. insyte/connectors/duckdb.py +74 -0
  38. insyte/connectors/factory.py +37 -0
  39. insyte/connectors/postgres.py +284 -0
  40. insyte/exceptions.py +112 -0
  41. insyte/logging_config.py +131 -0
  42. insyte/main.py +15 -0
  43. insyte/mcp/__init__.py +6 -0
  44. insyte/mcp/installer.py +130 -0
  45. insyte/mcp/server.py +91 -0
  46. insyte/mcp/tools.py +331 -0
  47. insyte/metadata/__init__.py +6 -0
  48. insyte/metadata/classifier.py +89 -0
  49. insyte/metadata/models.py +523 -0
  50. insyte/metadata/pii_detector.py +106 -0
  51. insyte/metadata/profiler.py +209 -0
  52. insyte/metadata/relationship_detector.py +164 -0
  53. insyte/metadata/repository.py +681 -0
  54. insyte/metadata/scanner.py +256 -0
  55. insyte/nl/__init__.py +7 -0
  56. insyte/nl/llm.py +304 -0
  57. insyte/nl/periods.py +92 -0
  58. insyte/query/__init__.py +13 -0
  59. insyte/query/cost_guard.py +66 -0
  60. insyte/query/executor.py +171 -0
  61. insyte/query/generator.py +200 -0
  62. insyte/query/models.py +72 -0
  63. insyte/query/validator.py +300 -0
  64. insyte/semantic/__init__.py +28 -0
  65. insyte/semantic/generator.py +197 -0
  66. insyte/semantic/models.py +72 -0
  67. insyte/semantic/repository.py +34 -0
  68. insyte/semantic/validator.py +117 -0
  69. insyte/services/__init__.py +30 -0
  70. insyte/services/analysis_service.py +43 -0
  71. insyte/services/conversation_service.py +79 -0
  72. insyte/services/export_service.py +25 -0
  73. insyte/services/history_service.py +19 -0
  74. insyte/services/metric_service.py +37 -0
  75. insyte/services/project_service.py +83 -0
  76. insyte/services/schema_service.py +86 -0
  77. insyte/studio/__init__.py +5 -0
  78. insyte/studio/app.py +107 -0
  79. insyte/studio/dependencies.py +26 -0
  80. insyte/studio/events.py +271 -0
  81. insyte/studio/routes/__init__.py +1 -0
  82. insyte/studio/routes/analysis.py +102 -0
  83. insyte/studio/routes/conversations.py +87 -0
  84. insyte/studio/routes/exports.py +37 -0
  85. insyte/studio/routes/history.py +37 -0
  86. insyte/studio/routes/metrics.py +50 -0
  87. insyte/studio/routes/project.py +71 -0
  88. insyte/studio/routes/schema.py +77 -0
  89. insyte/studio/routes/sync.py +37 -0
  90. insyte/studio/schemas.py +244 -0
  91. insyte/studio/static.py +31 -0
  92. insyte/studio_dist/assets/app.css +276 -0
  93. insyte/studio_dist/assets/app.js +635 -0
  94. insyte/studio_dist/assets/logo-dark.png +0 -0
  95. insyte/studio_dist/assets/logo-light.png +0 -0
  96. insyte/studio_dist/index.html +15 -0
  97. insyte/tui/__init__.py +6 -0
  98. insyte/tui/app.py +27 -0
  99. insyte/tui/controller.py +310 -0
  100. insyte/tui/intent.py +176 -0
  101. insyte/tui/screens/__init__.py +1 -0
  102. insyte/tui/screens/chat.py +123 -0
  103. insyte/tui/styles/app.tcss +101 -0
  104. insyte/tui/widgets/__init__.py +1 -0
  105. insyte/tui/widgets/chart_panel.py +38 -0
  106. insyte/tui/widgets/prompt_input.py +15 -0
  107. insyte/tui/widgets/result_card.py +63 -0
  108. insyte/tui/widgets/sidebar.py +27 -0
  109. insyte/tui/widgets/sql_panel.py +18 -0
  110. insyte/tui/widgets/status_bar.py +15 -0
  111. insyte/tui/widgets/table_panel.py +38 -0
  112. insyte/warehouse/__init__.py +8 -0
  113. insyte/warehouse/duckdb_manager.py +75 -0
  114. insyte/warehouse/extractor.py +116 -0
  115. insyte/warehouse/model_builder.py +20 -0
  116. insyte/warehouse/sync_engine.py +129 -0
  117. insyte/warehouse/sync_state.py +38 -0
  118. insyte-0.1.0.dist-info/METADATA +363 -0
  119. insyte-0.1.0.dist-info/RECORD +122 -0
  120. insyte-0.1.0.dist-info/WHEEL +4 -0
  121. insyte-0.1.0.dist-info/entry_points.txt +2 -0
  122. insyte-0.1.0.dist-info/licenses/LICENSE +201 -0
insyte/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Insyte — local-first AI analytics over your database, safely.
2
+
3
+ Insyte connects to a database using read-only credentials and lets you analyse it
4
+ through Claude Code, Codex, other MCP clients, or its own terminal UI. AI models never
5
+ receive database credentials and can never bypass the SQL validation pipeline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = ["__version__"]
@@ -0,0 +1,21 @@
1
+ """Analytics: metric resolution, SQL generation, comparison, segmentation, and charts."""
2
+
3
+ from insyte.analytics.engine import AnalyticsEngine
4
+ from insyte.analytics.models import (
5
+ AnalysisKind,
6
+ AnalysisResult,
7
+ ChartType,
8
+ Period,
9
+ PeriodComparison,
10
+ TimeGrain,
11
+ )
12
+
13
+ __all__ = [
14
+ "AnalysisKind",
15
+ "AnalysisResult",
16
+ "AnalyticsEngine",
17
+ "ChartType",
18
+ "Period",
19
+ "PeriodComparison",
20
+ "TimeGrain",
21
+ ]
@@ -0,0 +1,67 @@
1
+ """Chart recommendation and value formatting (spec §20).
2
+
3
+ Chart selection is a pure function of the analysis kind and result shape; a chart is only
4
+ recommended when it genuinely helps (never for a single aggregate value).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from insyte.analytics.models import AnalysisKind, ChartSpec, ChartType
10
+ from insyte.semantic.models import MetricFormat
11
+
12
+ # Above this many segments, a horizontal bar reads better than a vertical one.
13
+ _HORIZONTAL_BAR_THRESHOLD = 6
14
+
15
+
16
+ def recommend_chart(
17
+ kind: AnalysisKind, columns: list[str], row_count: int, label: str
18
+ ) -> ChartSpec:
19
+ """Recommend a chart type for a result."""
20
+
21
+ if kind is AnalysisKind.timeseries and row_count >= 2:
22
+ return ChartSpec(ChartType.line, title=label, x_label=_col(columns, 0), y_label=label)
23
+ if kind is AnalysisKind.segment and row_count >= 1:
24
+ chart = ChartType.horizontal_bar if row_count > _HORIZONTAL_BAR_THRESHOLD else ChartType.bar
25
+ return ChartSpec(chart, title=label, x_label=_col(columns, 0), y_label=label)
26
+ return ChartSpec(ChartType.none, title=label)
27
+
28
+
29
+ def _col(columns: list[str], index: int) -> str | None:
30
+ return columns[index] if index < len(columns) else None
31
+
32
+
33
+ def format_value(value: object, fmt: MetricFormat) -> str:
34
+ """Format a scalar metric value for display."""
35
+
36
+ if value is None:
37
+ return "—"
38
+ number = _as_float(value)
39
+ if number is None:
40
+ return str(value)
41
+ if fmt is MetricFormat.percent:
42
+ return f"{number * 100:.1f}%"
43
+ if fmt is MetricFormat.currency:
44
+ return _compact_number(number, prefix="₹")
45
+ return _compact_number(number)
46
+
47
+
48
+ def _compact_number(number: float, prefix: str = "") -> str:
49
+ """Compact number in the Indian system: thousand (K), lakh (L), crore (Cr)."""
50
+
51
+ magnitude = abs(number)
52
+ if magnitude >= 10_000_000: # 1 crore = 1,00,00,000
53
+ return f"{prefix}{number / 10_000_000:.2f} Cr"
54
+ if magnitude >= 100_000: # 1 lakh = 1,00,000
55
+ return f"{prefix}{number / 100_000:.2f} L"
56
+ if magnitude >= 1_000:
57
+ return f"{prefix}{number / 1_000:.1f} K"
58
+ if number == int(number):
59
+ return f"{prefix}{int(number)}"
60
+ return f"{prefix}{number:.2f}"
61
+
62
+
63
+ def _as_float(value: object) -> float | None:
64
+ try:
65
+ return float(value) # type: ignore[arg-type]
66
+ except (TypeError, ValueError):
67
+ return None
@@ -0,0 +1,64 @@
1
+ """Period comparison: compute deltas between a current and a baseline value."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from insyte.analytics.charts import format_value
6
+ from insyte.analytics.models import Period, PeriodComparison
7
+ from insyte.semantic.models import Metric
8
+
9
+
10
+ def compute_comparison(
11
+ metric_name: str,
12
+ metric: Metric,
13
+ current: Period,
14
+ current_value: float | None,
15
+ baseline: Period,
16
+ baseline_value: float | None,
17
+ sql_current: str,
18
+ sql_baseline: str,
19
+ ) -> PeriodComparison:
20
+ """Assemble a :class:`PeriodComparison`, including absolute and percentage change."""
21
+
22
+ absolute: float | None = None
23
+ percent: float | None = None
24
+ if current_value is not None and baseline_value is not None:
25
+ absolute = current_value - baseline_value
26
+ percent = (absolute / baseline_value * 100.0) if baseline_value else None
27
+
28
+ return PeriodComparison(
29
+ metric=metric_name,
30
+ label=metric.label,
31
+ current=current,
32
+ baseline=baseline,
33
+ current_value=current_value,
34
+ baseline_value=baseline_value,
35
+ absolute_change=absolute,
36
+ percent_change=percent,
37
+ sql_current=sql_current,
38
+ sql_baseline=sql_baseline,
39
+ summary=_summarise(metric, current, baseline, current_value, baseline_value, percent),
40
+ )
41
+
42
+
43
+ def _summarise(
44
+ metric: Metric,
45
+ current: Period,
46
+ baseline: Period,
47
+ current_value: float | None,
48
+ baseline_value: float | None,
49
+ percent: float | None,
50
+ ) -> str:
51
+ if current_value is None or baseline_value is None:
52
+ return f"{metric.label}: not enough data to compare."
53
+ cur = format_value(current_value, metric.format)
54
+ base = format_value(baseline_value, metric.format)
55
+ if percent is None:
56
+ direction = "changed"
57
+ pct = ""
58
+ else:
59
+ direction = "increased" if percent >= 0 else "decreased"
60
+ pct = f" by {abs(percent):.1f}%"
61
+ return (
62
+ f"{metric.label} {direction}{pct}, from {base} ({baseline.label}) "
63
+ f"to {cur} ({current.label})."
64
+ )
@@ -0,0 +1,195 @@
1
+ """The analytics engine: turn a metric request into an executed, formatted result.
2
+
3
+ The engine resolves a metric (and optional dimension) from the semantic layer, generates safe
4
+ SQL, runs it through the Milestone 4 executor (validation + audit + read-only execution), then
5
+ formats the rows and recommends a chart.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+
12
+ from insyte.analytics.charts import format_value, recommend_chart
13
+ from insyte.analytics.comparison import compute_comparison
14
+ from insyte.analytics.models import (
15
+ AnalysisKind,
16
+ AnalysisResult,
17
+ Period,
18
+ PeriodComparison,
19
+ TimeGrain,
20
+ )
21
+ from insyte.analytics.segmentation import rank_contributors
22
+ from insyte.exceptions import DimensionNotFoundError, MetricNotFoundError
23
+ from insyte.metadata.models import RelationshipInfo
24
+ from insyte.query.executor import QueryExecutor
25
+ from insyte.query.generator import aggregate_sql, segment_sql, timeseries_sql
26
+ from insyte.query.models import ExecutionResult
27
+ from insyte.semantic.models import Dimension, Metric, SemanticLayer
28
+
29
+ _SOURCE = "analytics"
30
+
31
+
32
+ class AnalyticsEngine:
33
+ """Answer structured analytical questions against a project."""
34
+
35
+ def __init__(
36
+ self,
37
+ executor: QueryExecutor,
38
+ layer: SemanticLayer,
39
+ relationships: list[RelationshipInfo],
40
+ ) -> None:
41
+ self._executor = executor
42
+ self._layer = layer
43
+ self._relationships = relationships
44
+
45
+ # -- resolution --------------------------------------------------------------------------
46
+
47
+ def _metric(self, name: str) -> Metric:
48
+ metric = self._layer.metrics.get(name)
49
+ if metric is None:
50
+ raise MetricNotFoundError(name)
51
+ return metric
52
+
53
+ def _dimension(self, name: str) -> Dimension:
54
+ dimension = self._layer.dimensions.get(name)
55
+ if dimension is None:
56
+ raise DimensionNotFoundError(name)
57
+ return dimension
58
+
59
+ # -- analyses ----------------------------------------------------------------------------
60
+
61
+ def aggregate(self, metric_name: str, period: Period | None = None) -> AnalysisResult:
62
+ metric = self._metric(metric_name)
63
+ start, end = _bounds(period)
64
+ sql = aggregate_sql(metric, start, end)
65
+ execution = self._executor.execute(sql, source=_SOURCE)
66
+ value = _scalar(execution)
67
+ formatted = format_value(value, metric.format)
68
+ suffix = f" ({period.label})" if period else ""
69
+ return AnalysisResult(
70
+ kind=AnalysisKind.aggregate,
71
+ metric=metric_name,
72
+ label=metric.label,
73
+ columns=execution.columns,
74
+ rows=execution.rows,
75
+ formatted_rows=[[formatted]],
76
+ sql=execution.normalized_sql,
77
+ chart=recommend_chart(AnalysisKind.aggregate, execution.columns, 1, metric.label),
78
+ summary=f"{metric.label}: {formatted}{suffix}.",
79
+ row_count=execution.row_count,
80
+ duration_ms=execution.duration_ms,
81
+ )
82
+
83
+ def timeseries(
84
+ self, metric_name: str, grain: TimeGrain, period: Period | None = None
85
+ ) -> AnalysisResult:
86
+ metric = self._metric(metric_name)
87
+ start, end = _bounds(period)
88
+ sql = timeseries_sql(metric, grain.value, start, end)
89
+ execution = self._executor.execute(sql, source=_SOURCE)
90
+ formatted = _format_rows(execution.rows, value_index=1, metric=metric)
91
+ latest = format_value(execution.rows[-1][1], metric.format) if execution.rows else "—"
92
+ return AnalysisResult(
93
+ kind=AnalysisKind.timeseries,
94
+ metric=metric_name,
95
+ label=metric.label,
96
+ columns=execution.columns,
97
+ rows=execution.rows,
98
+ formatted_rows=formatted,
99
+ sql=execution.normalized_sql,
100
+ chart=recommend_chart(
101
+ AnalysisKind.timeseries, execution.columns, execution.row_count, metric.label
102
+ ),
103
+ summary=(
104
+ f"{metric.label} by {grain.value}: {execution.row_count} buckets; latest {latest}."
105
+ ),
106
+ row_count=execution.row_count,
107
+ duration_ms=execution.duration_ms,
108
+ )
109
+
110
+ def segment(
111
+ self,
112
+ metric_name: str,
113
+ dimension_name: str,
114
+ period: Period | None = None,
115
+ limit: int = 20,
116
+ ) -> AnalysisResult:
117
+ metric = self._metric(metric_name)
118
+ dimension = self._dimension(dimension_name)
119
+ start, end = _bounds(period)
120
+ sql = segment_sql(metric, dimension, self._relationships, limit=limit, start=start, end=end)
121
+ execution = self._executor.execute(sql, source=_SOURCE)
122
+ contributors = rank_contributors(execution.rows)
123
+ formatted = _format_rows(execution.rows, value_index=1, metric=metric)
124
+ summary = _segment_summary(metric, dimension_name, contributors)
125
+ return AnalysisResult(
126
+ kind=AnalysisKind.segment,
127
+ metric=metric_name,
128
+ label=metric.label,
129
+ columns=execution.columns,
130
+ rows=execution.rows,
131
+ formatted_rows=formatted,
132
+ sql=execution.normalized_sql,
133
+ chart=recommend_chart(
134
+ AnalysisKind.segment, execution.columns, execution.row_count, metric.label
135
+ ),
136
+ summary=summary,
137
+ row_count=execution.row_count,
138
+ duration_ms=execution.duration_ms,
139
+ contributors=contributors,
140
+ )
141
+
142
+ def compare(self, metric_name: str, current: Period, baseline: Period) -> PeriodComparison:
143
+ metric = self._metric(metric_name)
144
+ sql_current = aggregate_sql(metric, current.start, current.end)
145
+ sql_baseline = aggregate_sql(metric, baseline.start, baseline.end)
146
+ current_value = _scalar(self._executor.execute(sql_current, source=_SOURCE))
147
+ baseline_value = _scalar(self._executor.execute(sql_baseline, source=_SOURCE))
148
+ return compute_comparison(
149
+ metric_name,
150
+ metric,
151
+ current,
152
+ current_value,
153
+ baseline,
154
+ baseline_value,
155
+ sql_current,
156
+ sql_baseline,
157
+ )
158
+
159
+
160
+ def _bounds(period: Period | None) -> tuple[datetime | None, datetime | None]:
161
+ return (None, None) if period is None else (period.start, period.end)
162
+
163
+
164
+ def _scalar(result: ExecutionResult) -> float | None:
165
+ if not result.rows or not result.rows[0]:
166
+ return None
167
+ try:
168
+ return float(result.rows[0][0]) # type: ignore[arg-type]
169
+ except (TypeError, ValueError):
170
+ return None
171
+
172
+
173
+ def _format_rows(
174
+ rows: list[tuple[object, ...]], *, value_index: int, metric: Metric
175
+ ) -> list[list[str]]:
176
+ formatted: list[list[str]] = []
177
+ for row in rows:
178
+ cells = []
179
+ for index, cell in enumerate(row):
180
+ if index == value_index:
181
+ cells.append(format_value(cell, metric.format))
182
+ elif cell is None:
183
+ cells.append("—")
184
+ else:
185
+ cells.append(str(cell))
186
+ formatted.append(cells)
187
+ return formatted
188
+
189
+
190
+ def _segment_summary(metric: Metric, dimension: str, contributors: list) -> str:
191
+ if not contributors:
192
+ return f"{metric.label} by {dimension}: no data."
193
+ top = contributors[0]
194
+ value = format_value(top.value, metric.format)
195
+ return f"{metric.label} by {dimension}: '{top.segment}' leads with {value} ({top.share:.0%})."
@@ -0,0 +1,58 @@
1
+ """Deterministic year-end projection from real monthly actuals.
2
+
3
+ This is *not* a model guess — it is arithmetic on the metric's own history: completed months of
4
+ the current year plus a trailing run-rate for the months not yet elapsed. The result is always
5
+ labelled an estimate. No AI is involved; the LLM only recognises that the user asked for a
6
+ forecast and routes here, Insyte computes it from queried rows.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+
14
+ _RUN_RATE_MONTHS = 3
15
+
16
+
17
+ @dataclass
18
+ class YearProjection:
19
+ """A projected full-year total for a metric, with the inputs that produced it."""
20
+
21
+ year: int
22
+ ytd_actual: float # sum of completed months in the current year
23
+ projected_total: float # ytd_actual + run_rate * remaining months
24
+ run_rate: float # average of the last N completed months (any year)
25
+ complete_months: int # completed months of the current year
26
+ remaining_months: int # months not yet complete (incl. the current partial month)
27
+ basis_months: int # how many months the run-rate averaged over
28
+
29
+
30
+ def project_current_year(
31
+ points: list[tuple[datetime, float]], now: datetime
32
+ ) -> YearProjection | None:
33
+ """Project the current calendar year from monthly ``(period_start, value)`` actuals."""
34
+
35
+ monthly = sorted((d, v) for d, v in points if v is not None)
36
+ if not monthly:
37
+ return None
38
+
39
+ # "Completed" = months strictly before the current month (the current month is partial).
40
+ completed = [(d, v) for d, v in monthly if (d.year, d.month) < (now.year, now.month)]
41
+ ytd_actual = sum(v for d, v in completed if d.year == now.year)
42
+
43
+ basis = completed[-_RUN_RATE_MONTHS:]
44
+ run_rate = sum(v for _, v in basis) / len(basis) if basis else 0.0
45
+
46
+ complete_months = now.month - 1
47
+ remaining_months = 12 - complete_months # includes the current, still-incomplete month
48
+ projected_total = ytd_actual + run_rate * remaining_months
49
+
50
+ return YearProjection(
51
+ year=now.year,
52
+ ytd_actual=ytd_actual,
53
+ projected_total=projected_total,
54
+ run_rate=run_rate,
55
+ complete_months=complete_months,
56
+ remaining_months=remaining_months,
57
+ basis_months=len(basis),
58
+ )
@@ -0,0 +1,90 @@
1
+ """Data structures for analytical requests and results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+ from enum import StrEnum
8
+
9
+
10
+ class TimeGrain(StrEnum):
11
+ day = "day"
12
+ week = "week"
13
+ month = "month"
14
+ quarter = "quarter"
15
+ year = "year"
16
+
17
+
18
+ class AnalysisKind(StrEnum):
19
+ aggregate = "aggregate"
20
+ timeseries = "timeseries"
21
+ segment = "segment"
22
+ comparison = "comparison"
23
+
24
+
25
+ class ChartType(StrEnum):
26
+ line = "line"
27
+ bar = "bar"
28
+ horizontal_bar = "horizontal_bar"
29
+ pie = "pie"
30
+ scatter = "scatter"
31
+ none = "none"
32
+
33
+
34
+ @dataclass
35
+ class ChartSpec:
36
+ """A recommendation for how to chart a result (spec §20)."""
37
+
38
+ type: ChartType
39
+ title: str
40
+ x_label: str | None = None
41
+ y_label: str | None = None
42
+
43
+
44
+ @dataclass
45
+ class Period:
46
+ """A named, half-open time range [start, end)."""
47
+
48
+ label: str
49
+ start: datetime
50
+ end: datetime
51
+
52
+
53
+ @dataclass
54
+ class Contributor:
55
+ """A single segment's contribution to a metric."""
56
+
57
+ segment: str
58
+ value: float
59
+ share: float # fraction of the total, 0..1
60
+
61
+
62
+ @dataclass
63
+ class AnalysisResult:
64
+ kind: AnalysisKind
65
+ metric: str
66
+ label: str
67
+ columns: list[str]
68
+ rows: list[tuple[object, ...]]
69
+ formatted_rows: list[list[str]]
70
+ sql: str
71
+ chart: ChartSpec
72
+ summary: str
73
+ row_count: int
74
+ duration_ms: float
75
+ contributors: list[Contributor] = field(default_factory=list)
76
+
77
+
78
+ @dataclass
79
+ class PeriodComparison:
80
+ metric: str
81
+ label: str
82
+ current: Period
83
+ baseline: Period
84
+ current_value: float | None
85
+ baseline_value: float | None
86
+ absolute_change: float | None
87
+ percent_change: float | None
88
+ sql_current: str
89
+ sql_baseline: str
90
+ summary: str
@@ -0,0 +1,46 @@
1
+ """Compute current/previous period pairs for a time grain (for comparisons)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime, timedelta
6
+
7
+ from insyte.analytics.models import Period, TimeGrain
8
+
9
+
10
+ def periods_for_grain(grain: TimeGrain, *, now: datetime | None = None) -> tuple[Period, Period]:
11
+ """Return ``(current, previous)`` periods for the grain, relative to ``now`` (or UTC now)."""
12
+
13
+ now = now or datetime.now(UTC)
14
+ if grain is TimeGrain.day:
15
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0)
16
+ return _pair(start, timedelta(days=1), "%Y-%m-%d")
17
+ if grain is TimeGrain.week:
18
+ midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
19
+ start = midnight - timedelta(days=midnight.weekday())
20
+ return _pair(start, timedelta(weeks=1), "%Y-%m-%d")
21
+ if grain is TimeGrain.year:
22
+ start = datetime(now.year, 1, 1, tzinfo=UTC)
23
+ return (
24
+ Period(str(now.year), start, datetime(now.year + 1, 1, 1, tzinfo=UTC)),
25
+ Period(str(now.year - 1), datetime(now.year - 1, 1, 1, tzinfo=UTC), start),
26
+ )
27
+ months = 3 if grain is TimeGrain.quarter else 1
28
+ start = datetime(now.year, now.month, 1, tzinfo=UTC)
29
+ end = _add_months(start, months)
30
+ previous = _add_months(start, -months)
31
+ return (
32
+ Period(start.strftime("%b %Y"), start, end),
33
+ Period(previous.strftime("%b %Y"), previous, start),
34
+ )
35
+
36
+
37
+ def _pair(start: datetime, span: timedelta, fmt: str) -> tuple[Period, Period]:
38
+ return (
39
+ Period(start.strftime(fmt), start, start + span),
40
+ Period((start - span).strftime(fmt), start - span, start),
41
+ )
42
+
43
+
44
+ def _add_months(value: datetime, months: int) -> datetime:
45
+ index = value.month - 1 + months
46
+ return datetime(value.year + index // 12, index % 12 + 1, 1, tzinfo=UTC)
@@ -0,0 +1,40 @@
1
+ """Segmentation helpers: rank a metric's breakdown by contribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from insyte.analytics.models import Contributor
6
+
7
+
8
+ def rank_contributors(rows: list[tuple[object, ...]]) -> list[Contributor]:
9
+ """Turn ``(segment, value)`` rows into contributors ranked by absolute value.
10
+
11
+ ``share`` is each segment's fraction of the total of positive values.
12
+ """
13
+
14
+ pairs: list[tuple[str, float]] = []
15
+ for row in rows:
16
+ if len(row) < 2:
17
+ continue
18
+ value = _as_float(row[1])
19
+ if value is None:
20
+ continue
21
+ pairs.append((_as_str(row[0]), value))
22
+
23
+ total = sum(v for _, v in pairs if v > 0)
24
+ contributors = [
25
+ Contributor(segment=segment, value=value, share=(value / total if total else 0.0))
26
+ for segment, value in pairs
27
+ ]
28
+ contributors.sort(key=lambda c: abs(c.value), reverse=True)
29
+ return contributors
30
+
31
+
32
+ def _as_float(value: object) -> float | None:
33
+ try:
34
+ return float(value) # type: ignore[arg-type]
35
+ except (TypeError, ValueError):
36
+ return None
37
+
38
+
39
+ def _as_str(value: object) -> str:
40
+ return "—" if value is None else str(value)
insyte/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Command-line interface package."""
insyte/cli/_project.py ADDED
@@ -0,0 +1,43 @@
1
+ """Shared helpers for commands that operate on a project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from insyte.config import loader, paths
9
+ from insyte.config.models import InsyteConfig
10
+ from insyte.exceptions import InsyteError
11
+
12
+ console = Console()
13
+
14
+
15
+ def resolve_config(project: str | None) -> InsyteConfig:
16
+ """Load the requested project's config, or the active/only one.
17
+
18
+ Prints a friendly message and raises ``typer.Exit(1)`` when no project can be resolved.
19
+ """
20
+
21
+ projects = loader.list_projects()
22
+ if not projects:
23
+ console.print(
24
+ "[yellow]No Insyte projects found.[/yellow] Run [bold]insyte init[/bold] first."
25
+ )
26
+ raise typer.Exit(1)
27
+
28
+ name = project or paths.get_active_project() or projects[0]
29
+ if name not in projects:
30
+ # On case-insensitive filesystems (macOS default) the on-disk folder and the stored
31
+ # active-project name can differ only by case — match case-insensitively before failing.
32
+ matches = [p for p in projects if p.lower() == name.lower()]
33
+ if matches:
34
+ name = matches[0]
35
+ else:
36
+ console.print(f"[red]Error:[/red] project {name!r} does not exist.")
37
+ raise typer.Exit(1)
38
+
39
+ try:
40
+ return loader.load_config(name)
41
+ except InsyteError as exc:
42
+ console.print(f"[red]Error:[/red] {exc}")
43
+ raise typer.Exit(1) from exc
insyte/cli/_stubs.py ADDED
@@ -0,0 +1,42 @@
1
+ """Registration of not-yet-implemented commands.
2
+
3
+ Every command from the product spec is registered so ``insyte --help`` shows the full
4
+ roadmap. Unimplemented commands print a clean notice pointing at the milestone that will
5
+ deliver them, and exit successfully.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ console = Console()
16
+
17
+ # command name -> (milestone, help text)
18
+ _STUBS: dict[str, tuple[int, str]] = {
19
+ "serve": (7, "Run the Insyte HTTP API"),
20
+ }
21
+
22
+
23
+ def _coming_soon(command: str, milestone: int) -> None:
24
+ console.print(
25
+ f"[yellow]🚧 [bold]insyte {command}[/bold] is coming in Milestone {milestone}.[/yellow]"
26
+ )
27
+ console.print("[dim]This command is registered but not yet implemented.[/dim]")
28
+
29
+
30
+ def _make_stub(command: str, milestone: int) -> Callable[[], None]:
31
+ def _stub() -> None:
32
+ _coming_soon(command, milestone)
33
+
34
+ _stub.__name__ = f"stub_{command.replace(' ', '_')}"
35
+ return _stub
36
+
37
+
38
+ def register_stub_commands(app: typer.Typer) -> None:
39
+ """Register all stubbed commands (and the ``mcp`` sub-app) on the given Typer app."""
40
+
41
+ for name, (milestone, help_text) in _STUBS.items():
42
+ app.command(name, help=f"{help_text} (Milestone {milestone})")(_make_stub(name, milestone))