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.
- ccusage_viz/__init__.py +3 -0
- ccusage_viz/__main__.py +3 -0
- ccusage_viz/acquisition.py +126 -0
- ccusage_viz/application.py +40 -0
- ccusage_viz/bootstrap.py +36 -0
- ccusage_viz/chart_models.py +258 -0
- ccusage_viz/charts/__init__.py +4 -0
- ccusage_viz/charts/builtins.py +38 -0
- ccusage_viz/charts/definition.py +31 -0
- ccusage_viz/charts/registry.py +41 -0
- ccusage_viz/cli.py +720 -0
- ccusage_viz/command_copy.py +257 -0
- ccusage_viz/configuration.py +67 -0
- ccusage_viz/core/__init__.py +1 -0
- ccusage_viz/core/time.py +61 -0
- ccusage_viz/coverage.py +70 -0
- ccusage_viz/dashboard.py +56 -0
- ccusage_viz/dashboard_layout.py +310 -0
- ccusage_viz/data_view.py +220 -0
- ccusage_viz/deltas.py +45 -0
- ccusage_viz/demo.py +58 -0
- ccusage_viz/dependency.py +60 -0
- ccusage_viz/diagnostics.py +75 -0
- ccusage_viz/domain.py +127 -0
- ccusage_viz/errors.py +25 -0
- ccusage_viz/filter_draft.py +164 -0
- ccusage_viz/formatting.py +118 -0
- ccusage_viz/historical_component.py +364 -0
- ccusage_viz/historical_render.py +91 -0
- ccusage_viz/i18n.py +42 -0
- ccusage_viz/lifecycle.py +366 -0
- ccusage_viz/locales/__init__.py +6 -0
- ccusage_viz/locales/en.py +333 -0
- ccusage_viz/locales/zh.py +333 -0
- ccusage_viz/monitor.py +578 -0
- ccusage_viz/monitor_component.py +390 -0
- ccusage_viz/options.py +380 -0
- ccusage_viz/processing/__init__.py +32 -0
- ccusage_viz/processing/filtering.py +201 -0
- ccusage_viz/processing/historical.py +83 -0
- ccusage_viz/processing/monitor.py +572 -0
- ccusage_viz/processing/projection.py +345 -0
- ccusage_viz/processing/summaries.py +171 -0
- ccusage_viz/project_identity.py +120 -0
- ccusage_viz/providers/__init__.py +1 -0
- ccusage_viz/providers/ccusage.py +357 -0
- ccusage_viz/providers/demo.py +160 -0
- ccusage_viz/query/__init__.py +1 -0
- ccusage_viz/query/coordinator.py +308 -0
- ccusage_viz/query/models.py +234 -0
- ccusage_viz/query/provider.py +73 -0
- ccusage_viz/query/registry.py +41 -0
- ccusage_viz/query/runtime.py +32 -0
- ccusage_viz/render/__init__.py +7 -0
- ccusage_viz/render/base.py +254 -0
- ccusage_viz/render/calendar.py +173 -0
- ccusage_viz/render/observation.py +34 -0
- ccusage_viz/render/palette.py +241 -0
- ccusage_viz/render/ranking.py +227 -0
- ccusage_viz/render/stack.py +228 -0
- ccusage_viz/render/summary.py +132 -0
- ccusage_viz/render/timeline.py +192 -0
- ccusage_viz/schema.py +218 -0
- ccusage_viz/selectors.py +102 -0
- ccusage_viz/terminal.py +104 -0
- ccusage_viz/terminal_ui.py +129 -0
- ccusage_viz/trends.py +9 -0
- ccusage_viz/tui.py +2246 -0
- ccusage_viz/tui_input.py +128 -0
- ccusage_viz/watch.py +949 -0
- ccusage_viz-0.1.0.dist-info/METADATA +256 -0
- ccusage_viz-0.1.0.dist-info/RECORD +75 -0
- ccusage_viz-0.1.0.dist-info/WHEEL +4 -0
- ccusage_viz-0.1.0.dist-info/entry_points.txt +4 -0
- ccusage_viz-0.1.0.dist-info/licenses/LICENSE +674 -0
ccusage_viz/__init__.py
ADDED
ccusage_viz/__main__.py
ADDED
|
@@ -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()
|
ccusage_viz/bootstrap.py
ADDED
|
@@ -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,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)
|