ccburn 0.2.1__tar.gz → 0.3.0__tar.gz
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.
- {ccburn-0.2.1/src/ccburn.egg-info → ccburn-0.3.0}/PKG-INFO +6 -3
- {ccburn-0.2.1 → ccburn-0.3.0}/README.md +5 -2
- {ccburn-0.2.1 → ccburn-0.3.0}/pyproject.toml +1 -1
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/app.py +65 -15
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/cli.py +5 -6
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/display/chart.py +85 -14
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/display/gauges.py +58 -5
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/display/layout.py +8 -7
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/utils/calculator.py +69 -33
- {ccburn-0.2.1 → ccburn-0.3.0/src/ccburn.egg-info}/PKG-INFO +6 -3
- {ccburn-0.2.1 → ccburn-0.3.0}/tests/test_calculator.py +14 -4
- {ccburn-0.2.1 → ccburn-0.3.0}/tests/test_cli.py +4 -2
- {ccburn-0.2.1 → ccburn-0.3.0}/LICENSE +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/setup.cfg +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/__init__.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/data/__init__.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/data/credentials.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/data/history.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/data/models.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/data/usage_client.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/display/__init__.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/main.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/utils/__init__.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn/utils/formatting.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn.egg-info/SOURCES.txt +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn.egg-info/dependency_links.txt +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn.egg-info/entry_points.txt +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn.egg-info/requires.txt +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/src/ccburn.egg-info/top_level.txt +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/tests/test_formatting.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/tests/test_history.py +0 -0
- {ccburn-0.2.1 → ccburn-0.3.0}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ccburn
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Terminal-based Claude Code usage limit visualizer with real-time burn-up charts
|
|
5
5
|
Author: JuanjoFuchs
|
|
6
6
|
License-Expression: MIT
|
|
@@ -45,7 +45,10 @@ Dynamic: license-file
|
|
|
45
45
|
[](https://pypi.org/project/ccburn/)
|
|
46
46
|
[](https://pypi.org/project/ccburn/)
|
|
47
47
|
[](https://github.com/JuanjoFuchs/ccburn/releases)
|
|
48
|
-
[](https://winstall.app/apps/JuanjoFuchs.ccburn)
|
|
49
|
+
[](https://www.npmjs.com/package/ccburn)
|
|
50
|
+
[](https://pepy.tech/project/ccburn)
|
|
51
|
+
[](https://github.com/JuanjoFuchs/ccburn/releases)
|
|
49
52
|
[](LICENSE)
|
|
50
53
|
|
|
51
54
|
<p align="center">
|
|
@@ -73,7 +76,7 @@ TUI and CLI for Claude Code usage limits — burn-up charts, compact mode for st
|
|
|
73
76
|
|
|
74
77
|
Run `claude` and login first to refresh credentials.
|
|
75
78
|
|
|
76
|
-
### WinGet (
|
|
79
|
+
### WinGet (Windows)
|
|
77
80
|
|
|
78
81
|
```powershell
|
|
79
82
|
winget install JuanjoFuchs.ccburn
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
[](https://pypi.org/project/ccburn/)
|
|
7
7
|
[](https://pypi.org/project/ccburn/)
|
|
8
8
|
[](https://github.com/JuanjoFuchs/ccburn/releases)
|
|
9
|
-
[](https://winstall.app/apps/JuanjoFuchs.ccburn)
|
|
10
|
+
[](https://www.npmjs.com/package/ccburn)
|
|
11
|
+
[](https://pepy.tech/project/ccburn)
|
|
12
|
+
[](https://github.com/JuanjoFuchs/ccburn/releases)
|
|
10
13
|
[](LICENSE)
|
|
11
14
|
|
|
12
15
|
<p align="center">
|
|
@@ -34,7 +37,7 @@ TUI and CLI for Claude Code usage limits — burn-up charts, compact mode for st
|
|
|
34
37
|
|
|
35
38
|
Run `claude` and login first to refresh credentials.
|
|
36
39
|
|
|
37
|
-
### WinGet (
|
|
40
|
+
### WinGet (Windows)
|
|
38
41
|
|
|
39
42
|
```powershell
|
|
40
43
|
winget install JuanjoFuchs.ccburn
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ccburn"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Terminal-based Claude Code usage limit visualizer with real-time burn-up charts"
|
|
9
9
|
authors = [{name = "JuanjoFuchs"}]
|
|
10
10
|
readme = "README.md"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Main application class for ccburn."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import signal
|
|
4
5
|
import threading
|
|
5
6
|
import time
|
|
@@ -34,7 +35,7 @@ class CCBurnApp:
|
|
|
34
35
|
self,
|
|
35
36
|
limit_type: LimitType = LimitType.SESSION,
|
|
36
37
|
interval: int = 5,
|
|
37
|
-
|
|
38
|
+
since_duration: timedelta | None = None,
|
|
38
39
|
json_output: bool = False,
|
|
39
40
|
once: bool = False,
|
|
40
41
|
compact: bool = False,
|
|
@@ -45,7 +46,7 @@ class CCBurnApp:
|
|
|
45
46
|
Args:
|
|
46
47
|
limit_type: Which limit to display
|
|
47
48
|
interval: Refresh interval in seconds
|
|
48
|
-
|
|
49
|
+
since_duration: Time window duration for zoom view (sliding window)
|
|
49
50
|
json_output: Output JSON instead of TUI
|
|
50
51
|
once: Print once and exit
|
|
51
52
|
compact: Single-line output for status bars
|
|
@@ -53,13 +54,18 @@ class CCBurnApp:
|
|
|
53
54
|
"""
|
|
54
55
|
self.limit_type = limit_type
|
|
55
56
|
self.interval = interval
|
|
56
|
-
self.
|
|
57
|
+
self.since_duration = since_duration
|
|
57
58
|
self.json_output = json_output
|
|
58
59
|
self.once = once
|
|
59
60
|
self.compact = compact
|
|
60
61
|
self.debug = debug
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
# Disable legacy_windows mode for modern terminals to prevent Unicode issues
|
|
64
|
+
# Rich may incorrectly detect legacy mode even in Windows Terminal
|
|
65
|
+
use_legacy = None # Auto-detect by default
|
|
66
|
+
if os.environ.get("WT_SESSION"): # Windows Terminal
|
|
67
|
+
use_legacy = False
|
|
68
|
+
self.console = Console(legacy_windows=use_legacy)
|
|
63
69
|
self.client = UsageClient()
|
|
64
70
|
self.history: HistoryDB | None = None
|
|
65
71
|
self.layout = BurnupLayout(self.console)
|
|
@@ -71,6 +77,16 @@ class CCBurnApp:
|
|
|
71
77
|
self.last_error: str | None = None
|
|
72
78
|
self.snapshots: list[UsageSnapshot] = []
|
|
73
79
|
|
|
80
|
+
def _get_since_datetime(self) -> datetime | None:
|
|
81
|
+
"""Calculate the since datetime based on current time and duration.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
datetime for filtering, or None if no duration set
|
|
85
|
+
"""
|
|
86
|
+
if self.since_duration is None:
|
|
87
|
+
return None
|
|
88
|
+
return datetime.now(timezone.utc) - self.since_duration
|
|
89
|
+
|
|
74
90
|
def _setup_signal_handlers(self) -> None:
|
|
75
91
|
"""Setup signal handlers for graceful shutdown."""
|
|
76
92
|
|
|
@@ -93,10 +109,10 @@ class CCBurnApp:
|
|
|
93
109
|
self.history = HistoryDB()
|
|
94
110
|
# Prune old data on startup
|
|
95
111
|
self.history.prune_old_data()
|
|
96
|
-
# Load existing snapshots
|
|
112
|
+
# Load existing snapshots (use current since datetime)
|
|
97
113
|
self.snapshots = self.history.get_snapshots_for_limit(
|
|
98
114
|
self.limit_type,
|
|
99
|
-
since=self.
|
|
115
|
+
since=self._get_since_datetime(),
|
|
100
116
|
)
|
|
101
117
|
except Exception as e:
|
|
102
118
|
# Fall back to in-memory if SQLite fails
|
|
@@ -134,10 +150,10 @@ class CCBurnApp:
|
|
|
134
150
|
self.last_snapshot = snapshot
|
|
135
151
|
self.last_fetch_time = snapshot.timestamp
|
|
136
152
|
self.last_error = None
|
|
137
|
-
# Reload snapshots from database
|
|
153
|
+
# Reload snapshots from database (use current since datetime)
|
|
138
154
|
self.snapshots = self.history.get_snapshots_for_limit(
|
|
139
155
|
self.limit_type,
|
|
140
|
-
since=self.
|
|
156
|
+
since=self._get_since_datetime(),
|
|
141
157
|
)
|
|
142
158
|
return True
|
|
143
159
|
|
|
@@ -154,9 +170,10 @@ class CCBurnApp:
|
|
|
154
170
|
# Add to local list
|
|
155
171
|
self.snapshots.append(snapshot)
|
|
156
172
|
|
|
157
|
-
# Keep only relevant snapshots (based on
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
# Keep only relevant snapshots (based on since_duration or window)
|
|
174
|
+
since_dt = self._get_since_datetime()
|
|
175
|
+
if since_dt:
|
|
176
|
+
self.snapshots = [s for s in self.snapshots if s.timestamp >= since_dt]
|
|
160
177
|
else:
|
|
161
178
|
# Keep last 24 hours of data for calculations
|
|
162
179
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
|
@@ -276,7 +293,7 @@ class CCBurnApp:
|
|
|
276
293
|
limit_data,
|
|
277
294
|
self.snapshots,
|
|
278
295
|
error=self.last_error,
|
|
279
|
-
|
|
296
|
+
since_duration=self.since_duration,
|
|
280
297
|
)
|
|
281
298
|
self.console.print(layout)
|
|
282
299
|
|
|
@@ -307,7 +324,7 @@ class CCBurnApp:
|
|
|
307
324
|
limit_data,
|
|
308
325
|
self.snapshots,
|
|
309
326
|
error=self.last_error,
|
|
310
|
-
|
|
327
|
+
since_duration=self.since_duration,
|
|
311
328
|
)
|
|
312
329
|
|
|
313
330
|
# Set initial window title
|
|
@@ -367,7 +384,7 @@ class CCBurnApp:
|
|
|
367
384
|
self.snapshots,
|
|
368
385
|
error=self.last_error,
|
|
369
386
|
stale_since=stale_since,
|
|
370
|
-
|
|
387
|
+
since_duration=self.since_duration,
|
|
371
388
|
)
|
|
372
389
|
live.update(updated_layout)
|
|
373
390
|
self._update_window_title()
|
|
@@ -416,7 +433,7 @@ class CCBurnApp:
|
|
|
416
433
|
"status": metrics.status,
|
|
417
434
|
}
|
|
418
435
|
|
|
419
|
-
# Add burn rate for selected limit
|
|
436
|
+
# Add burn rate and projection for selected limit
|
|
420
437
|
limit_data = self.last_snapshot.get_limit(self.limit_type)
|
|
421
438
|
if limit_data:
|
|
422
439
|
metrics = calculate_burn_metrics(limit_data, self.snapshots)
|
|
@@ -428,6 +445,39 @@ class CCBurnApp:
|
|
|
428
445
|
}
|
|
429
446
|
output["recommendation"] = metrics.recommendation
|
|
430
447
|
|
|
448
|
+
# Add projection data
|
|
449
|
+
if metrics.percent_per_hour > 0:
|
|
450
|
+
current_pct = limit_data.utilization * 100
|
|
451
|
+
remaining_pct = 100.0 - current_pct
|
|
452
|
+
hours_to_100 = remaining_pct / metrics.percent_per_hour
|
|
453
|
+
|
|
454
|
+
now = datetime.now(timezone.utc)
|
|
455
|
+
remaining_window_hours = (limit_data.resets_at - now).total_seconds() / 3600
|
|
456
|
+
|
|
457
|
+
if hours_to_100 <= remaining_window_hours:
|
|
458
|
+
# Will hit 100% before window ends
|
|
459
|
+
projected_end_pct = 100.0
|
|
460
|
+
hits_100 = True
|
|
461
|
+
status = "warning"
|
|
462
|
+
else:
|
|
463
|
+
# Won't hit 100%
|
|
464
|
+
projected_end_pct = current_pct + (metrics.percent_per_hour * remaining_window_hours)
|
|
465
|
+
hits_100 = False
|
|
466
|
+
status = "safe"
|
|
467
|
+
|
|
468
|
+
output["projection"] = {
|
|
469
|
+
"available": True,
|
|
470
|
+
"projected_end_pct": round(min(projected_end_pct, 100.0), 1),
|
|
471
|
+
"hits_100": hits_100,
|
|
472
|
+
"hours_to_100": round(hours_to_100, 1) if hits_100 else None,
|
|
473
|
+
"status": status,
|
|
474
|
+
}
|
|
475
|
+
else:
|
|
476
|
+
output["projection"] = {
|
|
477
|
+
"available": False,
|
|
478
|
+
"reason": "insufficient_data" if metrics.percent_per_hour == 0 else "usage_decreasing",
|
|
479
|
+
}
|
|
480
|
+
|
|
431
481
|
return output
|
|
432
482
|
|
|
433
483
|
def stop(self) -> None:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""CLI command definitions for ccburn."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from datetime import
|
|
4
|
+
from datetime import timedelta
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
from rich.console import Console
|
|
@@ -132,12 +132,11 @@ def run_app(
|
|
|
132
132
|
except ImportError:
|
|
133
133
|
from ccburn.app import CCBurnApp
|
|
134
134
|
|
|
135
|
-
#
|
|
136
|
-
|
|
135
|
+
# Parse since duration if provided
|
|
136
|
+
since_duration = None
|
|
137
137
|
if since:
|
|
138
138
|
try:
|
|
139
|
-
|
|
140
|
-
since_dt = datetime.now(timezone.utc) - since_delta
|
|
139
|
+
since_duration = parse_duration(since)
|
|
141
140
|
except typer.BadParameter as e:
|
|
142
141
|
typer.echo(f"Error: {e}", err=True)
|
|
143
142
|
raise typer.Exit(1) from None
|
|
@@ -145,7 +144,7 @@ def run_app(
|
|
|
145
144
|
app = CCBurnApp(
|
|
146
145
|
limit_type=limit_type,
|
|
147
146
|
interval=interval,
|
|
148
|
-
|
|
147
|
+
since_duration=since_duration,
|
|
149
148
|
json_output=json_output,
|
|
150
149
|
once=once,
|
|
151
150
|
compact=compact,
|
|
@@ -9,10 +9,10 @@ from rich.console import Console, ConsoleOptions, Group, RenderableType
|
|
|
9
9
|
from rich.jupyter import JupyterMixin
|
|
10
10
|
|
|
11
11
|
try:
|
|
12
|
-
from ..data.models import LimitData, UsageSnapshot
|
|
12
|
+
from ..data.models import BurnMetrics, LimitData, UsageSnapshot
|
|
13
13
|
from ..utils.formatting import get_utilization_color
|
|
14
14
|
except ImportError:
|
|
15
|
-
from ccburn.data.models import LimitData, UsageSnapshot
|
|
15
|
+
from ccburn.data.models import BurnMetrics, LimitData, UsageSnapshot
|
|
16
16
|
from ccburn.utils.formatting import get_utilization_color
|
|
17
17
|
|
|
18
18
|
|
|
@@ -23,21 +23,24 @@ class BurnupChart(JupyterMixin):
|
|
|
23
23
|
self,
|
|
24
24
|
limit_data: LimitData | None,
|
|
25
25
|
snapshots: list[UsageSnapshot],
|
|
26
|
-
|
|
26
|
+
since_duration: timedelta | None = None,
|
|
27
27
|
explicit_height: int | None = None,
|
|
28
|
+
burn_metrics: BurnMetrics | None = None,
|
|
28
29
|
):
|
|
29
30
|
"""Initialize the burnup chart.
|
|
30
31
|
|
|
31
32
|
Args:
|
|
32
33
|
limit_data: Current limit data (for window boundaries)
|
|
33
34
|
snapshots: Historical snapshots to plot
|
|
34
|
-
|
|
35
|
+
since_duration: Duration for zoom view (sliding window, e.g., last 1h)
|
|
35
36
|
explicit_height: Override chart height
|
|
37
|
+
burn_metrics: Burn rate metrics for projection line
|
|
36
38
|
"""
|
|
37
39
|
self.limit_data = limit_data
|
|
38
40
|
self.snapshots = snapshots
|
|
39
|
-
self.
|
|
41
|
+
self.since_duration = since_duration
|
|
40
42
|
self.explicit_height = explicit_height
|
|
43
|
+
self.burn_metrics = burn_metrics
|
|
41
44
|
self.decoder = AnsiDecoder()
|
|
42
45
|
|
|
43
46
|
def __rich_console__(
|
|
@@ -86,12 +89,14 @@ class BurnupChart(JupyterMixin):
|
|
|
86
89
|
window_end = self.limit_data.resets_at
|
|
87
90
|
now = datetime.now(timezone.utc)
|
|
88
91
|
|
|
89
|
-
# Display window (may be zoomed with --since)
|
|
92
|
+
# Display window (may be zoomed with --since as a sliding window)
|
|
90
93
|
display_start = original_window_start
|
|
91
94
|
display_end = window_end
|
|
92
|
-
if self.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
if self.since_duration:
|
|
96
|
+
# Sliding window: always show the last N duration (e.g., last 1h)
|
|
97
|
+
# Both start and end move forward with time
|
|
98
|
+
display_start = max(original_window_start, now - self.since_duration)
|
|
99
|
+
display_end = now
|
|
95
100
|
|
|
96
101
|
# Filter snapshots to display window
|
|
97
102
|
relevant_snapshots = [
|
|
@@ -160,11 +165,46 @@ class BurnupChart(JupyterMixin):
|
|
|
160
165
|
label="Usage",
|
|
161
166
|
)
|
|
162
167
|
|
|
168
|
+
# Plot projection line if we have positive burn rate (not on zoomed views)
|
|
169
|
+
hits_100_hours = None # Track when projection hits 100% for vertical marker
|
|
170
|
+
if self.burn_metrics and self.burn_metrics.percent_per_hour > 0 and not self.since_duration:
|
|
171
|
+
current_pct = self.limit_data.utilization * 100
|
|
172
|
+
now_hours = to_hours(now)
|
|
173
|
+
|
|
174
|
+
# Only draw projection if not already at or above 100%
|
|
175
|
+
if current_pct < 100.0:
|
|
176
|
+
# Calculate time to hit 100%
|
|
177
|
+
remaining_pct = 100.0 - current_pct
|
|
178
|
+
hours_to_100 = remaining_pct / self.burn_metrics.percent_per_hour
|
|
179
|
+
|
|
180
|
+
# End point: either 100% or window end, whichever comes first
|
|
181
|
+
remaining_window_hours = (display_end - now).total_seconds() / 3600
|
|
182
|
+
|
|
183
|
+
if hours_to_100 <= remaining_window_hours:
|
|
184
|
+
# Will hit 100% before window ends
|
|
185
|
+
end_hours = now_hours + hours_to_100
|
|
186
|
+
end_pct = 100.0
|
|
187
|
+
proj_color = (255, 100, 0) # Orange - warning
|
|
188
|
+
hits_100_hours = end_hours # Mark for vertical line
|
|
189
|
+
else:
|
|
190
|
+
# Won't hit 100%, project to window end
|
|
191
|
+
end_hours = now_hours + remaining_window_hours
|
|
192
|
+
end_pct = current_pct + (self.burn_metrics.percent_per_hour * remaining_window_hours)
|
|
193
|
+
proj_color = (100, 200, 100) # Green - safe
|
|
194
|
+
|
|
195
|
+
plt.plot(
|
|
196
|
+
[now_hours, end_hours],
|
|
197
|
+
[current_pct, min(end_pct, 100.0)],
|
|
198
|
+
color=proj_color,
|
|
199
|
+
marker="braille",
|
|
200
|
+
label="Projection",
|
|
201
|
+
)
|
|
202
|
+
|
|
163
203
|
# Configure axes
|
|
164
204
|
plt.xlim(0, display_hours)
|
|
165
205
|
|
|
166
206
|
# Y-axis: dynamic when zoomed (--since), fixed 0-100 otherwise
|
|
167
|
-
if self.
|
|
207
|
+
if self.since_duration and values:
|
|
168
208
|
# Calculate dynamic range from data with padding
|
|
169
209
|
all_y_values = values + pace_y
|
|
170
210
|
data_min = min(all_y_values)
|
|
@@ -186,7 +226,7 @@ class BurnupChart(JupyterMixin):
|
|
|
186
226
|
# Add "now" vertical line when showing full window (not zoomed)
|
|
187
227
|
# Use dotted effect by plotting points at intervals
|
|
188
228
|
now_hours_for_tick = None
|
|
189
|
-
if not self.
|
|
229
|
+
if not self.since_duration:
|
|
190
230
|
now_hours = to_hours(now)
|
|
191
231
|
if 0 < now_hours < display_hours:
|
|
192
232
|
# Create dotted vertical line with points using braille marker to match other lines
|
|
@@ -196,6 +236,15 @@ class BurnupChart(JupyterMixin):
|
|
|
196
236
|
plt.plot(dot_x, dot_y, color=(0, 120, 255), marker="braille", label="Now")
|
|
197
237
|
now_hours_for_tick = now_hours
|
|
198
238
|
|
|
239
|
+
# Add "hits 100%" vertical line when projection exceeds budget
|
|
240
|
+
hits_100_hours_for_tick = None
|
|
241
|
+
if hits_100_hours is not None and 0 < hits_100_hours < display_hours:
|
|
242
|
+
num_dots = 20
|
|
243
|
+
dot_y = [y_min + i * (y_max - y_min) / (num_dots - 1) for i in range(num_dots)]
|
|
244
|
+
dot_x = [hits_100_hours] * num_dots
|
|
245
|
+
plt.plot(dot_x, dot_y, color=(255, 100, 0), marker="braille", label="Depleted")
|
|
246
|
+
hits_100_hours_for_tick = hits_100_hours
|
|
247
|
+
|
|
199
248
|
# Enable right Y axis with same range
|
|
200
249
|
plt.plot([display_hours], [y_max], marker=" ", yside="right") # Hidden point to enable right axis
|
|
201
250
|
plt.ylim(y_min, y_max, yside="right")
|
|
@@ -247,6 +296,28 @@ class BurnupChart(JupyterMixin):
|
|
|
247
296
|
else:
|
|
248
297
|
tick_labels.append(local_now.strftime("%H:%M"))
|
|
249
298
|
|
|
299
|
+
# Add "100%" tick if showing the hits-100 line
|
|
300
|
+
if hits_100_hours_for_tick is not None:
|
|
301
|
+
min_distance = display_hours / 10 # Minimum 10% of display width apart
|
|
302
|
+
# Filter out ticks that are too close to "100%"
|
|
303
|
+
filtered = [(pos, label) for pos, label in zip(tick_positions, tick_labels, strict=True)
|
|
304
|
+
if abs(hits_100_hours_for_tick - pos) >= min_distance]
|
|
305
|
+
tick_positions = [pos for pos, _ in filtered]
|
|
306
|
+
tick_labels = [label for _, label in filtered]
|
|
307
|
+
# Add the "100%" tick with timestamp
|
|
308
|
+
hits_100_time = display_start + timedelta(hours=hits_100_hours_for_tick)
|
|
309
|
+
local_hits_100 = hits_100_time.astimezone()
|
|
310
|
+
# Round to nearest minute
|
|
311
|
+
if local_hits_100.second >= 30:
|
|
312
|
+
local_hits_100 = local_hits_100.replace(second=0, microsecond=0) + timedelta(minutes=1)
|
|
313
|
+
else:
|
|
314
|
+
local_hits_100 = local_hits_100.replace(second=0, microsecond=0)
|
|
315
|
+
tick_positions.append(hits_100_hours_for_tick)
|
|
316
|
+
if use_date_format:
|
|
317
|
+
tick_labels.append(local_hits_100.strftime("%a %Hh"))
|
|
318
|
+
else:
|
|
319
|
+
tick_labels.append(local_hits_100.strftime("%H:%M"))
|
|
320
|
+
|
|
250
321
|
plt.xticks(tick_positions, tick_labels)
|
|
251
322
|
|
|
252
323
|
# No labels on axes
|
|
@@ -291,7 +362,7 @@ def create_simple_chart(
|
|
|
291
362
|
snapshots: list[UsageSnapshot],
|
|
292
363
|
width: int = 80,
|
|
293
364
|
height: int = 15,
|
|
294
|
-
|
|
365
|
+
since_duration: timedelta | None = None,
|
|
295
366
|
) -> str:
|
|
296
367
|
"""Create a simple chart string without Rich integration.
|
|
297
368
|
|
|
@@ -300,10 +371,10 @@ def create_simple_chart(
|
|
|
300
371
|
snapshots: Historical snapshots
|
|
301
372
|
width: Chart width
|
|
302
373
|
height: Chart height
|
|
303
|
-
|
|
374
|
+
since_duration: Duration for zoom view (sliding window)
|
|
304
375
|
|
|
305
376
|
Returns:
|
|
306
377
|
Rendered chart string
|
|
307
378
|
"""
|
|
308
|
-
chart = BurnupChart(limit_data, snapshots,
|
|
379
|
+
chart = BurnupChart(limit_data, snapshots, since_duration=since_duration, explicit_height=height)
|
|
309
380
|
return chart._create_chart(width, height)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Progress bar gauges for ccburn TUI."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
3
6
|
from rich.progress import ProgressBar
|
|
4
7
|
from rich.style import Style
|
|
5
8
|
from rich.table import Table
|
|
@@ -15,26 +18,76 @@ except ImportError:
|
|
|
15
18
|
from ccburn.utils.formatting import format_reset_time, get_utilization_color
|
|
16
19
|
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
_emoji_support_cache: bool | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _supports_emoji() -> bool:
|
|
25
|
+
"""Detect if the console supports emoji characters.
|
|
26
|
+
|
|
27
|
+
Result is cached to ensure consistent behavior throughout the session,
|
|
28
|
+
as Rich's Live mode may affect stdout properties during rendering.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if emoji are likely supported, False otherwise.
|
|
32
|
+
"""
|
|
33
|
+
global _emoji_support_cache
|
|
34
|
+
if _emoji_support_cache is not None:
|
|
35
|
+
return _emoji_support_cache
|
|
36
|
+
|
|
37
|
+
# First, check if stdout encoding can handle emoji - this prevents crashes
|
|
38
|
+
# even in modern terminals if Python's encoding is misconfigured
|
|
39
|
+
try:
|
|
40
|
+
encoding = getattr(sys.stdout, "encoding", None) or ""
|
|
41
|
+
if encoding.lower() not in ("utf-8", "utf8"):
|
|
42
|
+
# Try to encode an emoji to test
|
|
43
|
+
"🔥".encode(encoding)
|
|
44
|
+
except (UnicodeEncodeError, LookupError):
|
|
45
|
+
# Encoding cannot handle emoji - use ASCII to prevent crashes
|
|
46
|
+
_emoji_support_cache = False
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
# Encoding is OK, now check for modern terminal environments
|
|
50
|
+
# that are known to render emoji correctly
|
|
51
|
+
if os.environ.get("WT_SESSION"): # Windows Terminal
|
|
52
|
+
_emoji_support_cache = True
|
|
53
|
+
return True
|
|
54
|
+
if os.environ.get("TERM_PROGRAM"): # macOS Terminal, iTerm2, VS Code, etc.
|
|
55
|
+
_emoji_support_cache = True
|
|
56
|
+
return True
|
|
57
|
+
if os.environ.get("COLORTERM") == "truecolor": # Modern terminals with truecolor
|
|
58
|
+
_emoji_support_cache = True
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
# Fall back to utf-8 encoding check
|
|
62
|
+
encoding = getattr(sys.stdout, "encoding", None) or ""
|
|
63
|
+
_emoji_support_cache = encoding.lower() in ("utf-8", "utf8")
|
|
64
|
+
return _emoji_support_cache
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_pace_emoji(utilization: float, budget_pace: float, ascii_fallback: bool = False) -> str:
|
|
19
68
|
"""Get emoji indicator based on utilization vs budget pace.
|
|
20
69
|
|
|
21
70
|
Args:
|
|
22
71
|
utilization: Current utilization (0-1)
|
|
23
72
|
budget_pace: Expected budget pace (0-1)
|
|
73
|
+
ascii_fallback: If True, use ASCII characters instead of emoji
|
|
24
74
|
|
|
25
75
|
Returns:
|
|
26
76
|
Emoji: 🧊 (behind), 🔥 (on pace), 🚨 (ahead)
|
|
77
|
+
ASCII: [_] (behind), [=] (on pace), [!] (ahead)
|
|
27
78
|
"""
|
|
79
|
+
use_ascii = ascii_fallback or not _supports_emoji()
|
|
80
|
+
|
|
28
81
|
if budget_pace == 0:
|
|
29
|
-
return "🔥"
|
|
82
|
+
return "[=]" if use_ascii else "🔥"
|
|
30
83
|
|
|
31
84
|
ratio = utilization / budget_pace
|
|
32
85
|
if ratio < 0.85:
|
|
33
|
-
return "🧊" # Behind pace - ice cold, under budget
|
|
86
|
+
return "[_]" if use_ascii else "🧊" # Behind pace - ice cold, under budget
|
|
34
87
|
elif ratio > 1.15:
|
|
35
|
-
return "🚨" # Ahead of pace - alarm!
|
|
88
|
+
return "[!]" if use_ascii else "🚨" # Ahead of pace - alarm!
|
|
36
89
|
else:
|
|
37
|
-
return "🔥" # On pace - normal burn
|
|
90
|
+
return "[=]" if use_ascii else "🔥" # On pace - normal burn
|
|
38
91
|
|
|
39
92
|
|
|
40
93
|
def create_header(limit_type: LimitType, limit_data: LimitData | None) -> Table:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Layout manager for ccburn TUI."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from datetime import datetime, timezone
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
5
|
|
|
6
6
|
from rich.console import Console
|
|
7
7
|
from rich.layout import Layout
|
|
@@ -68,7 +68,7 @@ class BurnupLayout:
|
|
|
68
68
|
snapshots: list[UsageSnapshot],
|
|
69
69
|
error: str | None = None,
|
|
70
70
|
stale_since: datetime | None = None,
|
|
71
|
-
|
|
71
|
+
since_duration: timedelta | None = None,
|
|
72
72
|
) -> Layout:
|
|
73
73
|
"""Update the layout with new data.
|
|
74
74
|
|
|
@@ -78,7 +78,7 @@ class BurnupLayout:
|
|
|
78
78
|
snapshots: Historical snapshots for chart
|
|
79
79
|
error: Error message to display (if any)
|
|
80
80
|
stale_since: When data became stale (if using cached data)
|
|
81
|
-
|
|
81
|
+
since_duration: Zoom view duration (sliding window)
|
|
82
82
|
|
|
83
83
|
Returns:
|
|
84
84
|
Updated Rich Layout
|
|
@@ -97,14 +97,14 @@ class BurnupLayout:
|
|
|
97
97
|
if self.should_use_compact_mode():
|
|
98
98
|
return self._create_compact_layout(limit_type, width, height)
|
|
99
99
|
|
|
100
|
-
return self._create_full_layout(limit_type, width, height,
|
|
100
|
+
return self._create_full_layout(limit_type, width, height, since_duration)
|
|
101
101
|
|
|
102
102
|
def _create_full_layout(
|
|
103
103
|
self,
|
|
104
104
|
limit_type: LimitType,
|
|
105
105
|
width: int,
|
|
106
106
|
height: int,
|
|
107
|
-
|
|
107
|
+
since_duration: timedelta | None = None,
|
|
108
108
|
) -> Layout:
|
|
109
109
|
"""Create full TUI layout with header, gauges, and chart.
|
|
110
110
|
|
|
@@ -112,7 +112,7 @@ class BurnupLayout:
|
|
|
112
112
|
limit_type: Which limit to display
|
|
113
113
|
width: Terminal width
|
|
114
114
|
height: Terminal height
|
|
115
|
-
|
|
115
|
+
since_duration: Zoom view duration (sliding window)
|
|
116
116
|
|
|
117
117
|
Returns:
|
|
118
118
|
Rich Layout
|
|
@@ -155,8 +155,9 @@ class BurnupLayout:
|
|
|
155
155
|
chart = BurnupChart(
|
|
156
156
|
self._last_limit_data,
|
|
157
157
|
self._last_snapshots,
|
|
158
|
-
|
|
158
|
+
since_duration=since_duration,
|
|
159
159
|
explicit_height=chart_height,
|
|
160
|
+
burn_metrics=self._last_metrics,
|
|
160
161
|
)
|
|
161
162
|
chart_layout.update(chart)
|
|
162
163
|
|
|
@@ -41,53 +41,86 @@ def calculate_budget_pace(resets_at: datetime, window_hours: float) -> float:
|
|
|
41
41
|
def calculate_burn_rate(
|
|
42
42
|
snapshots: list[UsageSnapshot],
|
|
43
43
|
limit_type: LimitType,
|
|
44
|
-
|
|
44
|
+
window_start: datetime,
|
|
45
|
+
window_hours: float,
|
|
46
|
+
min_points: int = 3,
|
|
47
|
+
min_span_pct: float = 0.10,
|
|
45
48
|
) -> float:
|
|
46
|
-
"""Calculate burn rate as percentage points per hour.
|
|
49
|
+
"""Calculate burn rate as percentage points per hour using linear regression.
|
|
47
50
|
|
|
48
|
-
Uses
|
|
51
|
+
Uses least-squares linear regression over snapshots from the current window
|
|
52
|
+
for an accurate burn rate estimate.
|
|
49
53
|
|
|
50
54
|
Args:
|
|
51
55
|
snapshots: List of usage snapshots (should be sorted by timestamp)
|
|
52
56
|
limit_type: Which limit to calculate burn rate for
|
|
53
|
-
|
|
57
|
+
window_start: Start of the current window (from limit_data)
|
|
58
|
+
window_hours: Duration of the window in hours
|
|
59
|
+
min_points: Minimum data points required for regression (default 3)
|
|
60
|
+
min_span_pct: Minimum time span as fraction of window (default 0.10 = 10%)
|
|
54
61
|
|
|
55
62
|
Returns:
|
|
56
63
|
Burn rate in percentage points per hour (e.g., 12.5 means 12.5%/hour)
|
|
64
|
+
Returns 0.0 if insufficient data (no projection should be shown)
|
|
57
65
|
"""
|
|
58
66
|
if len(snapshots) < 2:
|
|
59
67
|
return 0.0
|
|
60
68
|
|
|
61
|
-
now
|
|
62
|
-
cutoff =
|
|
63
|
-
|
|
64
|
-
# Filter to
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
# Use actual window start, not now - window_minutes
|
|
70
|
+
cutoff = window_start
|
|
71
|
+
|
|
72
|
+
# Filter to snapshots within window and extract (time, utilization) pairs
|
|
73
|
+
points: list[tuple[float, float]] = [] # (hours_from_start, utilization_pct)
|
|
74
|
+
first_timestamp = None
|
|
75
|
+
last_timestamp = None
|
|
76
|
+
|
|
77
|
+
for s in snapshots:
|
|
78
|
+
if s.timestamp < cutoff:
|
|
79
|
+
continue
|
|
80
|
+
limit = s.get_limit(limit_type)
|
|
81
|
+
if limit is None:
|
|
82
|
+
continue
|
|
83
|
+
if first_timestamp is None:
|
|
84
|
+
first_timestamp = s.timestamp
|
|
85
|
+
last_timestamp = s.timestamp
|
|
86
|
+
hours = (s.timestamp - first_timestamp).total_seconds() / 3600
|
|
87
|
+
points.append((hours, limit.utilization * 100))
|
|
88
|
+
|
|
89
|
+
# Need at least min_points for meaningful regression
|
|
90
|
+
if len(points) < min_points:
|
|
72
91
|
return 0.0
|
|
73
92
|
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
93
|
+
# Check that data spans at least min_span_pct of the window
|
|
94
|
+
# e.g., for 5h session at 10%, need 30 min of data
|
|
95
|
+
# e.g., for 168h weekly at 10%, need ~17 hours of data
|
|
96
|
+
if first_timestamp and last_timestamp:
|
|
97
|
+
span_hours = (last_timestamp - first_timestamp).total_seconds() / 3600
|
|
98
|
+
min_span_hours = window_hours * min_span_pct
|
|
99
|
+
if span_hours < min_span_hours:
|
|
100
|
+
return 0.0
|
|
101
|
+
|
|
102
|
+
# If we have exactly 2 points, use simple slope
|
|
103
|
+
if len(points) == 2:
|
|
104
|
+
dx = points[1][0] - points[0][0]
|
|
105
|
+
if dx <= 0:
|
|
106
|
+
return 0.0
|
|
107
|
+
return (points[1][1] - points[0][1]) / dx
|
|
108
|
+
|
|
109
|
+
# Linear regression using least squares
|
|
110
|
+
# slope = (n*Σxy - Σx*Σy) / (n*Σx² - (Σx)²)
|
|
111
|
+
n = len(points)
|
|
112
|
+
sum_x = sum(p[0] for p in points)
|
|
113
|
+
sum_y = sum(p[1] for p in points)
|
|
114
|
+
sum_xy = sum(p[0] * p[1] for p in points)
|
|
115
|
+
sum_x2 = sum(p[0] ** 2 for p in points)
|
|
116
|
+
|
|
117
|
+
denominator = n * sum_x2 - sum_x**2
|
|
118
|
+
if abs(denominator) < 1e-10:
|
|
119
|
+
# All x values are the same (no time elapsed)
|
|
82
120
|
return 0.0
|
|
83
121
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if delta_hours <= 0:
|
|
88
|
-
return 0.0
|
|
89
|
-
|
|
90
|
-
return delta_util / delta_hours # %/hour
|
|
122
|
+
slope = (n * sum_xy - sum_x * sum_y) / denominator
|
|
123
|
+
return slope # %/hour
|
|
91
124
|
|
|
92
125
|
|
|
93
126
|
def estimate_time_to_empty(current_utilization: float, burn_rate_per_hour: float) -> int | None:
|
|
@@ -177,20 +210,23 @@ def get_status(utilization: float, budget_pace: float) -> str:
|
|
|
177
210
|
def calculate_burn_metrics(
|
|
178
211
|
limit_data: LimitData,
|
|
179
212
|
snapshots: list[UsageSnapshot],
|
|
180
|
-
window_minutes: int = 5,
|
|
181
213
|
) -> BurnMetrics:
|
|
182
214
|
"""Calculate all burn metrics for a limit.
|
|
183
215
|
|
|
184
216
|
Args:
|
|
185
217
|
limit_data: Current limit data
|
|
186
218
|
snapshots: Historical snapshots for burn rate calculation
|
|
187
|
-
window_minutes: How far back to look for burn rate
|
|
188
219
|
|
|
189
220
|
Returns:
|
|
190
221
|
BurnMetrics with all calculated values
|
|
191
222
|
"""
|
|
192
223
|
budget_pace = calculate_budget_pace(limit_data.resets_at, limit_data.window_hours)
|
|
193
|
-
burn_rate = calculate_burn_rate(
|
|
224
|
+
burn_rate = calculate_burn_rate(
|
|
225
|
+
snapshots,
|
|
226
|
+
limit_data.limit_type,
|
|
227
|
+
window_start=limit_data.window_start,
|
|
228
|
+
window_hours=limit_data.window_hours,
|
|
229
|
+
)
|
|
194
230
|
time_to_empty = estimate_time_to_empty(limit_data.utilization, burn_rate)
|
|
195
231
|
trend = classify_burn_trend(burn_rate)
|
|
196
232
|
status = get_status(limit_data.utilization, budget_pace)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ccburn
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Terminal-based Claude Code usage limit visualizer with real-time burn-up charts
|
|
5
5
|
Author: JuanjoFuchs
|
|
6
6
|
License-Expression: MIT
|
|
@@ -45,7 +45,10 @@ Dynamic: license-file
|
|
|
45
45
|
[](https://pypi.org/project/ccburn/)
|
|
46
46
|
[](https://pypi.org/project/ccburn/)
|
|
47
47
|
[](https://github.com/JuanjoFuchs/ccburn/releases)
|
|
48
|
-
[](https://winstall.app/apps/JuanjoFuchs.ccburn)
|
|
49
|
+
[](https://www.npmjs.com/package/ccburn)
|
|
50
|
+
[](https://pepy.tech/project/ccburn)
|
|
51
|
+
[](https://github.com/JuanjoFuchs/ccburn/releases)
|
|
49
52
|
[](LICENSE)
|
|
50
53
|
|
|
51
54
|
<p align="center">
|
|
@@ -73,7 +76,7 @@ TUI and CLI for Claude Code usage limits — burn-up charts, compact mode for st
|
|
|
73
76
|
|
|
74
77
|
Run `claude` and login first to refresh credentials.
|
|
75
78
|
|
|
76
|
-
### WinGet (
|
|
79
|
+
### WinGet (Windows)
|
|
77
80
|
|
|
78
81
|
```powershell
|
|
79
82
|
winget install JuanjoFuchs.ccburn
|
|
@@ -63,12 +63,15 @@ class TestCalculateBurnRate:
|
|
|
63
63
|
|
|
64
64
|
def test_no_snapshots(self):
|
|
65
65
|
"""Burn rate should be 0 with no snapshots."""
|
|
66
|
-
|
|
66
|
+
now = datetime.now(timezone.utc)
|
|
67
|
+
window_start = now - timedelta(hours=5)
|
|
68
|
+
rate = calculate_burn_rate([], LimitType.SESSION, window_start, window_hours=5)
|
|
67
69
|
assert rate == 0.0
|
|
68
70
|
|
|
69
71
|
def test_one_snapshot(self):
|
|
70
72
|
"""Burn rate should be 0 with only one snapshot."""
|
|
71
73
|
now = datetime.now(timezone.utc)
|
|
74
|
+
window_start = now - timedelta(hours=3)
|
|
72
75
|
snapshot = UsageSnapshot(
|
|
73
76
|
timestamp=now,
|
|
74
77
|
session=LimitData(
|
|
@@ -80,18 +83,25 @@ class TestCalculateBurnRate:
|
|
|
80
83
|
weekly_sonnet=None,
|
|
81
84
|
weekly_opus=None,
|
|
82
85
|
)
|
|
83
|
-
rate = calculate_burn_rate([snapshot], LimitType.SESSION)
|
|
86
|
+
rate = calculate_burn_rate([snapshot], LimitType.SESSION, window_start, window_hours=5)
|
|
84
87
|
assert rate == 0.0
|
|
85
88
|
|
|
86
89
|
def test_increasing_usage(self, sample_snapshots):
|
|
87
90
|
"""Burn rate should be positive when usage is increasing."""
|
|
88
|
-
|
|
91
|
+
# sample_snapshots are from 2026-01-08 14:00:00 to 14:04:30 (4.5 min span)
|
|
92
|
+
# Window starts before the first snapshot
|
|
93
|
+
# Use 30-minute window so 4.5 min span > 10% minimum (3 min)
|
|
94
|
+
window_start = datetime(2026, 1, 8, 13, 55, 0, tzinfo=timezone.utc)
|
|
95
|
+
rate = calculate_burn_rate(
|
|
96
|
+
sample_snapshots, LimitType.SESSION, window_start, window_hours=0.5
|
|
97
|
+
)
|
|
89
98
|
# With 10% increase over 4.5 minutes, rate should be ~133%/hour
|
|
90
99
|
assert rate > 0
|
|
91
100
|
|
|
92
101
|
def test_constant_usage(self):
|
|
93
102
|
"""Burn rate should be 0 when usage is constant."""
|
|
94
103
|
now = datetime.now(timezone.utc)
|
|
104
|
+
window_start = now - timedelta(hours=1)
|
|
95
105
|
snapshots = []
|
|
96
106
|
for i in range(5):
|
|
97
107
|
ts = now - timedelta(minutes=i)
|
|
@@ -109,7 +119,7 @@ class TestCalculateBurnRate:
|
|
|
109
119
|
snapshots.append(snapshot)
|
|
110
120
|
|
|
111
121
|
snapshots.sort(key=lambda s: s.timestamp)
|
|
112
|
-
rate = calculate_burn_rate(snapshots, LimitType.SESSION,
|
|
122
|
+
rate = calculate_burn_rate(snapshots, LimitType.SESSION, window_start, window_hours=5)
|
|
113
123
|
assert rate == pytest.approx(0.0, abs=0.1)
|
|
114
124
|
|
|
115
125
|
|
|
@@ -127,7 +127,9 @@ class TestCLI:
|
|
|
127
127
|
|
|
128
128
|
@patch("ccburn.app.CCBurnApp")
|
|
129
129
|
def test_since_flag(self, mock_app_class):
|
|
130
|
-
"""Test --since flag sets time window."""
|
|
130
|
+
"""Test --since flag sets time window as sliding duration."""
|
|
131
|
+
from datetime import timedelta
|
|
132
|
+
|
|
131
133
|
mock_app = MagicMock()
|
|
132
134
|
mock_app.run.return_value = 0
|
|
133
135
|
mock_app_class.return_value = mock_app
|
|
@@ -136,4 +138,4 @@ class TestCLI:
|
|
|
136
138
|
|
|
137
139
|
mock_app_class.assert_called_once()
|
|
138
140
|
call_kwargs = mock_app_class.call_args.kwargs
|
|
139
|
-
assert call_kwargs["
|
|
141
|
+
assert call_kwargs["since_duration"] == timedelta(hours=2) # timedelta for sliding window
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|