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 +3 -0
- hotin/__main__.py +8 -0
- hotin/board.py +198 -0
- hotin/cache.py +379 -0
- hotin/canonical.py +59 -0
- hotin/categories.py +64 -0
- hotin/cli.py +1015 -0
- hotin/coerce.py +31 -0
- hotin/config.py +97 -0
- hotin/dedupe.py +34 -0
- hotin/engine.py +505 -0
- hotin/health.py +29 -0
- hotin/render.py +48 -0
- hotin/render_board.py +196 -0
- hotin/schedule.py +128 -0
- hotin/sources/__init__.py +1 -0
- hotin/sources/_hf.py +42 -0
- hotin/sources/collections.py +204 -0
- hotin/sources/frontier.py +183 -0
- hotin/sources/github.py +263 -0
- hotin/sources/hfmodels.py +118 -0
- hotin/sources/hfpapers.py +162 -0
- hotin/sources/hn.py +288 -0
- hotin/sources/insiders.py +210 -0
- hotin/sources/npm.py +293 -0
- hotin/sources/reddit.py +262 -0
- hotin/sources/smartmoney.py +247 -0
- hotin/sources/smolai.py +178 -0
- hotin/sources/trends.py +227 -0
- hotin/sources/x.py +25 -0
- hotin/sources/youtube.py +501 -0
- hotin/throttle.py +42 -0
- hotin-0.2.0.dist-info/METADATA +137 -0
- hotin-0.2.0.dist-info/RECORD +39 -0
- hotin-0.2.0.dist-info/WHEEL +5 -0
- hotin-0.2.0.dist-info/entry_points.txt +2 -0
- hotin-0.2.0.dist-info/licenses/LICENSE +190 -0
- hotin-0.2.0.dist-info/licenses/NOTICE +8 -0
- hotin-0.2.0.dist-info/top_level.txt +1 -0
hotin/__init__.py
ADDED
hotin/__main__.py
ADDED
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)
|