devalerts 0.1.4__tar.gz → 0.1.5__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.1.4
3
+ Version: 0.1.5
4
4
  Summary: Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token.
5
5
  Keywords: telegram,error-tracking,exception-monitoring,alerts,logging,monitoring,asgi
6
6
  Author: sslinNn
@@ -28,8 +28,12 @@ Description-Content-Type: text/markdown
28
28
  # devalerts
29
29
 
30
30
  [![PyPI](https://img.shields.io/pypi/v/devalerts)](https://pypi.org/project/devalerts/)
31
+ [![Downloads](https://img.shields.io/pypi/dm/devalerts)](https://pypi.org/project/devalerts/)
31
32
  [![Python versions](https://img.shields.io/pypi/pyversions/devalerts)](https://pypi.org/project/devalerts/)
32
33
  [![License: MIT](https://img.shields.io/pypi/l/devalerts)](LICENSE)
34
+ [![Tests](https://img.shields.io/github/actions/workflow/status/sslinNn/devalerts/test.yml?branch=main&label=tests)](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
35
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
36
+ [![mypy](https://img.shields.io/badge/mypy-checked-blue)](https://mypy-lang.org/)
33
37
 
34
38
  [Русская версия](README.ru.md)
35
39
 
@@ -103,14 +107,7 @@ See what's grouped and what's currently rate-limited:
103
107
  uv run devalerts dashboard
104
108
  ```
105
109
 
106
- ```
107
- ID TYPE LOCATION LAST SEEN TOTAL STATUS
108
- ────────────────────────────────────────────────────────────────────────────────────
109
- a1b2c3d4 ValueError app/orders.py:42 2m ago 14 ● limited
110
- 9f8e7d6c KeyError app/handlers/webhook.py:88 just now 1 ● sending
111
-
112
- 2 error groups, 1 currently rate-limited.
113
- ```
110
+ ![devalerts dashboard output](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/dashboard.svg)
114
111
 
115
112
  ## Manually reporting a caught exception
116
113
 
@@ -151,11 +148,12 @@ Only exceptions that actually escape as server errors get reported — routing
151
148
  404s and raised `HTTPException`s are already turned into responses by the
152
149
  framework before the middleware sees them.
153
150
 
154
- ## devalerts vs. a full error tracker
151
+ ## Why not Sentry?
155
152
 
156
- If you already run Sentry/Rollbar/etc., keep using it — this isn't a
157
- replacement. devalerts is for the side project, internal tool, or small
158
- service that doesn't have (and doesn't want) that infrastructure yet:
153
+ If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
154
+ replacement. It's for the side project, internal tool, or small service that
155
+ doesn't have (and doesn't want) that infrastructure: no account to create, no
156
+ SDK to configure, no server to trust — just a bot token you already control.
159
157
 
160
158
  | | devalerts | Sentry-style tracker |
161
159
  |--------------------------|---------------------|-----------------------|
@@ -165,14 +163,63 @@ service that doesn't have (and doesn't want) that infrastructure yet:
165
163
  | Grouping / rate limiting | yes, local SQLite | yes, server-side |
166
164
  | Search, trends, releases | no | yes |
167
165
 
166
+ ## FAQ
167
+
168
+ **Works with FastAPI / Starlette?**
169
+ Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
170
+ default excepthook never sees request errors.
171
+
172
+ **Works with Docker?**
173
+ Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
174
+ state file) is either writable inside the container or a mounted volume if
175
+ you want dedup state to survive restarts — it self-recreates otherwise.
176
+
177
+ **Works with threads?**
178
+ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
179
+
180
+ **Works with Celery / background workers?**
181
+ Yes, `init()` in the worker process the same way as in a web process. For
182
+ tasks you catch yourself, use `report()` or `@devalerts.capture()`.
183
+
184
+ **Works on Windows / Linux / macOS?**
185
+ Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
186
+ paths.
187
+
188
+ ## Privacy & Security
189
+
190
+ - The only network call devalerts makes is to `api.telegram.org` — no
191
+ telemetry, no analytics, nothing else phones home.
192
+ - No third-party server and no devalerts-run backend — messages go straight
193
+ from your process to your own Telegram bot.
194
+ - No accounts, no signup, no API key beyond the bot token you create and
195
+ control yourself.
196
+ - Basic secret redaction only (a few common token/key patterns) — do not
197
+ rely on this for sensitive production data; scrub what you can before it
198
+ ever reaches an exception message.
199
+
168
200
  ## What this does NOT do (by design)
169
201
 
170
202
  - Grouping/rate limiting is local and in-process only (SQLite file, no
171
203
  server) — the dashboard is a CLI table, not a web UI.
172
204
  - No backend, no accounts — each user runs their own bot.
173
- - Basic secret redaction only (a few common token patterns) — do not rely
174
- on this for sensitive production data.
175
- - No automated test suite — verified manually during implementation only.
205
+
206
+ ## Roadmap
207
+
208
+ - Web dashboard (hosted, optional — the local CLI dashboard stays either way)
209
+ - Slack delivery
210
+ - Discord delivery
211
+ - Email delivery
212
+
213
+ ## Development
214
+
215
+ uv sync --group dev
216
+ uv run pre-commit install
217
+
218
+ `pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
219
+ Run the full check manually with:
220
+
221
+ uv run pytest
222
+ uv run pre-commit run --all-files
176
223
 
177
224
  ## License
178
225
 
@@ -1,8 +1,12 @@
1
1
  # devalerts
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/devalerts)](https://pypi.org/project/devalerts/)
4
+ [![Downloads](https://img.shields.io/pypi/dm/devalerts)](https://pypi.org/project/devalerts/)
4
5
  [![Python versions](https://img.shields.io/pypi/pyversions/devalerts)](https://pypi.org/project/devalerts/)
5
6
  [![License: MIT](https://img.shields.io/pypi/l/devalerts)](LICENSE)
7
+ [![Tests](https://img.shields.io/github/actions/workflow/status/sslinNn/devalerts/test.yml?branch=main&label=tests)](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
8
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
+ [![mypy](https://img.shields.io/badge/mypy-checked-blue)](https://mypy-lang.org/)
6
10
 
7
11
  [Русская версия](README.ru.md)
8
12
 
@@ -76,14 +80,7 @@ See what's grouped and what's currently rate-limited:
76
80
  uv run devalerts dashboard
77
81
  ```
78
82
 
79
- ```
80
- ID TYPE LOCATION LAST SEEN TOTAL STATUS
81
- ────────────────────────────────────────────────────────────────────────────────────
82
- a1b2c3d4 ValueError app/orders.py:42 2m ago 14 ● limited
83
- 9f8e7d6c KeyError app/handlers/webhook.py:88 just now 1 ● sending
84
-
85
- 2 error groups, 1 currently rate-limited.
86
- ```
83
+ ![devalerts dashboard output](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/dashboard.svg)
87
84
 
88
85
  ## Manually reporting a caught exception
89
86
 
@@ -124,11 +121,12 @@ Only exceptions that actually escape as server errors get reported — routing
124
121
  404s and raised `HTTPException`s are already turned into responses by the
125
122
  framework before the middleware sees them.
126
123
 
127
- ## devalerts vs. a full error tracker
124
+ ## Why not Sentry?
128
125
 
129
- If you already run Sentry/Rollbar/etc., keep using it — this isn't a
130
- replacement. devalerts is for the side project, internal tool, or small
131
- service that doesn't have (and doesn't want) that infrastructure yet:
126
+ If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
127
+ replacement. It's for the side project, internal tool, or small service that
128
+ doesn't have (and doesn't want) that infrastructure: no account to create, no
129
+ SDK to configure, no server to trust — just a bot token you already control.
132
130
 
133
131
  | | devalerts | Sentry-style tracker |
134
132
  |--------------------------|---------------------|-----------------------|
@@ -138,14 +136,63 @@ service that doesn't have (and doesn't want) that infrastructure yet:
138
136
  | Grouping / rate limiting | yes, local SQLite | yes, server-side |
139
137
  | Search, trends, releases | no | yes |
140
138
 
139
+ ## FAQ
140
+
141
+ **Works with FastAPI / Starlette?**
142
+ Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
143
+ default excepthook never sees request errors.
144
+
145
+ **Works with Docker?**
146
+ Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
147
+ state file) is either writable inside the container or a mounted volume if
148
+ you want dedup state to survive restarts — it self-recreates otherwise.
149
+
150
+ **Works with threads?**
151
+ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
152
+
153
+ **Works with Celery / background workers?**
154
+ Yes, `init()` in the worker process the same way as in a web process. For
155
+ tasks you catch yourself, use `report()` or `@devalerts.capture()`.
156
+
157
+ **Works on Windows / Linux / macOS?**
158
+ Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
159
+ paths.
160
+
161
+ ## Privacy & Security
162
+
163
+ - The only network call devalerts makes is to `api.telegram.org` — no
164
+ telemetry, no analytics, nothing else phones home.
165
+ - No third-party server and no devalerts-run backend — messages go straight
166
+ from your process to your own Telegram bot.
167
+ - No accounts, no signup, no API key beyond the bot token you create and
168
+ control yourself.
169
+ - Basic secret redaction only (a few common token/key patterns) — do not
170
+ rely on this for sensitive production data; scrub what you can before it
171
+ ever reaches an exception message.
172
+
141
173
  ## What this does NOT do (by design)
142
174
 
143
175
  - Grouping/rate limiting is local and in-process only (SQLite file, no
144
176
  server) — the dashboard is a CLI table, not a web UI.
145
177
  - No backend, no accounts — each user runs their own bot.
146
- - Basic secret redaction only (a few common token patterns) — do not rely
147
- on this for sensitive production data.
148
- - No automated test suite — verified manually during implementation only.
178
+
179
+ ## Roadmap
180
+
181
+ - Web dashboard (hosted, optional — the local CLI dashboard stays either way)
182
+ - Slack delivery
183
+ - Discord delivery
184
+ - Email delivery
185
+
186
+ ## Development
187
+
188
+ uv sync --group dev
189
+ uv run pre-commit install
190
+
191
+ `pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
192
+ Run the full check manually with:
193
+
194
+ uv run pytest
195
+ uv run pre-commit run --all-files
149
196
 
150
197
  ## License
151
198
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devalerts"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -43,8 +43,19 @@ Repository = "https://github.com/sslinNn/devalerts"
43
43
  devalerts = "devalerts.cli:main"
44
44
 
45
45
  [build-system]
46
- requires = ["uv_build>=0.10.2,<0.11.0"]
46
+ requires = ["uv_build>=0.10.2,<0.12.0"]
47
47
  build-backend = "uv_build"
48
48
 
49
49
  [dependency-groups]
50
- dev = []
50
+ dev = [
51
+ "mypy>=1.19.1",
52
+ "pre-commit>=4.3.0",
53
+ "pytest>=8.4.2",
54
+ "ruff>=0.15.22",
55
+ ]
56
+
57
+ [tool.ruff]
58
+ target-version = "py39"
59
+
60
+ [tool.ruff.lint]
61
+ extend-select = ["BLE"]
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  import contextlib
6
6
  import sys
7
7
  import threading
8
+ from types import TracebackType
9
+ from typing import Callable, Literal, Optional, TypedDict
8
10
 
9
11
  from ._alert import _format_alert, _redact
10
12
  from ._store import _DEFAULT_RATE_LIMIT_SECONDS, _fingerprint, _should_send
@@ -12,19 +14,43 @@ from ._telegram import _send_telegram_message
12
14
 
13
15
  __all__: list[str] = ["init", "report", "capture", "ASGIMiddleware"]
14
16
 
15
- _state = {
17
+ _ExceptHook = Callable[
18
+ [type[BaseException], BaseException, Optional[TracebackType]], object
19
+ ]
20
+ _ThreadingExceptHook = Callable[[threading.ExceptHookArgs], object]
21
+
22
+
23
+ class _State(TypedDict):
24
+ bot_token: str | None
25
+ chat_id: int | str | None
26
+ redact: bool
27
+ rate_limit_seconds: int
28
+ # Seeded with the real default hooks below (never None) -- _excepthook/
29
+ # _threading_excepthook can only ever fire after init() has replaced
30
+ # sys.excepthook/threading.excepthook, by which point these are always
31
+ # set, so keeping them non-Optional avoids an unreachable None check.
32
+ prev_excepthook: _ExceptHook
33
+ prev_threading_excepthook: _ThreadingExceptHook
34
+
35
+
36
+ _state: _State = {
16
37
  "bot_token": None,
17
38
  "chat_id": None,
18
39
  "redact": True,
19
40
  "rate_limit_seconds": _DEFAULT_RATE_LIMIT_SECONDS,
20
- "prev_excepthook": None,
21
- "prev_threading_excepthook": None,
41
+ "prev_excepthook": sys.excepthook,
42
+ "prev_threading_excepthook": threading.excepthook,
22
43
  }
23
44
 
24
45
 
25
46
  def _send_exception(exc_type, exc_value, tb) -> None:
47
+ if _state["bot_token"] is None or _state["chat_id"] is None:
48
+ print("devalerts: init() was not called, dropping alert", file=sys.stderr)
49
+ return
26
50
  fingerprint, location = _fingerprint(exc_type, tb)
27
- send, skipped = _should_send(fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"])
51
+ send, skipped = _should_send(
52
+ fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"]
53
+ )
28
54
  if not send:
29
55
  return
30
56
  message = _format_alert(exc_type, exc_value, tb, skipped=skipped)
@@ -38,7 +64,10 @@ def _excepthook(exc_type, exc_value, tb) -> None:
38
64
  try:
39
65
  _send_exception(exc_type, exc_value, tb)
40
66
  except Exception as error: # noqa: BLE001 - crash handler must never raise
41
- print(f"devalerts: internal error while sending alert: {error}", file=sys.stderr)
67
+ print(
68
+ f"devalerts: internal error while sending alert: {error}",
69
+ file=sys.stderr,
70
+ )
42
71
  _state["prev_excepthook"](exc_type, exc_value, tb)
43
72
 
44
73
 
@@ -46,13 +75,15 @@ def _threading_excepthook(args) -> None:
46
75
  try:
47
76
  _send_exception(args.exc_type, args.exc_value, args.exc_traceback)
48
77
  except Exception as error: # noqa: BLE001
49
- print(f"devalerts: internal error while sending alert: {error}", file=sys.stderr)
78
+ print(
79
+ f"devalerts: internal error while sending alert: {error}", file=sys.stderr
80
+ )
50
81
  _state["prev_threading_excepthook"](args)
51
82
 
52
83
 
53
84
  def init(
54
85
  bot_token: str,
55
- chat_id,
86
+ chat_id: int | str,
56
87
  *,
57
88
  redact: bool = True,
58
89
  rate_limit_seconds: int = _DEFAULT_RATE_LIMIT_SECONDS,
@@ -78,7 +109,9 @@ def report(exc: BaseException | None = None) -> None:
78
109
  if exc is None:
79
110
  exc_type, exc_value, tb = sys.exc_info()
80
111
  if exc_type is None:
81
- raise RuntimeError("report() requires an active exception or an exc argument")
112
+ raise RuntimeError(
113
+ "report() requires an active exception or an exc argument"
114
+ )
82
115
  else:
83
116
  exc_type, exc_value, tb = type(exc), exc, exc.__traceback__
84
117
  _send_exception(exc_type, exc_value, tb)
@@ -92,7 +125,7 @@ class capture(contextlib.ContextDecorator):
92
125
  def __enter__(self) -> "capture":
93
126
  return self
94
127
 
95
- def __exit__(self, exc_type, exc_value, tb) -> bool:
128
+ def __exit__(self, exc_type, exc_value, tb) -> Literal[False]:
96
129
  if exc_type is not None:
97
130
  _send_exception(exc_type, exc_value, tb)
98
131
  return False
@@ -9,7 +9,7 @@ _MAX_MESSAGE_LENGTH = 4096
9
9
 
10
10
 
11
11
  def _format_alert(exc_type, exc_value, tb, skipped: int = 0) -> str:
12
- header = f"\U0001F534 {exc_type.__name__}: {exc_value}"
12
+ header = f"\U0001f534 {exc_type.__name__}: {exc_value}"
13
13
  if skipped:
14
14
  header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
15
15
  body = "".join(traceback.format_exception(exc_type, exc_value, tb))
@@ -42,7 +42,9 @@ def _get_connection() -> sqlite3.Connection:
42
42
  return conn
43
43
 
44
44
 
45
- def _should_send(fingerprint: str, exc_type_name: str, location: str, rate_limit_seconds: int) -> tuple[bool, int]:
45
+ def _should_send(
46
+ fingerprint: str, exc_type_name: str, location: str, rate_limit_seconds: int
47
+ ) -> tuple[bool, int]:
46
48
  now = time.time()
47
49
  try:
48
50
  conn = _get_connection()
@@ -79,12 +81,18 @@ def _should_send(fingerprint: str, exc_type_name: str, location: str, rate_limit
79
81
  """,
80
82
  (now, fingerprint),
81
83
  )
82
- conn.execute("DELETE FROM error_groups WHERE last_seen < ?", (now - _RETENTION_SECONDS,))
84
+ conn.execute(
85
+ "DELETE FROM error_groups WHERE last_seen < ?",
86
+ (now - _RETENTION_SECONDS,),
87
+ )
83
88
  return send, skipped
84
89
  finally:
85
90
  conn.close()
86
91
  except (sqlite3.Error, OSError) as error:
87
92
  # ponytail: dedup/rate-limit state must never block an alert -- fail
88
93
  # open (send, as if this were the first occurrence) on any DB error.
89
- print(f"devalerts: dedup/rate-limit state error, sending anyway: {error}", file=sys.stderr)
94
+ print(
95
+ f"devalerts: dedup/rate-limit state error, sending anyway: {error}",
96
+ file=sys.stderr,
97
+ )
90
98
  return True, 0
@@ -10,7 +10,7 @@ import urllib.request
10
10
  _TIMEOUT_SECONDS = 5
11
11
 
12
12
 
13
- def _send_telegram_message(bot_token: str, chat_id, text: str) -> None:
13
+ def _send_telegram_message(bot_token: str, chat_id: int | str, text: str) -> None:
14
14
  url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
15
15
  payload = json.dumps({"chat_id": chat_id, "text": text}).encode("utf-8")
16
16
  request = urllib.request.Request(
@@ -118,7 +118,9 @@ def _dashboard() -> int:
118
118
  # doesn't know the app's actual rate_limit_seconds -- uses the
119
119
  # library default. Upgrade to persisting the configured value if
120
120
  # apps commonly override it.
121
- in_window = last_sent is not None and now - last_sent < _DEFAULT_RATE_LIMIT_SECONDS
121
+ in_window = (
122
+ last_sent is not None and now - last_sent < _DEFAULT_RATE_LIMIT_SECONDS
123
+ )
122
124
  if in_window:
123
125
  limited_count += 1
124
126
  status = style.red(f"{dot_char} limited")
File without changes