keptwise 1.2.1__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.
keptwise/importer.py ADDED
@@ -0,0 +1,332 @@
1
+ """Reading bank CSV exports and loading them into the database.
2
+
3
+ The importer recognises the column names UK banks commonly use, so most exports work
4
+ as they are:
5
+
6
+ * Lloyds, Halifax, Bank of Scotland: Transaction Date, Transaction Type, Transaction
7
+ Description, Debit Amount, Credit Amount, Balance
8
+ * Nationwide-style: Date, Description, Paid out, Paid in, Balance (with a few lines of
9
+ account details above the header)
10
+ * Monzo / Starling-style: Date, Name or Counter Party, Amount (signed), Balance
11
+
12
+ It needs a date, a description and either a signed amount or money in/out columns.
13
+ Dates may be dd/mm/yyyy, dd/mm/yy, yyyy-mm-dd or "01 Oct 2026".
14
+
15
+ Re-importing is always safe: every transaction gets a fingerprint and duplicates are
16
+ skipped, so overlapping exports can be uploaded without double counting.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import csv
22
+ import hashlib
23
+ import io
24
+ import re
25
+ import sqlite3
26
+ from collections import Counter
27
+ from dataclasses import dataclass, field
28
+ from datetime import date, datetime
29
+ from pathlib import Path
30
+
31
+ from .dates import strftime
32
+ from .money import to_pence
33
+ from .rules import categorise
34
+
35
+ # Column names banks use, most specific first. Names are compared after lower-casing
36
+ # and turning punctuation into spaces, so "Amount (GBP)" matches "amount gbp".
37
+ COLUMN_ALIASES: dict[str, list[str]] = {
38
+ "date": [
39
+ "transaction date",
40
+ "date",
41
+ "posting date",
42
+ "booking date",
43
+ "completed date",
44
+ "date of transaction",
45
+ "value date",
46
+ "started date",
47
+ ],
48
+ "description": [
49
+ "transaction description",
50
+ "description",
51
+ "transaction details",
52
+ "details",
53
+ "narrative",
54
+ "counter party",
55
+ "counterparty",
56
+ "payee",
57
+ "name",
58
+ "merchant",
59
+ "memo",
60
+ "reference",
61
+ ],
62
+ "tx_type": ["transaction type", "type"],
63
+ "amount": ["amount", "amount gbp", "transaction amount", "value", "amount local currency"],
64
+ "debit": ["debit amount", "debit", "paid out", "money out", "withdrawals", "amount out", "out"],
65
+ "credit": ["credit amount", "credit", "paid in", "money in", "deposits", "amount in", "in"],
66
+ "balance": ["balance", "running balance", "balance gbp", "available balance"],
67
+ }
68
+
69
+ DATE_FORMATS = (
70
+ "%d/%m/%Y",
71
+ "%d/%m/%y",
72
+ "%Y-%m-%d",
73
+ "%d-%m-%Y",
74
+ "%d.%m.%Y",
75
+ "%Y/%m/%d",
76
+ "%d %b %Y",
77
+ "%d %B %Y",
78
+ "%d-%b-%Y",
79
+ "%d %b %y",
80
+ "%d-%b-%y",
81
+ )
82
+
83
+
84
+ class ImportProblem(ValueError):
85
+ """Raised when a file can't be read. The message is shown to the user."""
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ParsedTransaction:
90
+ date: date
91
+ tx_type: str
92
+ description: str
93
+ amount: int # pence; credits positive, debits negative
94
+ balance: int | None
95
+
96
+
97
+ @dataclass
98
+ class ImportResult:
99
+ filename: str
100
+ rows_total: int = 0
101
+ rows_added: int = 0
102
+ rows_duplicate: int = 0
103
+ first_date: date | None = None
104
+ last_date: date | None = None
105
+ import_id: int | None = None
106
+ added_ids: list[int] = field(default_factory=list)
107
+ already_imported: bool = False
108
+
109
+ @property
110
+ def summary(self) -> str:
111
+ if self.rows_total == 0:
112
+ return f"{self.filename}: no transactions found."
113
+ span = ""
114
+ if self.first_date and self.last_date:
115
+ fmt = "%-d %b %Y"
116
+ span = f" ({strftime(self.first_date, fmt)} – {strftime(self.last_date, fmt)})"
117
+ dupes = f", {self.rows_duplicate} already imported" if self.rows_duplicate else ""
118
+ return f"{self.filename}: {self.rows_added} new transactions{dupes}{span}."
119
+
120
+
121
+ def parse_date(text: str) -> date:
122
+ text = text.strip()
123
+ iso = re.match(r"^(\d{4}-\d{2}-\d{2})[T ]", text) # 2026-10-01T09:30:00Z
124
+ if iso:
125
+ text = iso.group(1)
126
+ for pattern in DATE_FORMATS:
127
+ try:
128
+ return datetime.strptime(text, pattern).date()
129
+ except ValueError:
130
+ continue
131
+ raise ImportProblem(f"Couldn't read the date {text!r}. Expected something like 31/12/2026.")
132
+
133
+
134
+ def parse_amount(text: str) -> int | None:
135
+ """Like to_pence, but also understands (12.34), 12.34 DR/CR and a unicode minus."""
136
+ text = (text or "").strip().replace("\u2212", "-")
137
+ if not text:
138
+ return None
139
+ sign = 1
140
+ if text.startswith("(") and text.endswith(")"):
141
+ text, sign = text[1:-1], -1
142
+ suffix = re.search(r"\s*(CR|DR)$", text, re.IGNORECASE)
143
+ if suffix:
144
+ text = text[: suffix.start()]
145
+ sign = -1 if suffix.group(1).upper() == "DR" else 1
146
+ value = to_pence(text)
147
+ return None if value is None else sign * value
148
+
149
+
150
+ def _clean_description(text: str) -> str:
151
+ return re.sub(r"\s+", " ", (text or "").strip())
152
+
153
+
154
+ def _normalise(name: str) -> str:
155
+ return re.sub(r"[^a-z0-9]+", " ", name.lower()).strip()
156
+
157
+
158
+ def find_columns(header: list[str]) -> dict[str, int] | None:
159
+ """Map our fields to column positions, or None if this isn't a header row."""
160
+ names = [_normalise(h) for h in header]
161
+ found: dict[str, int] = {}
162
+ for key, aliases in COLUMN_ALIASES.items():
163
+ for alias in aliases:
164
+ if alias in names and names.index(alias) not in found.values():
165
+ found[key] = names.index(alias)
166
+ break
167
+ has_money = "amount" in found or "debit" in found or "credit" in found
168
+ if "date" in found and "description" in found and has_money:
169
+ return found
170
+ return None
171
+
172
+
173
+ def _decode(data: bytes | str) -> str:
174
+ if isinstance(data, str):
175
+ return data.lstrip("\ufeff")
176
+ for encoding in ("utf-8-sig", "cp1252"):
177
+ try:
178
+ return data.decode(encoding)
179
+ except UnicodeDecodeError:
180
+ continue
181
+ return data.decode("latin-1")
182
+
183
+
184
+ def parse_csv(data: bytes | str) -> list[ParsedTransaction]:
185
+ """Parse a bank CSV export into transactions (the file's order is preserved)."""
186
+ text = _decode(data)
187
+ if not text.strip():
188
+ raise ImportProblem("The file is empty.")
189
+ sample = text[:4096]
190
+ try:
191
+ dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
192
+ except csv.Error:
193
+ dialect = csv.excel
194
+ rows = list(csv.reader(io.StringIO(text), dialect))
195
+
196
+ columns, header_at = None, 0
197
+ for i, row in enumerate(rows[:30]): # some banks put account details above the header
198
+ columns = find_columns(row)
199
+ if columns:
200
+ header_at = i
201
+ break
202
+ if columns is None:
203
+ raise ImportProblem(
204
+ "Couldn't find the transaction columns. The file needs a date, a description "
205
+ "and an amount (or money in and money out) column."
206
+ )
207
+
208
+ def cell(row: list[str], key: str) -> str:
209
+ i = columns.get(key)
210
+ return row[i] if i is not None and i < len(row) else ""
211
+
212
+ signed = "amount" in columns and not ("debit" in columns and "credit" in columns)
213
+ transactions = []
214
+ for line_no, row in enumerate(rows[header_at + 1 :], start=header_at + 2):
215
+ if not any(c.strip() for c in row) or not cell(row, "date").strip():
216
+ continue
217
+ try:
218
+ if signed:
219
+ amount = parse_amount(cell(row, "amount")) or 0
220
+ else:
221
+ debit = abs(parse_amount(cell(row, "debit")) or 0)
222
+ credit = abs(parse_amount(cell(row, "credit")) or 0)
223
+ amount = credit - debit
224
+ transactions.append(
225
+ ParsedTransaction(
226
+ date=parse_date(cell(row, "date")),
227
+ tx_type=cell(row, "tx_type").strip().upper(),
228
+ description=_clean_description(cell(row, "description")),
229
+ amount=amount,
230
+ balance=parse_amount(cell(row, "balance")),
231
+ )
232
+ )
233
+ except (ValueError, ImportProblem) as exc:
234
+ raise ImportProblem(f"Line {line_no}: {exc}") from exc
235
+ return transactions
236
+
237
+
238
+ def fingerprint(tx: ParsedTransaction, occurrence: int) -> str:
239
+ """Stable identity for a transaction across overlapping exports.
240
+
241
+ The running balance makes otherwise identical rows (two £2 bus fares on the same
242
+ day) distinct; ``occurrence`` covers the rare case where even that repeats.
243
+ """
244
+ key = "|".join(
245
+ [
246
+ tx.date.isoformat(),
247
+ str(tx.amount),
248
+ "" if tx.balance is None else str(tx.balance),
249
+ tx.description.upper(),
250
+ str(occurrence),
251
+ ]
252
+ )
253
+ return hashlib.sha256(key.encode()).hexdigest()[:32]
254
+
255
+
256
+ def import_transactions(
257
+ conn: sqlite3.Connection,
258
+ transactions: list[ParsedTransaction],
259
+ *,
260
+ filename: str,
261
+ source: str,
262
+ sha256: str,
263
+ ) -> ImportResult:
264
+ """Insert new transactions, skip duplicates, and categorise what was added."""
265
+ result = ImportResult(filename=filename, rows_total=len(transactions))
266
+ if transactions:
267
+ result.first_date = min(t.date for t in transactions)
268
+ result.last_date = max(t.date for t in transactions)
269
+
270
+ seen: Counter[tuple] = Counter()
271
+ with conn:
272
+ cur = conn.execute(
273
+ "INSERT INTO imports(filename, sha256, source, rows_total, rows_added, "
274
+ "rows_duplicate, first_date, last_date) VALUES (?,?,?,?,0,0,?,?)",
275
+ (
276
+ filename,
277
+ sha256,
278
+ source,
279
+ len(transactions),
280
+ result.first_date and result.first_date.isoformat(),
281
+ result.last_date and result.last_date.isoformat(),
282
+ ),
283
+ )
284
+ result.import_id = cur.lastrowid
285
+ # Insert oldest first so ids follow time order. Bank exports list newest first,
286
+ # so flip those before the (stable) sort to keep same-day rows in order too.
287
+ ordered = list(transactions)
288
+ if len(ordered) > 1 and ordered[0].date > ordered[-1].date:
289
+ ordered.reverse()
290
+ for tx in sorted(ordered, key=lambda t: t.date):
291
+ ident = (tx.date, tx.amount, tx.balance, tx.description.upper())
292
+ seen[ident] += 1
293
+ fp = fingerprint(tx, seen[ident])
294
+ cur = conn.execute(
295
+ "INSERT OR IGNORE INTO transactions"
296
+ "(fingerprint, date, tx_type, description, amount, balance, import_id) "
297
+ "VALUES (?,?,?,?,?,?,?)",
298
+ (
299
+ fp,
300
+ tx.date.isoformat(),
301
+ tx.tx_type,
302
+ tx.description,
303
+ tx.amount,
304
+ tx.balance,
305
+ result.import_id,
306
+ ),
307
+ )
308
+ if cur.rowcount:
309
+ result.added_ids.append(cur.lastrowid)
310
+ result.rows_added = len(result.added_ids)
311
+ result.rows_duplicate = result.rows_total - result.rows_added
312
+ conn.execute(
313
+ "UPDATE imports SET rows_added = ?, rows_duplicate = ? WHERE id = ?",
314
+ (result.rows_added, result.rows_duplicate, result.import_id),
315
+ )
316
+ if result.added_ids:
317
+ categorise(conn, ids=result.added_ids)
318
+ return result
319
+
320
+
321
+ def import_file(conn: sqlite3.Connection, data: bytes, filename: str) -> ImportResult:
322
+ """Import an uploaded bank CSV."""
323
+ if not filename.lower().endswith((".csv", ".txt")):
324
+ raise ImportProblem("Only CSV files can be imported. Download your statement as CSV.")
325
+ sha = hashlib.sha256(data).hexdigest()
326
+ transactions = parse_csv(data)
327
+ return import_transactions(conn, transactions, filename=filename, source="csv", sha256=sha)
328
+
329
+
330
+ def import_path(conn: sqlite3.Connection, path: Path | str) -> ImportResult:
331
+ path = Path(path)
332
+ return import_file(conn, path.read_bytes(), path.name)
keptwise/insights.py ADDED
@@ -0,0 +1,272 @@
1
+ """What's unusual this month, worked out from your own history.
2
+
3
+ Normal ranges
4
+ -------------
5
+ For each category we look at the previous (up to) 12 complete months and take the
6
+ *median* (the typical month) and the *median absolute deviation* (how much it usually
7
+ moves). Both ignore one-off extremes, so a single big month doesn't distort "normal".
8
+ A month is flagged only when it falls clearly outside that range, so categories that
9
+ always swing around aren't flagged for ordinary ups and downs.
10
+
11
+ Bills
12
+ -----
13
+ Regular bills are also checked one by one: a bill that has gone out nearly every
14
+ month but hasn't this month is flagged as missing, and a fixed bill whose amount
15
+ changes (say £27.00 to £31.50) is flagged as changed.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import calendar
21
+ import sqlite3
22
+ import statistics
23
+ from dataclasses import dataclass
24
+ from datetime import date
25
+
26
+ from .money import fmt
27
+ from .reports import months as all_months
28
+
29
+ LOOKBACK = 12 # months of history used for "normal"
30
+ MIN_HISTORY = 4 # months needed before we judge anything
31
+ Z = 2.0 # how far outside normal before flagging (in robust standard deviations)
32
+ MIN_GAP = 15_00 # ignore differences smaller than £15
33
+ OCCASIONAL_MIN = 100_00 # occasional categories need at least £100 to be flagged
34
+ MAD_TO_SD = 1.4826 # scales MAD to a standard deviation for normally distributed data
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Normal:
39
+ """A category's typical month. Amounts are *spend* (positive) or, for income
40
+ categories, money received (positive)."""
41
+
42
+ median: int
43
+ low: int
44
+ high: int
45
+ months: int
46
+ income: bool
47
+ occasional: bool # used in fewer than half of months
48
+ highest: int # biggest month in the history
49
+
50
+ def describe(self) -> str:
51
+ if self.occasional:
52
+ return f"your biggest month before was {pounds(self.highest)}"
53
+ if self.low == 0:
54
+ return f"usually up to {pounds(self.high)}"
55
+ return f"usually {pounds(self.low)} to {pounds(self.high)}"
56
+
57
+
58
+ def pounds(pence: int) -> str:
59
+ """Whole pounds for ranges: £1,240."""
60
+ return f"£{round(pence / 100):,}"
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Flag:
65
+ kind: str # above | below | missing | changed
66
+ category_id: int | None
67
+ name: str
68
+ bucket: str
69
+ amount: int
70
+ message: str
71
+ good: bool # True when it's good news (spent less, earned more)
72
+
73
+
74
+ def complete_months(conn: sqlite3.Connection) -> list[str]:
75
+ """Months whose data covers the whole month (drops a partial first/last month)."""
76
+ ms = all_months(conn)
77
+ if not ms:
78
+ return []
79
+ bounds = {
80
+ row["m"]: (date.fromisoformat(row["first"]), date.fromisoformat(row["last"]))
81
+ for row in conn.execute(
82
+ "SELECT substr(date,1,7) AS m, MIN(date) AS first, MAX(date) AS last "
83
+ "FROM transactions GROUP BY m"
84
+ )
85
+ }
86
+ result = []
87
+ for i, m in enumerate(ms):
88
+ first, last = bounds[m]
89
+ days = calendar.monthrange(first.year, first.month)[1]
90
+ if i == 0 and first.day > 4:
91
+ continue
92
+ if i == len(ms) - 1 and last.day < days - 3:
93
+ continue
94
+ result.append(m)
95
+ return result
96
+
97
+
98
+ def monthly_net(conn: sqlite3.Connection) -> dict[tuple[int | None, str], int]:
99
+ """(category_id, month) -> net spend (money out minus money in) in pence."""
100
+ rows = conn.execute(
101
+ "SELECT category_id, substr(date,1,7) AS m, -SUM(amount) AS spend "
102
+ "FROM transactions GROUP BY category_id, m"
103
+ )
104
+ return {(r["category_id"], r["m"]): r["spend"] for r in rows}
105
+
106
+
107
+ def normal_range(values: list[int]) -> Normal | None:
108
+ """Typical range from monthly *signed spend* values (income is negative)."""
109
+ if len(values) < MIN_HISTORY:
110
+ return None
111
+ income = statistics.median(values) < 0
112
+ vals = [-v for v in values] if income else list(values)
113
+ med = statistics.median(vals)
114
+ mad = statistics.median(abs(v - med) for v in vals)
115
+ spread = max(MAD_TO_SD * mad, 0.15 * abs(med), 10_00)
116
+ low = max(0, round(med - Z * spread))
117
+ high = round(med + Z * spread)
118
+ occasional = sum(1 for v in vals if v) < len(vals) / 2
119
+ return Normal(round(med), low, high, len(vals), income, occasional, max(vals))
120
+
121
+
122
+ def normals(conn: sqlite3.Connection, month: str) -> dict[int | None, Normal]:
123
+ """Normal range for every non-investment category, using months before ``month``."""
124
+ history = [m for m in complete_months(conn) if m < month][-LOOKBACK:]
125
+ if len(history) < MIN_HISTORY:
126
+ return {}
127
+ net = monthly_net(conn)
128
+ cats = conn.execute("SELECT id, bucket FROM categories WHERE bucket != 'investment'").fetchall()
129
+ ids: list[int | None] = [c["id"] for c in cats] + [None]
130
+ result = {}
131
+ for cid in ids:
132
+ values = [net.get((cid, m), 0) for m in history]
133
+ if not any(values):
134
+ continue
135
+ normal = normal_range(values)
136
+ if normal:
137
+ result[cid] = normal
138
+ return result
139
+
140
+
141
+ def compare(value: int, normal: Normal) -> str:
142
+ """'above', 'below' or 'normal' for a month's signed spend against its range."""
143
+ amount = -value if normal.income else value
144
+ if normal.occasional:
145
+ # Things you only spend on now and then: flag only a clearly record month.
146
+ big = amount >= OCCASIONAL_MIN and amount > 1.5 * normal.highest
147
+ return "above" if big else "normal"
148
+ if amount < 0:
149
+ return "normal" # more came in than went out (refunds, people paying back)
150
+ if amount > normal.high and amount - normal.median >= MIN_GAP:
151
+ return "above"
152
+ if amount < normal.low and normal.median - amount >= MIN_GAP:
153
+ return "below"
154
+ return "normal"
155
+
156
+
157
+ def month_flags(conn: sqlite3.Connection, month: str) -> list[Flag]:
158
+ """Everything worth a look in ``month``: unusual categories and bill problems."""
159
+ complete = month in complete_months(conn)
160
+ last_day_seen = conn.execute(
161
+ "SELECT MAX(date) FROM transactions WHERE substr(date,1,7) = ?", (month,)
162
+ ).fetchone()[0]
163
+ if last_day_seen is None:
164
+ return []
165
+ seen_until = date.fromisoformat(last_day_seen)
166
+ flags: list[Flag] = []
167
+ flags += _bill_flags(conn, month, complete, seen_until)
168
+
169
+ ranges = normals(conn, month)
170
+ net = monthly_net(conn)
171
+ names = {
172
+ r["id"]: (r["name"], r["bucket"])
173
+ for r in conn.execute("SELECT id, name, bucket FROM categories")
174
+ }
175
+ flagged = {f.category_id for f in flags}
176
+ for cid, normal in ranges.items():
177
+ if cid is None or cid in flagged:
178
+ continue # uncategorised spending has its own prompt
179
+ name, bucket = names.get(cid, ("No category", "adhoc"))
180
+ value = net.get((cid, month), 0)
181
+ verdict = compare(value, normal)
182
+ # Part months naturally look low, so only "above" counts until the month is in.
183
+ if verdict == "normal" or (verdict == "below" and not complete):
184
+ continue
185
+ amount = -value if normal.income else value
186
+ if normal.income:
187
+ word = "more" if verdict == "above" else "less"
188
+ message = f"{name}: {fmt(amount)} came in, {word} than normal ({normal.describe()})."
189
+ good = verdict == "above"
190
+ else:
191
+ word = "more" if verdict == "above" else "less"
192
+ message = f"{name}: {fmt(amount)}, {word} than normal ({normal.describe()})."
193
+ good = verdict == "below"
194
+ flags.append(Flag(verdict, cid, name, bucket, amount, message, good))
195
+
196
+ order = {"missing": 0, "changed": 1, "above": 2, "below": 3}
197
+ flags.sort(key=lambda f: (order[f.kind], -abs(f.amount)))
198
+ return flags
199
+
200
+
201
+ def _bill_flags(
202
+ conn: sqlite3.Connection, month: str, complete: bool, seen_until: date
203
+ ) -> list[Flag]:
204
+ history = [m for m in complete_months(conn) if m < month][-6:]
205
+ if len(history) < 3:
206
+ return []
207
+ rows = conn.execute(
208
+ "SELECT t.category_id, c.name, substr(t.date,1,7) AS m, t.date, t.amount "
209
+ "FROM transactions t JOIN categories c ON c.id = t.category_id "
210
+ "WHERE c.bucket = 'regular' AND t.amount < 0 ORDER BY t.date"
211
+ ).fetchall()
212
+ payments: dict[int, list[sqlite3.Row]] = {}
213
+ for r in rows:
214
+ payments.setdefault(r["category_id"], []).append(r)
215
+
216
+ flags = []
217
+ for cid, items in payments.items():
218
+ name = items[0]["name"]
219
+ by_month: dict[str, list[sqlite3.Row]] = {}
220
+ for r in items:
221
+ by_month.setdefault(r["m"], []).append(r)
222
+ months_paid = [m for m in history if m in by_month]
223
+ if len(months_paid) < len(history) - 1:
224
+ continue # not a reliable monthly bill
225
+ this_month = by_month.get(month, [])
226
+
227
+ if not this_month:
228
+ usual_day = statistics.median(
229
+ date.fromisoformat(by_month[m][0]["date"]).day for m in months_paid
230
+ )
231
+ if complete or seen_until.day > usual_day + 5:
232
+ usual = statistics.median(
233
+ -sum(r["amount"] for r in by_month[m]) for m in months_paid
234
+ )
235
+ flags.append(
236
+ Flag(
237
+ "missing",
238
+ cid,
239
+ name,
240
+ "regular",
241
+ round(usual),
242
+ f"{name} hasn't gone out this month. It's usually "
243
+ f"{fmt(round(usual))} around the {_ordinal(round(usual_day))}.",
244
+ False,
245
+ )
246
+ )
247
+ continue
248
+
249
+ # A fixed bill (same amount the last three times) that has changed.
250
+ if len(this_month) != 1 or any(len(by_month[m]) != 1 for m in months_paid[-3:]):
251
+ continue
252
+ previous = [-by_month[m][0]["amount"] for m in months_paid[-3:]]
253
+ current = -this_month[0]["amount"]
254
+ if len(set(previous)) == 1 and current != previous[-1]:
255
+ went = "up" if current > previous[-1] else "down"
256
+ flags.append(
257
+ Flag(
258
+ "changed",
259
+ cid,
260
+ name,
261
+ "regular",
262
+ current,
263
+ f"{name} went {went} from {fmt(previous[-1])} to {fmt(current)}.",
264
+ went == "down",
265
+ )
266
+ )
267
+ return flags
268
+
269
+
270
+ def _ordinal(n: int) -> str:
271
+ suffix = "th" if 11 <= n % 100 <= 13 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
272
+ return f"{n}{suffix}"