devalerts 0.3.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devalerts
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Send unhandled Python exceptions straight to a Telegram chat (or Slack). No backend, no account, no database — just your own bot token or webhook URL.
5
5
  Keywords: telegram,error-tracking,exception-monitoring,alerts,logging,monitoring,asgi
6
6
  Author: sslinNn
@@ -139,6 +139,34 @@ resend doubles the effective `rate_limit_seconds` for that group (capped at
139
139
  forever. A group that goes quiet and reappears once resets to the base rate
140
140
  immediately. The dashboard shows the active multiplier (`● sending ×4`).
141
141
 
142
+ ## Crash streak badge
143
+
144
+ `devalerts dashboard` also reports how long it's been since the last
145
+ recorded incident (sent or suppressed by rate-limiting) — same idea as the
146
+ classic "days since last workplace accident" sign, applied to your crashes:
147
+
148
+ ```
149
+ 🟢 14 days since the last incident.
150
+ ```
151
+
152
+ Get the same number as a shareable SVG badge for your README:
153
+
154
+ ```
155
+ uv run devalerts badge --out docs/streak.svg
156
+ ```
157
+
158
+ ![devalerts crash streak badge](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/streak-badge.svg)
159
+
160
+ No hosting, no cron job built in — regenerate and commit it yourself,
161
+ e.g. on a schedule with GitHub Actions:
162
+
163
+ ```yaml
164
+ - run: uv run devalerts badge --out docs/streak.svg
165
+ - run: git add docs/streak.svg && git commit -m "Update crash streak badge" || true
166
+ ```
167
+
168
+ Pass `--label` to customize the left-hand text (default `"crash streak"`).
169
+
142
170
  ## Context: hostname and tags
143
171
 
144
172
  Every alert automatically includes the sending host, so you can tell which
@@ -112,6 +112,34 @@ resend doubles the effective `rate_limit_seconds` for that group (capped at
112
112
  forever. A group that goes quiet and reappears once resets to the base rate
113
113
  immediately. The dashboard shows the active multiplier (`● sending ×4`).
114
114
 
115
+ ## Crash streak badge
116
+
117
+ `devalerts dashboard` also reports how long it's been since the last
118
+ recorded incident (sent or suppressed by rate-limiting) — same idea as the
119
+ classic "days since last workplace accident" sign, applied to your crashes:
120
+
121
+ ```
122
+ 🟢 14 days since the last incident.
123
+ ```
124
+
125
+ Get the same number as a shareable SVG badge for your README:
126
+
127
+ ```
128
+ uv run devalerts badge --out docs/streak.svg
129
+ ```
130
+
131
+ ![devalerts crash streak badge](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/streak-badge.svg)
132
+
133
+ No hosting, no cron job built in — regenerate and commit it yourself,
134
+ e.g. on a schedule with GitHub Actions:
135
+
136
+ ```yaml
137
+ - run: uv run devalerts badge --out docs/streak.svg
138
+ - run: git add docs/streak.svg && git commit -m "Update crash streak badge" || true
139
+ ```
140
+
141
+ Pass `--label` to customize the left-hand text (default `"crash streak"`).
142
+
115
143
  ## Context: hostname and tags
116
144
 
117
145
  Every alert automatically includes the sending host, so you can tell which
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devalerts"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Send unhandled Python exceptions straight to a Telegram chat (or Slack). No backend, no account, no database — just your own bot token or webhook URL."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,55 @@
1
+ """Crash-streak SVG badge rendering for `devalerts badge` -- no network call,
2
+ no shields.io dependency, just a hand-rolled flat badge."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from xml.sax.saxutils import escape
7
+
8
+ _BAND_COLORS = {
9
+ "grey": "#9f9f9f",
10
+ "red": "#e05d44",
11
+ "yellow": "#dfb317",
12
+ "green": "#4c1",
13
+ }
14
+
15
+
16
+ def _streak_band(days: int | None) -> str:
17
+ if days is None:
18
+ return "grey"
19
+ if days < 1:
20
+ return "red"
21
+ if days < 7:
22
+ return "yellow"
23
+ return "green"
24
+
25
+
26
+ def _streak_text(days: int | None) -> str:
27
+ if days is None:
28
+ return "no incidents yet"
29
+ if days < 1:
30
+ return "today"
31
+ return f"{days} day{'' if days == 1 else 's'}"
32
+
33
+
34
+ def _render_badge(label: str, days: int | None) -> str:
35
+ """Flat shields.io-style badge. Character width is a fixed-per-glyph
36
+ approximation, not real font metrics -- fine for a README image, not
37
+ meant to be pixel-perfect."""
38
+ value = _streak_text(days)
39
+ color = _BAND_COLORS[_streak_band(days)]
40
+ label_width = 10 + 7 * len(label)
41
+ value_width = 10 + 7 * len(value)
42
+ width = label_width + value_width
43
+ height = 20
44
+ label_esc = escape(label)
45
+ value_esc = escape(value)
46
+ return (
47
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}">'
48
+ f'<rect width="{label_width}" height="{height}" fill="#555"/>'
49
+ f'<rect x="{label_width}" width="{value_width}" height="{height}" fill="{color}"/>'
50
+ f'<g fill="#fff" font-family="Verdana,Geneva,sans-serif" font-size="11">'
51
+ f'<text x="{label_width / 2}" y="14" text-anchor="middle">{label_esc}</text>'
52
+ f'<text x="{label_width + value_width / 2}" y="14" text-anchor="middle">'
53
+ f"{value_esc}</text>"
54
+ f"</g></svg>"
55
+ )
@@ -152,6 +152,18 @@ def _should_send(
152
152
  return True, 0, True
153
153
 
154
154
 
155
+ def _last_incident() -> float | None:
156
+ """Unix timestamp of the most recent occurrence across all groups (sent or
157
+ suppressed -- last_seen is updated either way), or None if the state DB
158
+ has no groups recorded (fresh install, or everything cleared)."""
159
+ conn = _get_connection()
160
+ try:
161
+ row = conn.execute("SELECT MAX(last_seen) FROM error_groups").fetchone()
162
+ finally:
163
+ conn.close()
164
+ return row[0]
165
+
166
+
155
167
  def _match_fingerprints(prefix: str) -> list[str]:
156
168
  """Fingerprints starting with prefix, for CLI mute/unmute/clear commands."""
157
169
  conn = _get_connection()
@@ -1,6 +1,7 @@
1
1
  """CLI: `devalerts dashboard` reports grouped/rate-limited errors from the local
2
2
  state DB; `devalerts test` sends a one-off message to verify bot_token/chat_id
3
- and/or slack_webhook_url."""
3
+ and/or slack_webhook_url; `devalerts badge` renders a 'days since last
4
+ incident' SVG badge."""
4
5
 
5
6
  from __future__ import annotations
6
7
 
@@ -11,13 +12,16 @@ import sqlite3
11
12
  import sys
12
13
  import time
13
14
  from importlib.metadata import version as _pkg_version
15
+ from pathlib import Path
14
16
 
17
+ from ._badge import _render_badge, _streak_band
15
18
  from ._slack import _send_slack_message
16
19
  from ._store import (
17
20
  _DB_PATH,
18
21
  _DEFAULT_RATE_LIMIT_SECONDS,
19
22
  _clear,
20
23
  _clear_all,
24
+ _last_incident,
21
25
  _match_fingerprints,
22
26
  _set_muted,
23
27
  )
@@ -54,6 +58,23 @@ def _relative_time(ts: float, now: float) -> str:
54
58
  return f"{int(delta // 86400)}d ago"
55
59
 
56
60
 
61
+ def _streak_line(last_incident: float, now: float, unicode_ok: bool) -> str:
62
+ days = int((now - last_incident) // 86400)
63
+ band = _streak_band(days)
64
+ icons = (
65
+ {"red": "🔴", "yellow": "🟡", "green": "🟢"}
66
+ if unicode_ok
67
+ else {"red": "[RESET]", "yellow": "[WARN]", "green": "[OK]"}
68
+ )
69
+ if band == "red":
70
+ return (
71
+ f"{icons['red']} New incident {_relative_time(last_incident, now)}"
72
+ " — streak reset to 0 days."
73
+ )
74
+ plural = "" if days == 1 else "s"
75
+ return f"{icons[band]} {days} day{plural} since the last incident."
76
+
77
+
57
78
  def _color_enabled() -> bool:
58
79
  if os.environ.get("NO_COLOR") is not None or not sys.stdout.isatty():
59
80
  return False
@@ -157,6 +178,9 @@ def _dashboard(as_json: bool = False) -> int:
157
178
  ellipsis = "…" if unicode_ok else "..."
158
179
  times_char = "×" if unicode_ok else "x"
159
180
 
181
+ print(_streak_line(rows[0][3], now, unicode_ok))
182
+ print()
183
+
160
184
  style = _Style(_color_enabled())
161
185
  type_width = max(len("TYPE"), *(len(r[1]) for r in rows))
162
186
 
@@ -255,6 +279,18 @@ def _clear_command(prefix: str | None, clear_all: bool) -> int:
255
279
  return 0
256
280
 
257
281
 
282
+ def _badge_command(out: str | None, label: str) -> int:
283
+ incident = _last_incident()
284
+ days = int((time.time() - incident) // 86400) if incident is not None else None
285
+ svg = _render_badge(label, days)
286
+ if out:
287
+ Path(out).write_text(svg, encoding="utf-8")
288
+ print(f"Wrote badge to {out}.")
289
+ else:
290
+ print(svg)
291
+ return 0
292
+
293
+
258
294
  def _test(
259
295
  bot_token: str | None, chat_id: str | None, slack_webhook_url: str | None
260
296
  ) -> int:
@@ -330,6 +366,17 @@ def main(argv: list[str] | None = None) -> int:
330
366
  clear_target.add_argument(
331
367
  "--all", action="store_true", help="Delete all error groups"
332
368
  )
369
+ badge_parser = subparsers.add_parser(
370
+ "badge", help="Print or write an SVG 'days since last incident' badge"
371
+ )
372
+ badge_parser.add_argument(
373
+ "--out", help="Write the SVG to this path instead of stdout"
374
+ )
375
+ badge_parser.add_argument(
376
+ "--label",
377
+ default="crash streak",
378
+ help="Badge label text (default: 'crash streak')",
379
+ )
333
380
  args = parser.parse_args(argv)
334
381
 
335
382
  if args.command == "dashboard":
@@ -342,6 +389,8 @@ def main(argv: list[str] | None = None) -> int:
342
389
  return _mute_command(args.fingerprint, muted=False)
343
390
  if args.command == "clear":
344
391
  return _clear_command(args.fingerprint, args.all)
392
+ if args.command == "badge":
393
+ return _badge_command(args.out, args.label)
345
394
  return 1
346
395
 
347
396
 
File without changes