decentralized and customizable links page on top of atproto ligo.at
atproto link-in-bio python uv

get_record async

+65 -45
+1 -1
pyproject.toml
··· 7 7 dependencies = [ 8 8 "authlib>=1.3", 9 9 "dnspython>=2.8.0", 10 - "flask[dotenv]>=3.1.2", 10 + "flask[async,dotenv]>=3.1.2", 11 11 "gunicorn>=23.0.0", 12 12 "httpx>=0.28.1", 13 13 ]
+15 -12
src/atproto/__init__.py
··· 194 194 return meta 195 195 196 196 197 - def get_record( 197 + async def get_record( 198 198 pds: str, 199 199 repo: str, 200 200 collection: str, ··· 202 202 type: str | None = None, 203 203 ) -> dict[str, Any] | None: 204 204 """Retrieve record from PDS. Verifies type is the same as collection name.""" 205 - response = httpx.get( 206 - f"{pds}/xrpc/com.atproto.repo.getRecord?repo={repo}&collection={collection}&rkey={record}" 207 - ) 208 - if not response.is_success: 209 - return None 210 - parsed = response.json() 211 - value: dict[str, Any] = parsed["value"] 212 - if value["$type"] != (type or collection): 213 - return None 214 - del value["$type"] 215 - return value 205 + 206 + async with httpx.AsyncClient() as client: 207 + response = await client.get( 208 + f"{pds}/xrpc/com.atproto.repo.getRecord?repo={repo}&collection={collection}&rkey={record}" 209 + ) 210 + if not response.is_success: 211 + return None 212 + parsed = response.json() 213 + value: dict[str, Any] = parsed["value"] 214 + if value["$type"] != (type or collection): 215 + return None 216 + del value["$type"] 217 + 218 + return value
+1 -1
src/db.py
··· 38 38 db: sqlite3.Connection | None = g.get("db", None) 39 39 if db is None: 40 40 db_path: str = app.config.get("DATABASE_URL", "ligoat.db") 41 - db = g.db = sqlite3.connect(db_path) 41 + db = g.db = sqlite3.connect(db_path, check_same_thread=False) 42 42 # return rows as dict-like objects 43 43 db.row_factory = sqlite3.Row 44 44 return db
+34 -29
src/main.py
··· 1 + import asyncio 2 + import json 3 + 1 4 from flask import Flask, g, session, redirect, render_template, request, url_for 2 5 from typing import Any 3 - import json 4 6 5 7 from .atproto import ( 6 8 PdsUrl, ··· 41 43 return render_template("index.html") 42 44 43 45 44 - @app.get("/did:<string:did>") 45 - def page_profile_with_did(did: str): 46 - did = f"did:{did}" 47 - if not is_valid_did(did): 48 - return render_template("error.html", message="invalid did"), 400 49 - return page_profile(did) 50 - 51 - 52 - @app.get("/@<string:handle>") 53 - def page_profile_with_handle(handle: str): 46 + @app.get("/<string:atid>") 47 + async def page_profile(atid: str): 54 48 reload = request.args.get("reload") is not None 55 - kv = KV(app, "did_from_handle") 56 - did = resolve_did_from_handle(handle, kv, reload=reload) 57 - if did is None: 58 - return render_template("error.html", message="did not found"), 404 59 - return page_profile(did, reload=reload) 60 49 50 + if atid.startswith("@"): 51 + handle = atid[1:].lower() 52 + did = resolve_did_from_handle(handle, reload=reload) 53 + if did is None: 54 + return render_template("error.html", message="did not found"), 404 55 + elif is_valid_did(atid): 56 + did = atid 57 + else: 58 + return render_template("error.html", message="invalid did or handle"), 400 61 59 62 - def page_profile(did: str, reload: bool = False): 63 60 if _is_did_blocked(did): 64 - app.logger.debug(f"handling blocked did {did}") 65 61 return render_template("error.html", message="profile not found"), 404 62 + 66 63 kv = KV(app, "pds_from_did") 67 64 pds = resolve_pds_from_did(did, kv, reload=reload) 68 65 if pds is None: 69 66 return render_template("error.html", message="pds not found"), 404 70 - profile, _ = load_profile(pds, did, reload=reload) 71 - links = load_links(pds, did, reload=reload) 67 + (profile, _), links = await asyncio.gather( 68 + load_profile(pds, did, reload=reload), 69 + load_links(pds, did, reload=reload), 70 + ) 72 71 if links is None: 73 72 return render_template("error.html", message="profile not found"), 404 74 73 ··· 103 102 104 103 105 104 @app.get("/editor") 106 - def page_editor(): 105 + async def page_editor(): 107 106 user = get_user() 108 107 if user is None: 109 108 return redirect("/login") ··· 112 111 pds: str = user.pds_url 113 112 handle: str | None = user.handle 114 113 115 - profile, from_bluesky = load_profile(pds, did, reload=True) 116 - links = load_links(pds, did, reload=True) or [{"background": "#fa0"}] 114 + (profile, from_bluesky), links = await asyncio.gather( 115 + load_profile(pds, did, reload=True), 116 + load_links(pds, did, reload=True), 117 + ) 117 118 118 119 return render_template( 119 120 "editor.html", 120 121 handle=handle, 121 122 profile=profile, 122 123 profile_from_bluesky=from_bluesky, 123 - links=json.dumps(links), 124 + links=json.dumps(links or [{"background": "#fa0"}]), 124 125 ) 125 126 126 127 ··· 195 196 return "come back soon" 196 197 197 198 198 - def load_links(pds: str, did: str, reload: bool = False) -> list[dict[str, str]] | None: 199 + async def load_links( 200 + pds: str, 201 + did: str, 202 + reload: bool = False, 203 + ) -> list[dict[str, str]] | None: 199 204 kv = KV(app, "links_from_did") 200 205 links = kv.get(did) 201 206 ··· 203 208 app.logger.debug(f"returning cached links for {did}") 204 209 return json.loads(links) 205 210 206 - record = get_record(pds, did, f"{SCHEMA}.actor.links", "self") 211 + record = await get_record(pds, did, f"{SCHEMA}.actor.links", "self") 207 212 if record is None: 208 213 return None 209 214 ··· 213 218 return links 214 219 215 220 216 - def load_profile( 221 + async def load_profile( 217 222 pds: str, 218 223 did: str, 219 224 reload: bool = False, ··· 226 231 return json.loads(profile), False 227 232 228 233 from_bluesky = False 229 - record = get_record(pds, did, f"{SCHEMA}.actor.profile", "self") 234 + record = await get_record(pds, did, f"{SCHEMA}.actor.profile", "self") 230 235 if record is None: 231 - record = get_record(pds, did, "app.bsky.actor.profile", "self") 236 + record = await get_record(pds, did, "app.bsky.actor.profile", "self") 232 237 from_bluesky = True 233 238 if record is None: 234 239 return None, False
+14 -2
uv.lock
··· 16 16 ] 17 17 18 18 [[package]] 19 + name = "asgiref" 20 + version = "3.10.0" 21 + source = { registry = "https://pypi.org/simple" } 22 + sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" } 23 + wheels = [ 24 + { url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" }, 25 + ] 26 + 27 + [[package]] 19 28 name = "authlib" 20 29 version = "1.6.5" 21 30 source = { registry = "https://pypi.org/simple" } ··· 173 182 ] 174 183 175 184 [package.optional-dependencies] 185 + async = [ 186 + { name = "asgiref" }, 187 + ] 176 188 dotenv = [ 177 189 { name = "python-dotenv" }, 178 190 ] ··· 263 275 dependencies = [ 264 276 { name = "authlib" }, 265 277 { name = "dnspython" }, 266 - { name = "flask", extra = ["dotenv"] }, 278 + { name = "flask", extra = ["async", "dotenv"] }, 267 279 { name = "gunicorn" }, 268 280 { name = "httpx" }, 269 281 ] ··· 272 284 requires-dist = [ 273 285 { name = "authlib", specifier = ">=1.3" }, 274 286 { name = "dnspython", specifier = ">=2.8.0" }, 275 - { name = "flask", extras = ["dotenv"], specifier = ">=3.1.2" }, 287 + { name = "flask", extras = ["async", "dotenv"], specifier = ">=3.1.2" }, 276 288 { name = "gunicorn", specifier = ">=23.0.0" }, 277 289 { name = "httpx", specifier = ">=0.28.1" }, 278 290 ]