ccusage-viz 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 (75) hide show
  1. ccusage_viz/__init__.py +3 -0
  2. ccusage_viz/__main__.py +3 -0
  3. ccusage_viz/acquisition.py +126 -0
  4. ccusage_viz/application.py +40 -0
  5. ccusage_viz/bootstrap.py +36 -0
  6. ccusage_viz/chart_models.py +258 -0
  7. ccusage_viz/charts/__init__.py +4 -0
  8. ccusage_viz/charts/builtins.py +38 -0
  9. ccusage_viz/charts/definition.py +31 -0
  10. ccusage_viz/charts/registry.py +41 -0
  11. ccusage_viz/cli.py +720 -0
  12. ccusage_viz/command_copy.py +257 -0
  13. ccusage_viz/configuration.py +67 -0
  14. ccusage_viz/core/__init__.py +1 -0
  15. ccusage_viz/core/time.py +61 -0
  16. ccusage_viz/coverage.py +70 -0
  17. ccusage_viz/dashboard.py +56 -0
  18. ccusage_viz/dashboard_layout.py +310 -0
  19. ccusage_viz/data_view.py +220 -0
  20. ccusage_viz/deltas.py +45 -0
  21. ccusage_viz/demo.py +58 -0
  22. ccusage_viz/dependency.py +60 -0
  23. ccusage_viz/diagnostics.py +75 -0
  24. ccusage_viz/domain.py +127 -0
  25. ccusage_viz/errors.py +25 -0
  26. ccusage_viz/filter_draft.py +164 -0
  27. ccusage_viz/formatting.py +118 -0
  28. ccusage_viz/historical_component.py +364 -0
  29. ccusage_viz/historical_render.py +91 -0
  30. ccusage_viz/i18n.py +42 -0
  31. ccusage_viz/lifecycle.py +366 -0
  32. ccusage_viz/locales/__init__.py +6 -0
  33. ccusage_viz/locales/en.py +333 -0
  34. ccusage_viz/locales/zh.py +333 -0
  35. ccusage_viz/monitor.py +578 -0
  36. ccusage_viz/monitor_component.py +390 -0
  37. ccusage_viz/options.py +380 -0
  38. ccusage_viz/processing/__init__.py +32 -0
  39. ccusage_viz/processing/filtering.py +201 -0
  40. ccusage_viz/processing/historical.py +83 -0
  41. ccusage_viz/processing/monitor.py +572 -0
  42. ccusage_viz/processing/projection.py +345 -0
  43. ccusage_viz/processing/summaries.py +171 -0
  44. ccusage_viz/project_identity.py +120 -0
  45. ccusage_viz/providers/__init__.py +1 -0
  46. ccusage_viz/providers/ccusage.py +357 -0
  47. ccusage_viz/providers/demo.py +160 -0
  48. ccusage_viz/query/__init__.py +1 -0
  49. ccusage_viz/query/coordinator.py +308 -0
  50. ccusage_viz/query/models.py +234 -0
  51. ccusage_viz/query/provider.py +73 -0
  52. ccusage_viz/query/registry.py +41 -0
  53. ccusage_viz/query/runtime.py +32 -0
  54. ccusage_viz/render/__init__.py +7 -0
  55. ccusage_viz/render/base.py +254 -0
  56. ccusage_viz/render/calendar.py +173 -0
  57. ccusage_viz/render/observation.py +34 -0
  58. ccusage_viz/render/palette.py +241 -0
  59. ccusage_viz/render/ranking.py +227 -0
  60. ccusage_viz/render/stack.py +228 -0
  61. ccusage_viz/render/summary.py +132 -0
  62. ccusage_viz/render/timeline.py +192 -0
  63. ccusage_viz/schema.py +218 -0
  64. ccusage_viz/selectors.py +102 -0
  65. ccusage_viz/terminal.py +104 -0
  66. ccusage_viz/terminal_ui.py +129 -0
  67. ccusage_viz/trends.py +9 -0
  68. ccusage_viz/tui.py +2246 -0
  69. ccusage_viz/tui_input.py +128 -0
  70. ccusage_viz/watch.py +949 -0
  71. ccusage_viz-0.1.0.dist-info/METADATA +256 -0
  72. ccusage_viz-0.1.0.dist-info/RECORD +75 -0
  73. ccusage_viz-0.1.0.dist-info/WHEEL +4 -0
  74. ccusage_viz-0.1.0.dist-info/entry_points.txt +4 -0
  75. ccusage_viz-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,3 @@
1
+ __version__ = "0.1.0"
2
+
3
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from ccusage_viz.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, timedelta
4
+
5
+ from ccusage_viz.core.time import today_for_timezone
6
+ from ccusage_viz.coverage import DateCoverage, DateInterval
7
+ from ccusage_viz.options import MonitorConfig, StandaloneLaunch
8
+ from ccusage_viz.query.models import (
9
+ DataResolution,
10
+ DataScope,
11
+ ExecutionContext,
12
+ QueryIntent,
13
+ QueryTrigger,
14
+ )
15
+ from ccusage_viz.query.provider import ProviderDefinition
16
+
17
+
18
+ def historical_provider_id(options: StandaloneLaunch) -> str:
19
+ """Resolve the configured historical data mode to its Provider ID."""
20
+ return "demo" if options.host.demo_size else options.host.provider
21
+
22
+
23
+ def monitor_query_intent(
24
+ options: StandaloneLaunch,
25
+ definition: ProviderDefinition,
26
+ *,
27
+ owner_id: str,
28
+ generation: int,
29
+ trigger: QueryTrigger,
30
+ sample_ordinal: int = 0,
31
+ today: date | None = None,
32
+ ) -> QueryIntent:
33
+ """Compose one complete cumulative Monitor sample request."""
34
+ chart = options.chart
35
+ if not isinstance(chart, MonitorConfig):
36
+ raise TypeError("monitor query planning requires a monitor configuration")
37
+ provider = definition.provider
38
+ current_day = today_for_timezone(options.host.timezone, today)
39
+ scope_interval = DateInterval(current_day - timedelta(days=1), current_day)
40
+ project_required = bool(chart.filters.projects) or chart.by == "project"
41
+ available_options = {
42
+ "chart_kind": chart.kind,
43
+ "demo_size": options.host.demo_size,
44
+ "sample_ordinal": sample_ordinal,
45
+ }
46
+ execution_options = tuple(
47
+ (key, available_options[key])
48
+ for key in sorted(provider.capabilities.execution_options)
49
+ if available_options.get(key) is not None
50
+ )
51
+ execution_context = (
52
+ None
53
+ if provider.capabilities.in_process
54
+ else ExecutionContext(
55
+ options.process.ccusage_bin,
56
+ options.process.query_timeout,
57
+ options.process.output_limit,
58
+ options.process.environment,
59
+ )
60
+ )
61
+ return QueryIntent(
62
+ owner_id=owner_id,
63
+ generation=generation,
64
+ trigger=trigger,
65
+ provider=provider.provider,
66
+ scope=DataScope((scope_interval,), options.host.timezone),
67
+ missing_intervals=(scope_interval,),
68
+ resolution=DataResolution.DATE,
69
+ dimensions=("project",) if project_required else ("agent",),
70
+ execution_options=execution_options,
71
+ execution_context=execution_context,
72
+ )
73
+
74
+
75
+ def historical_query_intent(
76
+ options: StandaloneLaunch,
77
+ definition: ProviderDefinition,
78
+ *,
79
+ owner_id: str,
80
+ generation: int,
81
+ trigger: QueryTrigger,
82
+ coverage: DateCoverage | None = None,
83
+ required_coverage: DateCoverage | None = None,
84
+ ) -> QueryIntent:
85
+ """Compose one historical Host request into provider-neutral query intent."""
86
+ chart = options.chart
87
+ if isinstance(chart, MonitorConfig):
88
+ raise TypeError("historical query planning does not support monitor configurations")
89
+ provider = definition.provider
90
+ scope_interval = DateInterval(chart.date_range.since, chart.date_range.until)
91
+ required_coverage = (
92
+ required_coverage if required_coverage is not None else DateCoverage((scope_interval,))
93
+ )
94
+ coverage = coverage or DateCoverage()
95
+ project_required = bool(chart.filters.projects) or getattr(chart, "by", None) == "project"
96
+ available_options = {
97
+ "chart_kind": chart.kind,
98
+ "demo_size": options.host.demo_size,
99
+ }
100
+ execution_options = tuple(
101
+ (key, available_options[key])
102
+ for key in sorted(provider.capabilities.execution_options)
103
+ if available_options.get(key) is not None
104
+ )
105
+ execution_context = (
106
+ None
107
+ if provider.capabilities.in_process
108
+ else ExecutionContext(
109
+ options.process.ccusage_bin,
110
+ options.process.query_timeout,
111
+ options.process.output_limit,
112
+ options.process.environment,
113
+ )
114
+ )
115
+ return QueryIntent(
116
+ owner_id=owner_id,
117
+ generation=generation,
118
+ trigger=trigger,
119
+ provider=provider.provider,
120
+ scope=DataScope(required_coverage.intervals, chart.date_range.timezone),
121
+ missing_intervals=coverage.missing_coverage(required_coverage).intervals,
122
+ resolution=DataResolution.DATE,
123
+ dimensions=("project",) if project_required else ("agent",),
124
+ execution_options=execution_options,
125
+ execution_context=execution_context,
126
+ )
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+
6
+ from ccusage_viz.bootstrap import build_provider_registry
7
+ from ccusage_viz.dependency import ensure_provider_dependencies
8
+ from ccusage_viz.errors import UsageError
9
+ from ccusage_viz.i18n import Translator
10
+ from ccusage_viz.options import DashboardLaunch, LaunchConfig
11
+
12
+
13
+ def _preflight_runtime(options: LaunchConfig) -> None:
14
+ if not interactive_streams():
15
+ raise UsageError("error.tty")
16
+ if options.was_explicit("interval") and isinstance(options, DashboardLaunch):
17
+ raise UsageError("error.arguments", detail="dashboard does not support --interval")
18
+ if os.environ.get("TERM") == "dumb" and not options.host.ascii:
19
+ raise UsageError("error.arguments", detail="TERM=dumb requires explicit --ascii")
20
+
21
+
22
+ def run(options: LaunchConfig, translator: Translator) -> int:
23
+ _preflight_runtime(options)
24
+ ensure_provider_dependencies(options, build_provider_registry(), translator)
25
+ if isinstance(options, DashboardLaunch):
26
+ from ccusage_viz.tui import run_tui
27
+
28
+ return run_tui(options, translator)
29
+ if options.chart.kind == "monitor":
30
+ from ccusage_viz.monitor import run_monitor
31
+
32
+ return run_monitor(options, translator)
33
+
34
+ from ccusage_viz.watch import run_watch
35
+
36
+ return run_watch(options, translator)
37
+
38
+
39
+ def interactive_streams() -> bool:
40
+ return sys.stdin.isatty() and sys.stdout.isatty()
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from ccusage_viz.charts.builtins import (
4
+ CALENDAR_DEFINITION,
5
+ RANKING_DEFINITION,
6
+ STACK_DEFINITION,
7
+ TIMELINE_DEFINITION,
8
+ )
9
+ from ccusage_viz.charts.registry import ChartRegistry
10
+ from ccusage_viz.providers.ccusage import CCUSAGE_DEFINITION
11
+ from ccusage_viz.providers.demo import DEMO_DEFINITION
12
+ from ccusage_viz.query.coordinator import QueryCoordinator
13
+ from ccusage_viz.query.registry import ProviderRegistry
14
+ from ccusage_viz.query.runtime import QueryRuntime
15
+
16
+
17
+ def build_chart_registry() -> ChartRegistry:
18
+ registry = ChartRegistry()
19
+ registry.register(TIMELINE_DEFINITION)
20
+ registry.register(CALENDAR_DEFINITION)
21
+ registry.register(STACK_DEFINITION)
22
+ registry.register(RANKING_DEFINITION)
23
+ registry.freeze()
24
+ return registry
25
+
26
+
27
+ def build_provider_registry() -> ProviderRegistry:
28
+ registry = ProviderRegistry()
29
+ registry.register(CCUSAGE_DEFINITION)
30
+ registry.register(DEMO_DEFINITION)
31
+ registry.freeze()
32
+ return registry
33
+
34
+
35
+ def build_query_runtime(*, max_parallel: int = 2) -> QueryRuntime:
36
+ return QueryRuntime(build_provider_registry(), QueryCoordinator(max_parallel=max_parallel))
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Hashable
4
+ from dataclasses import KW_ONLY, dataclass, field
5
+ from datetime import date, datetime
6
+ from enum import StrEnum
7
+ from typing import Literal
8
+
9
+ from ccusage_viz.core.time import DateRange
10
+ from ccusage_viz.domain import Notice, TokenUsage
11
+
12
+ GroupKey = Hashable
13
+ ChartValue = int | float
14
+ MetricUnit = Literal["tokens", "tpm"]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class MetricDescriptor:
19
+ unit: MetricUnit = "tokens"
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class ObservedScope:
24
+ window_seconds: int
25
+ mode: Literal["total", "agent", "model", "project"]
26
+ state: Literal["ready", "baseline", "sampling"] = "ready"
27
+ agents: tuple[str, ...] = ()
28
+
29
+
30
+ class ChangeDirection(StrEnum):
31
+ INCREASE = "increase"
32
+ DECREASE = "decrease"
33
+ UNCHANGED = "unchanged"
34
+ FROM_ZERO = "from_zero"
35
+
36
+
37
+ class ComparisonState(StrEnum):
38
+ READY = "ready"
39
+ PENDING = "pending"
40
+ UNAVAILABLE = "unavailable"
41
+ FAILED = "failed"
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class PercentChange:
46
+ direction: ChangeDirection
47
+ percent: float | None = None
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class PeriodSummary:
52
+ period: str
53
+ day: date
54
+ total: int
55
+ sequential: PercentChange | None
56
+ year_over_year: PercentChange | None = None
57
+ previous_week_day: date | None = None
58
+ all_agents: bool = False
59
+ _: KW_ONLY
60
+ filter_count: int = 0
61
+ sequential_state: ComparisonState = ComparisonState.READY
62
+ year_over_year_state: ComparisonState = ComparisonState.READY
63
+
64
+ @property
65
+ def day_over_day(self) -> PercentChange | None:
66
+ return self.sequential
67
+
68
+ @property
69
+ def week_over_week(self) -> PercentChange | None:
70
+ return self.year_over_year
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class Series:
75
+ key: GroupKey
76
+ label: str
77
+ values: tuple[TokenUsage, ...]
78
+ is_other: bool = False
79
+
80
+ @property
81
+ def total(self) -> TokenUsage:
82
+ return sum(self.values, start=TokenUsage.zero())
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class ScalarSeries:
87
+ key: GroupKey
88
+ label: str
89
+ values: tuple[ChartValue | None, ...]
90
+ is_other: bool = False
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class TimelineModel:
95
+ days: tuple[date, ...]
96
+ series: tuple[Series, ...]
97
+ notices: tuple[Notice, ...] = field(default_factory=tuple)
98
+ summary: PeriodSummary | None = None
99
+ aggregation: str = "day"
100
+ observed_at: tuple[datetime, ...] = field(default_factory=tuple)
101
+ observed_series: tuple[ScalarSeries, ...] = field(default_factory=tuple)
102
+ metric: MetricDescriptor = field(default_factory=MetricDescriptor)
103
+ observed_scope: ObservedScope | None = None
104
+ y_axis_max: float | None = None
105
+ observed_current: tuple[ScalarRankingEntry, ...] = field(default_factory=tuple)
106
+
107
+ def __post_init__(self) -> None:
108
+ if (
109
+ not self.observed_at
110
+ and not self.observed_series
111
+ and not self.observed_current
112
+ and self.observed_scope is None
113
+ ):
114
+ return
115
+ if self.days or self.series or self.observed_scope is None:
116
+ raise ValueError("observed timelines require only observed axis and series data")
117
+ if any(len(series.values) != len(self.observed_at) for series in self.observed_series):
118
+ raise ValueError("observed timeline values must align with observed timestamps")
119
+
120
+ @property
121
+ def is_observed(self) -> bool:
122
+ return self.observed_scope is not None
123
+
124
+ @property
125
+ def total(self) -> TokenUsage:
126
+ return sum((item.total for item in self.series), start=TokenUsage.zero())
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class CalendarDay:
131
+ day: date
132
+ usage: TokenUsage
133
+
134
+
135
+ @dataclass(frozen=True, slots=True)
136
+ class CalendarModel:
137
+ days: tuple[CalendarDay, ...]
138
+ notices: tuple[Notice, ...] = field(default_factory=tuple)
139
+ summary: PeriodSummary | None = None
140
+
141
+ @property
142
+ def total(self) -> TokenUsage:
143
+ return sum((item.usage for item in self.days), start=TokenUsage.zero())
144
+
145
+ @property
146
+ def active_days(self) -> int:
147
+ return sum(item.usage.total > 0 for item in self.days)
148
+
149
+ @property
150
+ def peak(self) -> CalendarDay | None:
151
+ active = [item for item in self.days if item.usage.total > 0]
152
+ return (
153
+ max(active, key=lambda item: (item.usage.total, -item.day.toordinal()))
154
+ if active
155
+ else None
156
+ )
157
+
158
+ @property
159
+ def average(self) -> float:
160
+ return self.total.total / len(self.days) if self.days else 0.0
161
+
162
+ @property
163
+ def longest_streak(self) -> int:
164
+ longest = current = 0
165
+ for item in self.days:
166
+ current = current + 1 if item.usage.total > 0 else 0
167
+ longest = max(longest, current)
168
+ return longest
169
+
170
+ @property
171
+ def current_streak(self) -> int:
172
+ current = 0
173
+ for item in reversed(self.days):
174
+ if item.usage.total <= 0:
175
+ break
176
+ current += 1
177
+ return current
178
+
179
+
180
+ @dataclass(frozen=True, slots=True)
181
+ class StackModel:
182
+ days: tuple[date, ...]
183
+ components: tuple[Series, ...]
184
+ notices: tuple[Notice, ...] = field(default_factory=tuple)
185
+ summary: PeriodSummary | None = None
186
+ aggregation: str = "day"
187
+
188
+ @property
189
+ def total(self) -> TokenUsage:
190
+ # Every component series stores a usage with only its represented amount in total.
191
+ total = sum((item.total.total for item in self.components), start=0)
192
+ return TokenUsage(total, 0, 0, 0, 0, total)
193
+
194
+
195
+ @dataclass(frozen=True, slots=True)
196
+ class RankingEntry:
197
+ key: GroupKey
198
+ label: str
199
+ usage: TokenUsage
200
+ is_other: bool = False
201
+
202
+
203
+ @dataclass(frozen=True, slots=True)
204
+ class ScalarRankingEntry:
205
+ key: GroupKey
206
+ label: str
207
+ value: ChartValue
208
+ is_other: bool = False
209
+
210
+
211
+ @dataclass(frozen=True, slots=True)
212
+ class RankingModel:
213
+ entries: tuple[RankingEntry, ...]
214
+ date_range: DateRange | None
215
+ notices: tuple[Notice, ...] = field(default_factory=tuple)
216
+ denominator: TokenUsage | None = None
217
+ summary: PeriodSummary | None = None
218
+ top: int | None = None
219
+ top_share: float | None = None
220
+ observed_entries: tuple[ScalarRankingEntry, ...] = field(default_factory=tuple)
221
+ metric: MetricDescriptor = field(default_factory=MetricDescriptor)
222
+ observed_scope: ObservedScope | None = None
223
+ summary_notices: tuple[Notice, ...] = field(default_factory=tuple)
224
+
225
+ def __post_init__(self) -> None:
226
+ if not self.observed_entries and self.observed_scope is None:
227
+ if self.date_range is None:
228
+ raise ValueError("historical rankings require a date range")
229
+ if self.top_share is not None:
230
+ if self.top is None or not 0 <= self.top_share <= 1:
231
+ raise ValueError("ranking coverage requires Top and a valid share")
232
+ if self.denominator is None or self.denominator.total <= 0:
233
+ raise ValueError("ranking coverage requires a positive denominator")
234
+ elif self.top is not None:
235
+ raise ValueError("ranking Top metadata requires coverage")
236
+ return
237
+ if self.entries or self.observed_scope is None or self.date_range is not None:
238
+ raise ValueError("observed rankings require only observed entries and scope")
239
+ if (
240
+ self.denominator is not None
241
+ or self.summary is not None
242
+ or self.top is not None
243
+ or self.top_share is not None
244
+ ):
245
+ raise ValueError("observed rankings do not support historical percentages or summaries")
246
+
247
+ @property
248
+ def is_observed(self) -> bool:
249
+ return self.observed_scope is not None
250
+
251
+ @property
252
+ def total(self) -> TokenUsage:
253
+ return sum((item.usage for item in self.entries), start=TokenUsage.zero())
254
+
255
+ @property
256
+ def percentage_total(self) -> TokenUsage:
257
+ """Return the full filtered total used for historical entry percentages."""
258
+ return self.denominator if self.denominator is not None else self.total
@@ -0,0 +1,4 @@
1
+ from ccusage_viz.charts.definition import ChartDefinition
2
+ from ccusage_viz.charts.registry import ChartRegistry
3
+
4
+ __all__ = ("ChartDefinition", "ChartRegistry")
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from ccusage_viz.chart_models import CalendarModel, RankingModel, StackModel, TimelineModel
6
+ from ccusage_viz.charts.definition import ChartDefinition, HistoricalRenderer
7
+ from ccusage_viz.options import CalendarConfig, RankingConfig, StackConfig, TimelineConfig
8
+ from ccusage_viz.processing import process_historical
9
+ from ccusage_viz.render import render_calendar, render_ranking, render_stack, render_timeline
10
+
11
+ TIMELINE_DEFINITION = ChartDefinition(
12
+ "timeline",
13
+ TimelineConfig,
14
+ TimelineModel,
15
+ process_historical,
16
+ cast(HistoricalRenderer, render_timeline),
17
+ )
18
+ CALENDAR_DEFINITION = ChartDefinition(
19
+ "calendar",
20
+ CalendarConfig,
21
+ CalendarModel,
22
+ process_historical,
23
+ cast(HistoricalRenderer, render_calendar),
24
+ )
25
+ STACK_DEFINITION = ChartDefinition(
26
+ "stack",
27
+ StackConfig,
28
+ StackModel,
29
+ process_historical,
30
+ cast(HistoricalRenderer, render_stack),
31
+ )
32
+ RANKING_DEFINITION = ChartDefinition(
33
+ "ranking",
34
+ RankingConfig,
35
+ RankingModel,
36
+ process_historical,
37
+ cast(HistoricalRenderer, render_ranking),
38
+ )
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+
6
+ from ccusage_viz.chart_models import CalendarModel, RankingModel, StackModel, TimelineModel
7
+ from ccusage_viz.options import CalendarConfig, RankingConfig, StackConfig, TimelineConfig
8
+ from ccusage_viz.processing.historical import HistoricalModel, process_historical
9
+ from ccusage_viz.render.base import RenderContext
10
+
11
+ HistoricalConfigType = (
12
+ type[TimelineConfig] | type[CalendarConfig] | type[StackConfig] | type[RankingConfig]
13
+ )
14
+ HistoricalModelType = (
15
+ type[TimelineModel] | type[CalendarModel] | type[StackModel] | type[RankingModel]
16
+ )
17
+ HistoricalRenderer = Callable[[HistoricalModel, RenderContext], str]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ChartDefinition:
22
+ """Stateless collaborators registered for one built-in chart kind."""
23
+
24
+ chart_id: str
25
+ config_type: HistoricalConfigType
26
+ model_type: HistoricalModelType
27
+ processor: Callable[..., HistoricalModel]
28
+ renderer: HistoricalRenderer
29
+
30
+
31
+ __all__ = ("ChartDefinition", "HistoricalRenderer", "process_historical")
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+
5
+ from ccusage_viz.charts.definition import ChartDefinition
6
+
7
+
8
+ class ChartRegistry:
9
+ """Private deterministic registry populated only by application bootstrap."""
10
+
11
+ __slots__ = ("_definitions", "_frozen")
12
+
13
+ def __init__(self) -> None:
14
+ self._definitions: dict[str, ChartDefinition] = {}
15
+ self._frozen = False
16
+
17
+ @property
18
+ def frozen(self) -> bool:
19
+ return self._frozen
20
+
21
+ def register(self, definition: ChartDefinition) -> None:
22
+ if self._frozen:
23
+ raise RuntimeError("chart registry is frozen")
24
+ if definition.chart_id in self._definitions:
25
+ raise ValueError(f"duplicate chart ID: {definition.chart_id}")
26
+ self._definitions[definition.chart_id] = definition
27
+
28
+ def freeze(self) -> None:
29
+ self._frozen = True
30
+
31
+ def get(self, chart_id: str) -> ChartDefinition:
32
+ try:
33
+ return self._definitions[chart_id]
34
+ except KeyError as exc:
35
+ raise KeyError(f"unknown chart ID: {chart_id}") from exc
36
+
37
+ def __iter__(self) -> Iterator[ChartDefinition]:
38
+ return iter(self._definitions.values())
39
+
40
+ def __len__(self) -> int:
41
+ return len(self._definitions)