hotin 0.2.0__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.
hotin/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """The hotin command-line package."""
2
+
3
+ __version__ = "0.2.0"
hotin/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m hotin`` — the portable invocation a scheduler can rely on."""
2
+
3
+ import sys
4
+
5
+ from hotin.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
hotin/board.py ADDED
@@ -0,0 +1,198 @@
1
+ """Map ranked entity records to the shared board Row view-model.
2
+
3
+ Pure functions (no I/O, never raise) that turn each entity's records into the
4
+ Row shape `render_board` consumes: {rank, name, url, meta, receipts, badges}.
5
+ Receipts are numbers (who points at it), badges are verdicts. This is the one
6
+ place record-shape knowledge meets the renderer, so the console/markdown/html
7
+ surfaces stay identical.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from .coerce import finite_float, finite_int
15
+
16
+
17
+ def _num(value: float) -> str:
18
+ """Compact human number: 2100 -> 2.1k, 1.2M, 3.4M; whole values stay whole."""
19
+ value = float(value)
20
+ for unit in ("", "k", "M", "B"):
21
+ if abs(value) < 1000:
22
+ if unit and abs(value) < 100:
23
+ return "{:.1f}".format(value).rstrip("0").rstrip(".") + unit
24
+ return "{:.0f}{}".format(value, unit)
25
+ value /= 1000
26
+ return "{:.0f}T".format(value)
27
+
28
+
29
+ def _sig(record: dict) -> dict:
30
+ signal = record.get("signal")
31
+ return signal if isinstance(signal, dict) else {}
32
+
33
+
34
+ def _meta(record: dict) -> dict:
35
+ meta = record.get("meta")
36
+ return meta if isinstance(meta, dict) else {}
37
+
38
+
39
+ def _insider_receipt(record: dict) -> Optional[Dict[str, str]]:
40
+ """`★ karpathy +38 insiders` when the AI Insiders are on a repo."""
41
+ n = finite_int(_sig(record).get("smartmoney_starrers") or _sig(record).get("insider_stars"), 0)
42
+ if not n:
43
+ return None
44
+ who = _meta(record).get("insiders") or _meta(record).get("top_starrers") or []
45
+ lead = None
46
+ if isinstance(who, list) and who:
47
+ first = who[0]
48
+ lead = first.get("username") if isinstance(first, dict) else (first if isinstance(first, str) else None)
49
+ lead = _meta(record).get("top_insider") or lead
50
+ label = "★ {} +{} insiders".format(lead, n - 1) if lead and n > 1 else (
51
+ "★ {} · insider".format(lead) if lead else "★ {} insiders".format(n))
52
+ return {"label": label, "kind": "insiders"}
53
+
54
+
55
+ _ENGINE_BADGE_MAP = {
56
+ "fresh": ("fresh", False),
57
+ "smart-money": ("smart-money", False),
58
+ "paper-backed": ("paper-backed", False),
59
+ "viral": ("trending", True), # viral = trending, turned up
60
+ "rising": ("trending", False), # rising shows in the receipt number too
61
+ "trending": ("trending", False),
62
+ }
63
+
64
+
65
+ def _badges(record: dict) -> List[Dict[str, Any]]:
66
+ out: List[Dict[str, Any]] = []
67
+ seen = set()
68
+ for raw in record.get("badges") or []:
69
+ mapped = _ENGINE_BADGE_MAP.get(raw)
70
+ if not mapped:
71
+ continue
72
+ label, hot = mapped
73
+ key = label
74
+ if key in seen:
75
+ # if both rising and viral present, keep the hotter one
76
+ if hot:
77
+ for b in out:
78
+ if b["label"] == label:
79
+ b["hot"] = True
80
+ continue
81
+ seen.add(key)
82
+ out.append({"label": label, "hot": hot})
83
+ return out
84
+
85
+
86
+ def repo_rows(ranked: List[dict]) -> List[dict]:
87
+ """Fused repo board rows: receipts = the numbers, badges = the verdicts."""
88
+ rows: List[dict] = []
89
+ for i, repo in enumerate(ranked, 1):
90
+ if not isinstance(repo, dict):
91
+ continue
92
+ signal = _sig(repo)
93
+ slug = repo.get("canonical_repo") or repo.get("name") or "?"
94
+ receipts: List[Dict[str, str]] = []
95
+ insider = _insider_receipt(repo)
96
+ if insider:
97
+ receipts.append(insider)
98
+ growth = finite_int(signal.get("stars_growth"), 0)
99
+ if growth:
100
+ receipts.append({"label": "+{} stars".format(_num(growth)), "kind": "stars"})
101
+ elif finite_int(signal.get("stars"), 0):
102
+ receipts.append({"label": "{} stars".format(_num(finite_int(signal.get("stars")))), "kind": "stars"})
103
+ if finite_int(signal.get("hn_points"), 0):
104
+ receipts.append({"label": "{} pts".format(_num(finite_int(signal.get("hn_points")))), "kind": "hn"})
105
+ if finite_float(signal.get("npm_downloads_week"), 0.0):
106
+ receipts.append({"label": "{}/wk".format(_num(finite_float(signal.get("npm_downloads_week")))), "kind": "npm"})
107
+ if finite_int(signal.get("reddit_score"), 0):
108
+ receipts.append({"label": "reddit {}".format(_num(finite_int(signal.get("reddit_score")))), "kind": "reddit"})
109
+ name = repo.get("name") if isinstance(repo.get("name"), str) else slug
110
+ meta = None
111
+ if name and name.casefold() != str(slug).casefold():
112
+ meta = name # a human title (HN/Reddit) the slug doesn't carry
113
+ name = slug
114
+ rows.append({
115
+ "rank": i, "name": name, "url": repo.get("url"), "meta": meta,
116
+ "receipts": receipts, "badges": _badges(repo),
117
+ })
118
+ return rows
119
+
120
+
121
+ def insider_rows(records: List[dict]) -> List[dict]:
122
+ """`hotin insiders`: repos the AI Insiders are backing, receipts led by names."""
123
+ rows: List[dict] = []
124
+ for i, rec in enumerate(records, 1):
125
+ if not isinstance(rec, dict):
126
+ continue
127
+ receipts = []
128
+ insider = _insider_receipt(rec)
129
+ if insider:
130
+ receipts.append(insider)
131
+ rows.append({
132
+ "rank": i, "name": rec.get("canonical_repo") or rec.get("name") or "?",
133
+ "url": rec.get("url"), "meta": None, "receipts": receipts,
134
+ "badges": [{"label": "smart-money", "hot": False}],
135
+ })
136
+ return rows
137
+
138
+
139
+ def model_rows(ranked: List[dict]) -> List[dict]:
140
+ rows: List[dict] = []
141
+ for i, m in enumerate(ranked, 1):
142
+ s = _sig(m)
143
+ receipts = []
144
+ if finite_int(s.get("model_downloads"), 0):
145
+ receipts.append({"label": "{} downloads".format(_num(finite_int(s.get("model_downloads")))), "kind": "npm"})
146
+ if finite_int(s.get("model_likes"), 0):
147
+ receipts.append({"label": "{} likes".format(_num(finite_int(s.get("model_likes")))), "kind": "stars"})
148
+ rows.append({"rank": i, "name": m.get("entity_id") or m.get("name") or "?",
149
+ "url": m.get("url"), "meta": (_meta(m).get("model_task") or None),
150
+ "receipts": receipts, "badges": _badges(m)})
151
+ return rows
152
+
153
+
154
+ def paper_rows(ranked: List[dict]) -> List[dict]:
155
+ rows: List[dict] = []
156
+ for i, p in enumerate(ranked, 1):
157
+ up = finite_int(_sig(p).get("paper_upvotes"), 0)
158
+ rows.append({"rank": i, "name": p.get("name") or p.get("entity_id") or "?",
159
+ "url": p.get("url"), "meta": None,
160
+ "receipts": ([{"label": "{} upvotes".format(_num(up)), "kind": "paper"}] if up else []),
161
+ "badges": [{"label": "paper-backed", "hot": False}] if _meta(p).get("linked_repo") else []})
162
+ return rows
163
+
164
+
165
+ def news_rows(items: List[dict]) -> List[dict]:
166
+ rows: List[dict] = []
167
+ for item in items:
168
+ if not isinstance(item, dict):
169
+ continue
170
+ # AINews titles are mostly the "not much happened today" filler, so the
171
+ # date is the useful, clickable handle; fall back to the title if dateless.
172
+ date = (_meta(item).get("date") or "")[:16]
173
+ rows.append({"rank": "·", "name": date or item.get("name") or "?",
174
+ "url": item.get("url"), "meta": None, "receipts": [], "badges": []})
175
+ return rows
176
+
177
+
178
+ def demo() -> None:
179
+ repo = {"canonical_repo": "a/b", "name": "A cool thing", "url": "u",
180
+ "signal": {"smartmoney_starrers": 3, "hn_points": 936, "stars_growth": 2100},
181
+ "meta": {"top_insider": "karpathy"}, "badges": ["fresh", "viral", "smart-money"]}
182
+ rows = repo_rows([repo])
183
+ r = rows[0]
184
+ assert r["rank"] == 1 and r["name"] == "a/b" and r["meta"] == "A cool thing"
185
+ labels = [x["label"] for x in r["receipts"]]
186
+ assert any("karpathy +2 insiders" in x for x in labels), labels
187
+ assert any("+2.1k stars" in x for x in labels) and any("936 pts" in x for x in labels)
188
+ badges = {(b["label"], b["hot"]) for b in r["badges"]}
189
+ assert ("trending", True) in badges and ("fresh", False) in badges and ("smart-money", False) in badges
190
+ ins = insider_rows([{"canonical_repo": "x/y", "url": "u", "signal": {"insider_stars": 5},
191
+ "meta": {"insiders": ["simonw", "deepfates"], "top_insider": "simonw"}}])
192
+ assert ins[0]["receipts"][0]["label"] == "★ simonw +4 insiders"
193
+ assert news_rows([{"name": "hi", "meta": {"date": "Fri, 18 Jul 2026"}}])[0]["rank"] == "·"
194
+ print("board demo: ok")
195
+
196
+
197
+ if __name__ == "__main__":
198
+ demo()
hotin/cache.py ADDED
@@ -0,0 +1,379 @@
1
+ """A best-effort local SQLite cache; cache failures must never stop hotin.
2
+
3
+ Records are keyed by ``(entity_type, entity_id, source)``. For repos,
4
+ ``entity_id == canonical_repo``; other entity types (paper, model) supply their
5
+ own id. URL is provenance, never identity.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import sqlite3
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+
17
+ LOGGER = logging.getLogger(__name__)
18
+
19
+ # Bump when the physical schema changes; _migrate() upgrades older databases.
20
+ SCHEMA_VERSION = 2
21
+
22
+ _TABLE_COLUMNS = """
23
+ id INTEGER PRIMARY KEY,
24
+ entity_type TEXT NOT NULL DEFAULT 'repo',
25
+ entity_id TEXT NOT NULL,
26
+ url TEXT NOT NULL,
27
+ canonical_repo TEXT,
28
+ name TEXT NOT NULL,
29
+ source TEXT NOT NULL,
30
+ signal_json TEXT NOT NULL,
31
+ fetched_at REAL NOT NULL,
32
+ UNIQUE(entity_type, entity_id, source)
33
+ """
34
+
35
+ # Append-only time series. run_id groups one ingest; the uniqueness key makes a
36
+ # retried run idempotent (same value, no dup) while a genuine later run appends a
37
+ # fresh sample. This is what velocity/acceleration are computed from.
38
+ _OBSERVATIONS_COLUMNS = """
39
+ id INTEGER PRIMARY KEY,
40
+ run_id TEXT NOT NULL,
41
+ entity_type TEXT NOT NULL,
42
+ entity_id TEXT NOT NULL,
43
+ source TEXT NOT NULL,
44
+ metric TEXT NOT NULL,
45
+ value REAL NOT NULL,
46
+ observed_at REAL NOT NULL,
47
+ UNIQUE(run_id, entity_type, entity_id, source, metric)
48
+ """
49
+
50
+
51
+ def cache_path() -> Path:
52
+ root = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
53
+ directory = root / "hotin"
54
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
55
+ return directory / "cache.db"
56
+
57
+
58
+ def _normalise_record(record: Dict[str, Any]) -> Dict[str, Any]:
59
+ signal = record.get("signal_json", {})
60
+ if not isinstance(signal, str):
61
+ signal = json.dumps(signal, sort_keys=True)
62
+ entity_type = record.get("entity_type") or "repo"
63
+ canonical = record.get("canonical_repo")
64
+ # entity_id: repos use the canonical repo; other entity types supply one.
65
+ # Fall back to canonical_repo, then url, so a row is never keyless.
66
+ entity_id = record.get("entity_id") or canonical or record.get("url") or ""
67
+ return {
68
+ "entity_type": str(entity_type),
69
+ "entity_id": str(entity_id),
70
+ "url": str(record.get("url", "")),
71
+ "canonical_repo": canonical,
72
+ "name": str(record.get("name", "")),
73
+ "source": str(record.get("source", "")),
74
+ "signal_json": signal,
75
+ "fetched_at": record.get("fetched_at", time.time()),
76
+ }
77
+
78
+
79
+ class MemoryCache:
80
+ """Interface-compatible cache used if SQLite cannot be used at all."""
81
+
82
+ def __init__(self) -> None:
83
+ self._records: Dict[Tuple[str, str, str], Dict[str, Any]] = {}
84
+ self._observations: List[Dict[str, Any]] = []
85
+
86
+ def upsert(self, record: Dict[str, Any]) -> None:
87
+ normalized = _normalise_record(record)
88
+ key = (normalized["entity_type"], normalized["entity_id"], normalized["source"])
89
+ self._records[key] = normalized
90
+
91
+ def record_observations(self, observations: List[Dict[str, Any]]) -> None:
92
+ seen = {(o["run_id"], o["entity_type"], o["entity_id"], o["source"], o["metric"]) for o in self._observations}
93
+ for obs in observations:
94
+ key = (obs["run_id"], obs["entity_type"], obs["entity_id"], obs["source"], obs["metric"])
95
+ if key not in seen:
96
+ seen.add(key)
97
+ self._observations.append(dict(obs))
98
+
99
+ def observations_for(self, entity_type: str, entity_id: str, metric: str) -> List[Tuple[float, float]]:
100
+ rows = [(o["value"], o["observed_at"]) for o in self._observations
101
+ if o["entity_type"] == entity_type and o["entity_id"] == entity_id and o["metric"] == metric]
102
+ return sorted(rows, key=lambda item: item[1])
103
+
104
+ def recent_observations(self, since: float) -> List[Dict[str, Any]]:
105
+ return [dict(o) for o in self._observations if o["observed_at"] >= since]
106
+
107
+ def prune_observations(self, cutoff: float) -> None:
108
+ self._observations = [o for o in self._observations if o["observed_at"] >= cutoff]
109
+
110
+ def search(self, query: str) -> List[Dict[str, Any]]:
111
+ """Return observations per ``(entity_type, entity_id, source)``.
112
+
113
+ Callers displaying tools must first route records through the engine's
114
+ merge/filter (e.g. ``engine.merge_by_repo``).
115
+ """
116
+ if not query.strip():
117
+ return []
118
+ needle = query.lower()
119
+ return [
120
+ dict(record)
121
+ for record in self._records.values()
122
+ if needle in " ".join(str(record.get(key) or "") for key in ("url", "canonical_repo", "name", "source")).lower()
123
+ ]
124
+
125
+ def get_all(self) -> List[Dict[str, Any]]:
126
+ """Return every observation; callers filter/merge in the engine."""
127
+ return [dict(record) for record in self._records.values()]
128
+
129
+ def close(self) -> None:
130
+ return None
131
+
132
+
133
+ class Cache:
134
+ """SQLite-backed cache with FTS5 when its Python SQLite build supports it."""
135
+
136
+ def __init__(self, connection: sqlite3.Connection) -> None:
137
+ self._connection = connection
138
+ self._fts_available = False
139
+ self._fallback: Optional[MemoryCache] = None
140
+ self._initialize()
141
+
142
+ def _initialize(self) -> None:
143
+ self._connection.execute("PRAGMA journal_mode=WAL")
144
+ self._connection.execute("PRAGMA busy_timeout=5000")
145
+ try:
146
+ self._migrate()
147
+ except sqlite3.Error as exc:
148
+ # A migration we cannot complete is not worth crashing over; degrade
149
+ # to the in-memory cache for this run rather than lose the command.
150
+ self._activate_fallback(exc)
151
+ return
152
+ fts_existed = self._connection.execute(
153
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tools_fts'"
154
+ ).fetchone() is not None
155
+ try:
156
+ self._create_fts()
157
+ if not fts_existed:
158
+ self._connection.execute(
159
+ """INSERT INTO tools_fts (rowid, url, canonical_repo, name, source)
160
+ SELECT id, url, canonical_repo, name, source FROM tools"""
161
+ )
162
+ self._fts_available = True
163
+ except sqlite3.OperationalError:
164
+ LOGGER.warning("SQLite FTS5 unavailable; cache search will use LIKE")
165
+ self._fts_available = False
166
+ self._connection.commit()
167
+
168
+ def _migrate(self) -> None:
169
+ """Bring the physical schema up to SCHEMA_VERSION, preserving data.
170
+
171
+ Handles: fresh DB, the legacy pre-release ``UNIQUE(url)`` schema, and the
172
+ shipped ``UNIQUE(url, source)`` schema (both at user_version 0). Rebuilds
173
+ ``tools`` when the entity columns are absent, deterministically keeping
174
+ the newest row per new key, then forces an FTS backfill.
175
+ """
176
+ conn = self._connection
177
+ version = conn.execute("PRAGMA user_version").fetchone()[0]
178
+ if version > SCHEMA_VERSION:
179
+ raise sqlite3.Error("cache is newer (v{}) than this hotin (v{})".format(version, SCHEMA_VERSION))
180
+ exists = conn.execute(
181
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='tools'"
182
+ ).fetchone() is not None
183
+ if not exists:
184
+ conn.execute("CREATE TABLE tools ({})".format(_TABLE_COLUMNS))
185
+ else:
186
+ columns = {row[1] for row in conn.execute("PRAGMA table_info(tools)")}
187
+ if "entity_type" not in columns or "entity_id" not in columns:
188
+ conn.execute("DROP TABLE IF EXISTS tools_new")
189
+ conn.execute("CREATE TABLE tools_new ({})".format(_TABLE_COLUMNS))
190
+ # Copy oldest-first so the newest row wins each (entity_id, source).
191
+ conn.execute(
192
+ """INSERT OR REPLACE INTO tools_new
193
+ (entity_type, entity_id, url, canonical_repo, name, source, signal_json, fetched_at)
194
+ SELECT 'repo', COALESCE(NULLIF(canonical_repo, ''), url), url,
195
+ canonical_repo, name, source, signal_json, fetched_at
196
+ FROM tools ORDER BY fetched_at ASC"""
197
+ )
198
+ conn.execute("DROP TABLE tools")
199
+ conn.execute("ALTER TABLE tools_new RENAME TO tools")
200
+ conn.execute("DROP TABLE IF EXISTS tools_fts") # force FTS rebuild
201
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_tools_fetched_at ON tools(fetched_at DESC)")
202
+ # v2: the append-only observation time series (additive, safe on any prior version).
203
+ conn.execute("CREATE TABLE IF NOT EXISTS observations ({})".format(_OBSERVATIONS_COLUMNS))
204
+ conn.execute(
205
+ "CREATE INDEX IF NOT EXISTS idx_obs_entity ON observations(entity_type, entity_id, metric, observed_at)"
206
+ )
207
+ conn.execute("PRAGMA user_version = {}".format(SCHEMA_VERSION))
208
+ conn.commit()
209
+
210
+ def _create_fts(self) -> None:
211
+ self._connection.execute(
212
+ """CREATE VIRTUAL TABLE IF NOT EXISTS tools_fts USING fts5(
213
+ url, canonical_repo, name, source
214
+ )"""
215
+ )
216
+
217
+ @staticmethod
218
+ def _row(row: sqlite3.Row) -> Dict[str, Any]:
219
+ return dict(row)
220
+
221
+ def _activate_fallback(self, exc: sqlite3.Error) -> MemoryCache:
222
+ LOGGER.warning("SQLite cache unavailable; using memory-only cache: %s", exc)
223
+ if self._fallback is None:
224
+ self._fallback = MemoryCache()
225
+ return self._fallback
226
+
227
+ def upsert(self, record: Dict[str, Any]) -> None:
228
+ normalized = _normalise_record(record)
229
+ if self._fallback is not None:
230
+ self._fallback.upsert(normalized)
231
+ return
232
+ try:
233
+ self._connection.execute(
234
+ """INSERT INTO tools (entity_type, entity_id, url, canonical_repo, name, source, signal_json, fetched_at)
235
+ VALUES (:entity_type, :entity_id, :url, :canonical_repo, :name, :source, :signal_json, :fetched_at)
236
+ ON CONFLICT(entity_type, entity_id, source) DO UPDATE SET
237
+ url=excluded.url,
238
+ canonical_repo=excluded.canonical_repo,
239
+ name=excluded.name,
240
+ signal_json=excluded.signal_json,
241
+ fetched_at=excluded.fetched_at""",
242
+ normalized,
243
+ )
244
+ if self._fts_available:
245
+ row = self._connection.execute(
246
+ "SELECT id FROM tools WHERE entity_type = ? AND entity_id = ? AND source = ?",
247
+ (normalized["entity_type"], normalized["entity_id"], normalized["source"]),
248
+ ).fetchone()
249
+ rowid = row[0]
250
+ self._connection.execute("DELETE FROM tools_fts WHERE rowid = ?", (rowid,))
251
+ self._connection.execute(
252
+ "INSERT INTO tools_fts (rowid, url, canonical_repo, name, source) VALUES (?, ?, ?, ?, ?)",
253
+ (rowid, normalized["url"], normalized["canonical_repo"], normalized["name"], normalized["source"]),
254
+ )
255
+ self._connection.commit()
256
+ except sqlite3.Error as exc:
257
+ self._activate_fallback(exc).upsert(normalized)
258
+
259
+ def search(self, query: str) -> List[Dict[str, Any]]:
260
+ """Return matching observations; callers filter/merge in the engine."""
261
+ if not query.strip():
262
+ return []
263
+ if self._fallback is not None:
264
+ return self._fallback.search(query)
265
+ try:
266
+ if self._fts_available:
267
+ try:
268
+ rows = self._connection.execute(
269
+ """SELECT t.* FROM tools t JOIN tools_fts f ON t.id = f.rowid
270
+ WHERE tools_fts MATCH ? ORDER BY t.fetched_at DESC""",
271
+ (self._fts_query(query),),
272
+ ).fetchall()
273
+ except sqlite3.OperationalError:
274
+ # A user query can be invalid FTS syntax; retain the same
275
+ # friendly substring semantics as the no-FTS build.
276
+ rows = self._like_search(query)
277
+ else:
278
+ rows = self._like_search(query)
279
+ return [self._row(row) for row in rows]
280
+ except sqlite3.Error as exc:
281
+ return self._activate_fallback(exc).search(query)
282
+
283
+ def _like_search(self, query: str) -> List[sqlite3.Row]:
284
+ escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
285
+ pattern = "%{}%".format(escaped)
286
+ return self._connection.execute(
287
+ """SELECT * FROM tools
288
+ WHERE url LIKE ? ESCAPE '\\' OR canonical_repo LIKE ? ESCAPE '\\'
289
+ OR name LIKE ? ESCAPE '\\' OR source LIKE ? ESCAPE '\\'
290
+ ORDER BY fetched_at DESC""",
291
+ (pattern, pattern, pattern, pattern),
292
+ ).fetchall()
293
+
294
+ @staticmethod
295
+ def _fts_query(query: str) -> str:
296
+ return " ".join('"{}"*'.format(token.replace('"', '""')) for token in query.split())
297
+
298
+ def get_all(self) -> List[Dict[str, Any]]:
299
+ """Return every observation; callers filter/merge in the engine."""
300
+ if self._fallback is not None:
301
+ return self._fallback.get_all()
302
+ try:
303
+ rows = self._connection.execute("SELECT * FROM tools ORDER BY fetched_at DESC").fetchall()
304
+ return [self._row(row) for row in rows]
305
+ except sqlite3.Error as exc:
306
+ return self._activate_fallback(exc).get_all()
307
+
308
+ def record_observations(self, observations: List[Dict[str, Any]]) -> None:
309
+ """Append time-series samples idempotently (INSERT OR IGNORE on the key)."""
310
+ rows = list(observations)
311
+ if not rows:
312
+ return
313
+ if self._fallback is not None:
314
+ self._fallback.record_observations(rows)
315
+ return
316
+ try:
317
+ self._connection.executemany(
318
+ """INSERT OR IGNORE INTO observations
319
+ (run_id, entity_type, entity_id, source, metric, value, observed_at)
320
+ VALUES (:run_id, :entity_type, :entity_id, :source, :metric, :value, :observed_at)""",
321
+ rows,
322
+ )
323
+ self._connection.commit()
324
+ except sqlite3.Error as exc:
325
+ self._activate_fallback(exc).record_observations(rows)
326
+
327
+ def observations_for(self, entity_type: str, entity_id: str, metric: str) -> List[Tuple[float, float]]:
328
+ """Return ``(value, observed_at)`` samples for one metric, oldest first."""
329
+ if self._fallback is not None:
330
+ return self._fallback.observations_for(entity_type, entity_id, metric)
331
+ try:
332
+ rows = self._connection.execute(
333
+ "SELECT value, observed_at FROM observations WHERE entity_type=? AND entity_id=? AND metric=? ORDER BY observed_at ASC",
334
+ (entity_type, entity_id, metric),
335
+ ).fetchall()
336
+ return [(row[0], row[1]) for row in rows]
337
+ except sqlite3.Error as exc:
338
+ return self._activate_fallback(exc).observations_for(entity_type, entity_id, metric)
339
+
340
+ def recent_observations(self, since: float) -> List[Dict[str, Any]]:
341
+ """Every observation at/after ``since`` (for the brief's deltas)."""
342
+ if self._fallback is not None:
343
+ return self._fallback.recent_observations(since)
344
+ try:
345
+ rows = self._connection.execute(
346
+ "SELECT entity_type, entity_id, source, metric, value, observed_at FROM observations WHERE observed_at >= ? ORDER BY observed_at ASC",
347
+ (since,),
348
+ ).fetchall()
349
+ return [dict(row) for row in rows]
350
+ except sqlite3.Error as exc:
351
+ return self._activate_fallback(exc).recent_observations(since)
352
+
353
+ def prune_observations(self, cutoff: float) -> None:
354
+ if self._fallback is not None:
355
+ self._fallback.prune_observations(cutoff)
356
+ return
357
+ try:
358
+ self._connection.execute("DELETE FROM observations WHERE observed_at < ?", (cutoff,))
359
+ self._connection.commit()
360
+ except sqlite3.Error:
361
+ pass
362
+
363
+ def close(self) -> None:
364
+ try:
365
+ self._connection.close()
366
+ except sqlite3.Error as exc:
367
+ LOGGER.warning("could not close SQLite cache: %s", exc)
368
+
369
+
370
+ def open_cache() -> Any:
371
+ """Open the persistent cache, or return an in-memory replacement on failure."""
372
+ try:
373
+ path = cache_path()
374
+ connection = sqlite3.connect(str(path))
375
+ connection.row_factory = sqlite3.Row
376
+ return Cache(connection)
377
+ except (sqlite3.Error, OSError) as exc:
378
+ LOGGER.warning("SQLite cache unavailable; using memory-only cache: %s", exc)
379
+ return MemoryCache()
hotin/canonical.py ADDED
@@ -0,0 +1,59 @@
1
+ """Normalize the GitHub repository references that sources commonly emit."""
2
+
3
+ import re
4
+ from typing import Optional
5
+
6
+
7
+ RESERVED_TOP_LEVEL = {
8
+ "topics", "trending", "sponsors", "marketplace", "settings", "orgs", "apps", "about",
9
+ "features", "pricing", "explore", "enterprise", "notifications", "login", "join", "new",
10
+ "collections", "readme", "security", "contact", "pulls", "issues", "dashboard", "watching",
11
+ "stars", "site", "business", "customer-stories", "team", "mobile", "discussions", "account",
12
+ "search", "blog", "organizations",
13
+ }
14
+
15
+ _GITHUB_URL = re.compile(
16
+ r"^(?:https?://)?(?:www\.)?github\.com/([^/\s]+)/([^/\s?#]+)(?:/.*)?/?$",
17
+ re.IGNORECASE,
18
+ )
19
+ _SCP_STYLE = re.compile(
20
+ r"^git@github\.com:([^/\s]+)/([^/\s?#]+?)(?:\.git)?/?$",
21
+ re.IGNORECASE,
22
+ )
23
+ _BARE_REPO = re.compile(r"^([^/\s@:]+)/([^/\s?#]+?)/?$", re.IGNORECASE)
24
+
25
+ # Finds a repository-shaped GitHub link embedded in free text (an HN story body,
26
+ # a Reddit post, a YouTube description). canonicalize() performs the
27
+ # authoritative owner/repository validation on whatever this matches.
28
+ GITHUB_URL_IN_TEXT_RE = re.compile(
29
+ r"https?://(?:www\.)?github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)",
30
+ re.IGNORECASE,
31
+ )
32
+
33
+
34
+ def trim_glued_repo_name(repo: str) -> str:
35
+ """Trim prose glued directly onto a repo name with no separator.
36
+
37
+ Free text sometimes glues the next sentence straight onto a URL. This
38
+ deliberately sacrifices genuine camelCase repo names such as ``myProject``
39
+ to avoid attributing prose such as ``repoGithub`` to a nonexistent repo.
40
+ """
41
+ return re.split(r"(?<=[a-z0-9])(?=[A-Z])", repo, maxsplit=1)[0]
42
+
43
+
44
+ def canonicalize(url: str) -> Optional[str]:
45
+ """Return lower-case ``owner/repo`` for supported GitHub URL variants."""
46
+ if not isinstance(url, str):
47
+ return None
48
+ candidate = url.strip().split("?", 1)[0].split("#", 1)[0]
49
+ match = _GITHUB_URL.match(candidate) or _SCP_STYLE.match(candidate) or _BARE_REPO.match(candidate)
50
+ if not match:
51
+ return None
52
+ owner, repo = match.group(1).lower(), match.group(2).lower()
53
+ if owner in RESERVED_TOP_LEVEL or not owner or not repo:
54
+ return None
55
+ if repo.endswith(".git"):
56
+ repo = repo[:-4]
57
+ if not repo or repo in {".", ".."}:
58
+ return None
59
+ return "{}/{}".format(owner, repo)