github-actions[bot] commited on
Commit
ce89343
·
1 Parent(s): 84e63a1

Automated sync from GitHub

Browse files
Files changed (5) hide show
  1. .gitattributes +2 -2
  2. bot/db.py +3 -1
  3. bot/errorlog.py +54 -4
  4. tests/test_db.py +27 -0
  5. tests/test_errorlog.py +67 -8
.gitattributes CHANGED
@@ -1,8 +1,8 @@
1
- *.mp4 filter=lfs diff=lfs merge=lfs -text
2
- *.mp3 filter=lfs diff=lfs merge=lfs -text
3
  *.jpg filter=lfs diff=lfs merge=lfs -text
4
  *.jpeg filter=lfs diff=lfs merge=lfs -text
5
  *.png filter=lfs diff=lfs merge=lfs -text
6
  *.ttf filter=lfs diff=lfs merge=lfs -text
7
  *.otf filter=lfs diff=lfs merge=lfs -text
8
  *.webp filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
1
  *.jpg filter=lfs diff=lfs merge=lfs -text
2
  *.jpeg filter=lfs diff=lfs merge=lfs -text
3
  *.png filter=lfs diff=lfs merge=lfs -text
4
  *.ttf filter=lfs diff=lfs merge=lfs -text
5
  *.otf filter=lfs diff=lfs merge=lfs -text
6
  *.webp filter=lfs diff=lfs merge=lfs -text
7
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
8
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
bot/db.py CHANGED
@@ -73,7 +73,9 @@ class Admin(Base):
73
 
74
 
75
  class Format(Base):
76
- __tablename__ = "formats"
 
 
77
 
78
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
79
  user_id: Mapped[int] = mapped_column(BigInteger, index=True)
 
73
 
74
 
75
  class Format(Base):
76
+ # Not just "formats": the Postgres may be shared with another app that already has a table of that name, and
77
+ # create_all() leaves an existing table alone, so ours would never be created.
78
+ __tablename__ = "rename_formats"
79
 
80
  id: Mapped[int] = mapped_column(Integer, primary_key=True)
81
  user_id: Mapped[int] = mapped_column(BigInteger, index=True)
bot/errorlog.py CHANGED
@@ -14,6 +14,7 @@ import re
14
  import time
15
  from collections import deque
16
  from dataclasses import dataclass
 
17
  from typing import Any, Awaitable, Callable, Deque, Dict, Iterable, List, Optional, Set, Tuple
18
  from urllib.parse import urlsplit
19
 
@@ -56,7 +57,7 @@ def describe_update(event: Any) -> str:
56
 
57
 
58
  _SQL_PARAMS = re.compile(r"\[parameters: [^\n]*\]") # SQLAlchemy prints the values it bound, e.g. a saved session
59
- _TOKENISH = re.compile(r"[A-Za-z0-9_\-+/=]{60,}") # session strings, access tokens, long hashes
60
 
61
 
62
  def _mask_token(match: "re.Match[str]") -> str:
@@ -65,7 +66,7 @@ def _mask_token(match: "re.Match[str]") -> str:
65
 
66
 
67
  def redact(text: str, secrets: Iterable[str] = ()) -> str:
68
- """Hide the configured secrets and anything that looks like a session string or access token."""
69
  for secret in secrets:
70
  if len(secret) >= 6:
71
  text = text.replace(secret, "***")
@@ -101,14 +102,63 @@ def _tail(text: str, units: int) -> str:
101
  return tail
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def _fit(head: str, trace: str) -> str:
105
- """``head`` + the traceback in at most ``MAX_TEXT``: when it is too long the end is kept, as that holds the error."""
 
106
  if not trace:
107
  return clip(head, MAX_TEXT // 2)
108
  head = clip(head, HEAD_LIMIT)
109
  room = MAX_TEXT - _u16(head) - 2
 
110
  if _u16(trace) > room:
111
- trace = "…\n" + _tail(trace, room - 2)
 
 
112
  return head + "\n\n" + trace
113
 
114
 
 
14
  import time
15
  from collections import deque
16
  from dataclasses import dataclass
17
+ from itertools import groupby
18
  from typing import Any, Awaitable, Callable, Deque, Dict, Iterable, List, Optional, Set, Tuple
19
  from urllib.parse import urlsplit
20
 
 
57
 
58
 
59
  _SQL_PARAMS = re.compile(r"\[parameters: [^\n]*\]") # SQLAlchemy prints the values it bound, e.g. a saved session
60
+ _TOKENISH = re.compile(r"[A-Za-z0-9_\-+=]{100,}") # a session string is ~350 characters; no path or file name has 100 in a row
61
 
62
 
63
  def _mask_token(match: "re.Match[str]") -> str:
 
66
 
67
 
68
  def redact(text: str, secrets: Iterable[str] = ()) -> str:
69
+ """Hide the configured secrets and anything that looks like a session string."""
70
  for secret in secrets:
71
  if len(secret) >= 6:
72
  text = text.replace(secret, "***")
 
102
  return tail
103
 
104
 
105
+ def _head(text: str, units: int) -> str:
106
+ """The beginning of ``text`` that fits in ``units`` UTF-16 units, ending at the end of a line."""
107
+ used, end = 0, len(text)
108
+ for i, char in enumerate(text):
109
+ used += 2 if ord(char) > 0xFFFF else 1
110
+ if used > units:
111
+ end = i
112
+ break
113
+ if end == len(text):
114
+ return text
115
+ head = text[:end]
116
+ newline = head.rfind("\n")
117
+ return head[:newline] if newline != -1 else head
118
+
119
+
120
+ _LIBRARY_FRAME = re.compile(r' File ".*[\\/](?:site-packages|dist-packages|lib[\\/]python[\d.]+)[\\/]')
121
+ _CUT = "\n…\n"
122
+
123
+
124
+ def _elide(trace: str, room: int) -> str:
125
+ """Make a traceback fit ``room`` by replacing runs of frames from libraries (SQLAlchemy, Telethon, asyncio...) with
126
+ one line each: the bot's own frames and the error stay. Runs of three or more go first, then every run."""
127
+ if _u16(trace) <= room:
128
+ return trace
129
+ blocks: List[List[str]] = [] # a frame is its "File" line and the source lines under it; any other line is a block of its own
130
+ for line in trace.split("\n"):
131
+ if blocks and line.startswith(" ") and blocks[-1][0].startswith(" File "):
132
+ blocks[-1].append(line)
133
+ else:
134
+ blocks.append([line])
135
+ text = trace
136
+ for shortest_run in (3, 1):
137
+ lines: List[str] = []
138
+ for library, group in groupby(blocks, key=lambda block: bool(_LIBRARY_FRAME.match(block[0]))):
139
+ frames = list(group)
140
+ if library and len(frames) >= shortest_run:
141
+ lines.append(f" [… {len(frames)} library frame{'s' if len(frames) != 1 else ''} …]")
142
+ else:
143
+ lines.extend(line for frame in frames for line in frame)
144
+ text = "\n".join(lines)
145
+ if _u16(text) <= room:
146
+ break
147
+ return text
148
+
149
+
150
  def _fit(head: str, trace: str) -> str:
151
+ """``head`` + the traceback in at most ``MAX_TEXT``. What does not fit goes in this order: frames from libraries,
152
+ then the middle of the traceback: its first frames (where it came from) and its end (the error) are what matter."""
153
  if not trace:
154
  return clip(head, MAX_TEXT // 2)
155
  head = clip(head, HEAD_LIMIT)
156
  room = MAX_TEXT - _u16(head) - 2
157
+ trace = _elide(trace, room)
158
  if _u16(trace) > room:
159
+ room -= len(_CUT)
160
+ first = room * 2 // 5
161
+ trace = _head(trace, first) + _CUT + _tail(trace, room - first)
162
  return head + "\n\n" + trace
163
 
164
 
tests/test_db.py CHANGED
@@ -76,6 +76,33 @@ async def test_admins_and_kv(db):
76
  assert await db.kv_get("session") is None
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  async def test_first_use_by_many_concurrent_handlers_does_not_collide(db):
80
  """A forwarded batch is handled by several tasks at once; all of them look the new user up together."""
81
  import asyncio
 
76
  assert await db.kv_get("session") is None
77
 
78
 
79
+ async def test_a_foreign_table_called_formats_in_a_shared_database_is_left_alone(tmp_path):
80
+ """The Postgres can be shared with another app whose "formats" table has other columns (that broke /rename once)."""
81
+ import sqlite3
82
+
83
+ path = tmp_path / "shared.db"
84
+ other_app = sqlite3.connect(path)
85
+ other_app.execute("CREATE TABLE formats (id INTEGER PRIMARY KEY, name TEXT, body TEXT)")
86
+ other_app.execute("INSERT INTO formats (name, body) VALUES ('their format', 'their body')")
87
+ other_app.commit()
88
+ other_app.close()
89
+
90
+ database = Database(f"sqlite+aiosqlite:///{path}")
91
+ await database.init()
92
+ try:
93
+ created = await database.create_format(1, "One Piece", "{title} E{episode}")
94
+ assert [f["name"] for f in await database.list_formats(1)] == ["One Piece"]
95
+ assert (await database.get_format(1, created["id"]))["template"] == "{title} E{episode}"
96
+ assert await database.delete_format(1, created["id"]) is True
97
+ finally:
98
+ await database.close()
99
+
100
+ other_app = sqlite3.connect(path)
101
+ assert other_app.execute("SELECT name, body FROM formats").fetchall() == [("their format", "their body")]
102
+ assert [r[1] for r in other_app.execute("PRAGMA table_info(formats)")] == ["id", "name", "body"]
103
+ other_app.close()
104
+
105
+
106
  async def test_first_use_by_many_concurrent_handlers_does_not_collide(db):
107
  """A forwarded batch is handled by several tasks at once; all of them look the new user up together."""
108
  import asyncio
tests/test_errorlog.py CHANGED
@@ -123,18 +123,18 @@ def test_configured_secrets_are_hidden_but_short_values_are_left_alone():
123
  assert redact("token abc123XYZ789 and abc", ["abc123XYZ789", "abc"]) == "token *** and abc"
124
 
125
 
126
- def test_session_strings_and_access_tokens_are_hidden():
127
  session = "1" + "A1b2C3_-" * 40
128
- telegraph = "b968da509bb76866c35425099bc0989a5ec3b32997d55286c657e6994bbb"
129
- text = f"bound {session} and {telegraph} here"
130
- assert redact(text) == "bound *** and *** here"
131
 
132
 
133
  def test_ordinary_text_survives():
134
  for text in (
135
  "File \"/home/user/app/bot/app.py\", line 1021, in _mediainfo",
 
136
  "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv → One Piece - E1147.mkv (276.61 MB)",
137
- "-" * 80,
 
138
  ):
139
  assert redact(text) == text
140
 
@@ -198,12 +198,71 @@ def test_secrets_are_hidden_in_the_message_and_in_the_traceback():
198
  assert "TOP-SECRET-VALUE" not in body and body.count("***") >= 2
199
 
200
 
201
- def test_a_long_traceback_keeps_its_end_and_fits_in_one_message():
202
  _signature, body = build_report(make_record("Error handling update:", very_deep))
203
  text = visible(body)
204
  assert errorlog._u16(text) <= errorlog.MAX_TEXT
205
- assert text.startswith("Error handling update:\n\n…\n File ") # cut at the beginning of a line
206
- assert text.endswith("ValueError: bottom")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
 
209
  def test_emoji_count_double_towards_the_limit():
 
123
  assert redact("token abc123XYZ789 and abc", ["abc123XYZ789", "abc"]) == "token *** and abc"
124
 
125
 
126
+ def test_session_strings_are_hidden():
127
  session = "1" + "A1b2C3_-" * 40
128
+ assert redact(f"bound {session} here") == "bound *** here"
 
 
129
 
130
 
131
  def test_ordinary_text_survives():
132
  for text in (
133
  "File \"/home/user/app/bot/app.py\", line 1021, in _mediainfo",
134
+ "File \"/tmp/claude-0/-home-claude/77cec5a8-65b1-5e4a-87df-15495b60ec66/scratchpad/venv312/lib/python3.12/site-packages/x.py\"",
135
  "1147 - One Piece [Dual] 720p @Anime_X_Universe.mkv → One Piece - E1147.mkv (276.61 MB)",
136
+ "Naruto_Shippuden_Episode_123_Dual_Audio_English_Japanese_Subbed_1080p_HEVC_x265_10bit.mkv",
137
+ "-" * 150,
138
  ):
139
  assert redact(text) == text
140
 
 
198
  assert "TOP-SECRET-VALUE" not in body and body.count("***") >= 2
199
 
200
 
201
+ def test_a_long_traceback_keeps_its_first_frames_and_its_end_and_fits_in_one_message():
202
  _signature, body = build_report(make_record("Error handling update:", very_deep))
203
  text = visible(body)
204
  assert errorlog._u16(text) <= errorlog.MAX_TEXT
205
+ first, last = text.split("\n…\n") # only the middle is cut
206
+ assert first.startswith("Error handling update:\n\nTraceback (most recent call last):\n File ")
207
+ assert "in make_record" in first and "in very_deep" in first # where it came from
208
+ assert re.search(r", in f\d+$", first) # cut after a whole line
209
+ assert last.startswith(" File ") and last.endswith("ValueError: bottom") # from the start of a line, up to the error
210
+
211
+
212
+ def test_a_traceback_that_fits_is_left_whole():
213
+ text = visible(build_report(make_record("Error handling update:", chained))[1])
214
+ assert "…" not in text and "in make_record" in text
215
+
216
+
217
+ def library_failure(depth=40):
218
+ """Fail ``depth`` calls deep inside code that looks like it lives in site-packages, the way SQLAlchemy's frames do."""
219
+ scope = {}
220
+ source = "\n".join(f"def g{i}():\n return g{i + 1}()" for i in range(depth)) + f"\ndef g{depth}():\n raise ValueError('inside the library')"
221
+ exec(compile(source, "/home/user/.local/lib/python3.12/site-packages/somelib/core.py", "exec"), scope) # noqa: S102
222
+ scope["g0"]()
223
+
224
+
225
+ def test_library_frames_are_collapsed_before_anything_of_the_bots_own_is_cut():
226
+ text = visible(build_report(make_record("Error handling update:", library_failure))[1])
227
+ assert errorlog._u16(text) <= errorlog.MAX_TEXT
228
+ assert "in make_record" in text and "in library_failure" in text # the bot's own frames stay
229
+ assert "[… 41 library frames …]" in text and "somelib" not in text
230
+ assert "\n…\n" not in text # nothing else had to go
231
+ assert text.endswith("ValueError: inside the library")
232
+
233
+
234
+ def test_only_as_much_of_the_library_frames_is_collapsed_as_needed():
235
+ def frame(path, n):
236
+ return f' File "{path}", line {n}, in f{n}\n call{n}()'
237
+
238
+ lib, mine = "/usr/local/lib/python3.12/site-packages/x.py", "/home/user/app/bot/app.py"
239
+ trace = "\n".join(
240
+ ["Traceback (most recent call last):", frame(mine, 1)] + [frame(lib, n) for n in range(10, 15)]
241
+ + [frame(mine, 2), frame(lib, 30), frame(mine, 3), "ValueError: boom"]
242
+ )
243
+ assert errorlog._elide(trace, 10_000) == trace # fits: left alone
244
+ some = errorlog._elide(trace, len(trace) - 50) # the run of five goes, the single library frame stays
245
+ assert "[… 5 library frames …]" in some and 'x.py", line 30' in some
246
+ most = errorlog._elide(trace, 150) # every run goes
247
+ assert "[… 1 library frame …]" in most and 'x.py' not in most
248
+ assert most.endswith("ValueError: boom") and 'app.py", line 1' in most
249
+
250
+
251
+ def test_a_chained_traceback_keeps_each_exception_line_when_its_library_frames_are_collapsed():
252
+ def frame(path, n):
253
+ return f' File "{path}", line {n}, in f{n}\n call{n}()'
254
+
255
+ lib = "/usr/local/lib/python3.12/site-packages/asyncpg/protocol.py"
256
+ mine = "/home/user/app/bot/db.py"
257
+ trace = "\n".join(
258
+ ["Traceback (most recent call last):"] + [frame(lib, n) for n in range(5)] + ["asyncpg.UndefinedColumnError: no such column", "",
259
+ "The above exception was the direct cause of the following exception:", "", "Traceback (most recent call last):", frame(mine, 7)]
260
+ + [frame(lib, n) for n in range(20, 30)] + ["sqlalchemy.exc.ProgrammingError: column formats.data does not exist"]
261
+ )
262
+ text = errorlog._elide(trace, 300)
263
+ assert text.count("library frames …]") == 2
264
+ assert "asyncpg.UndefinedColumnError: no such column" in text and "direct cause of the following exception" in text
265
+ assert 'db.py", line 7' in text and text.endswith("column formats.data does not exist")
266
 
267
 
268
  def test_emoji_count_double_towards_the_limit():