The code for my Bluesky feed generator

Set up new feed

+1
.python-version
··· 1 + 3.12
-8
.vscode/settings.json
··· 1 - { 2 - "cSpell.words": [ 3 - "ATPROTO", 4 - "bluesky", 5 - "bsky", 6 - "dotenv" 7 - ] 8 - }
+33 -28
publish_feed.py
··· 9 9 10 10 load_dotenv() 11 11 12 + 12 13 def _get_bool_env_var(value: str) -> bool: 13 14 # Helper function to convert string to bool 14 15 ··· 16 17 return False 17 18 18 19 normalized_value = value.strip().lower() 19 - if normalized_value in {'1', 'true', 't', 'yes', 'y'}: 20 + if normalized_value in {"1", "true", "t", "yes", "y"}: 20 21 return True 21 22 22 23 return False ··· 24 25 25 26 # YOUR bluesky handle 26 27 # Ex: user.bsky.social 27 - HANDLE: str = os.environ.get('HANDLE') 28 + HANDLE: str = os.environ.get("HANDLE") 28 29 29 30 # YOUR bluesky password, or preferably an App Password (found in your client settings) 30 31 # Ex: abcd-1234-efgh-5678 31 - PASSWORD: str = os.environ.get('PASSWORD') 32 + PASSWORD: str = os.environ.get("PASSWORD") 32 33 33 34 # The hostname of the server where feed server will be hosted 34 35 # Ex: feed.bsky.dev 35 - HOSTNAME: str = os.environ.get('HOSTNAME') 36 + HOSTNAME: str = os.environ.get("HOSTNAME") 36 37 37 38 # A short name for the record that will show in urls 38 39 # Lowercase with no spaces. 39 40 # Ex: whats-hot 40 - RECORD_NAME: str = os.environ.get('RECORD_NAME') 41 + RECORD_NAME: str = os.environ.get("RECORD_NAME") 41 42 42 43 # A display name for your feed 43 44 # Ex: What's Hot 44 - DISPLAY_NAME: str = os.environ.get('DISPLAY_NAME') 45 + DISPLAY_NAME: str = os.environ.get("DISPLAY_NAME") 45 46 46 47 # (Optional) A description of your feed 47 48 # Ex: Top trending content from the whole network 48 - DESCRIPTION: str = os.environ.get('DESCRIPTION') 49 + DESCRIPTION: str = os.environ.get("DESCRIPTION") 49 50 50 51 # (Optional) The path to an image to be used as your feed's avatar 51 52 # Ex: ./path/to/avatar.jpeg 52 - AVATAR_PATH: str = os.environ.get('AVATAR_PATH') 53 + AVATAR_PATH: str = os.environ.get("AVATAR_PATH") 53 54 54 55 # (Optional). Only use this if you want a service did different from did:web 55 - SERVICE_DID: str = os.environ.get('SERVICE_DID') 56 + SERVICE_DID: str = os.environ.get("SERVICE_DID") 56 57 57 58 # (Optional). If your feed accepts interactions from clients 58 - ACCEPTS_INTERACTIONS: bool = _get_bool_env_var(os.environ.get('ACCEPTS_INTERACTIONS')) 59 + ACCEPTS_INTERACTIONS: bool = _get_bool_env_var(os.environ.get("ACCEPTS_INTERACTIONS")) 59 60 60 61 # (Optional). If your feed is a video feed 61 - IS_VIDEO_FEED: bool = _get_bool_env_var(os.environ.get('IS_VIDEO_FEED')) 62 + IS_VIDEO_FEED: bool = _get_bool_env_var(os.environ.get("IS_VIDEO_FEED")) 62 63 63 64 # ------------------------------------- 64 65 # NO NEED TO TOUCH ANYTHING BELOW HERE ··· 71 72 72 73 feed_did = SERVICE_DID 73 74 if not feed_did: 74 - feed_did = f'did:web:{HOSTNAME}' 75 + feed_did = f"did:web:{HOSTNAME}" 75 76 76 77 avatar_blob = None 77 78 if AVATAR_PATH: 78 - with open(AVATAR_PATH, 'rb') as f: 79 + with open(AVATAR_PATH, "rb") as f: 79 80 avatar_data = f.read() 80 81 avatar_blob = client.upload_blob(avatar_data).blob 81 82 82 - response = client.com.atproto.repo.put_record(models.ComAtprotoRepoPutRecord.Data( 83 - repo=client.me.did, 84 - collection=models.ids.AppBskyFeedGenerator, 85 - rkey=RECORD_NAME, 86 - record=models.AppBskyFeedGenerator.Record( 87 - did=feed_did, 88 - display_name=DISPLAY_NAME, 89 - accepts_interactions=ACCEPTS_INTERACTIONS, 90 - description=DESCRIPTION, 91 - avatar=avatar_blob, 92 - content_mode='app.bsky.feed.defs#contentModeVideo' if IS_VIDEO_FEED else None, 93 - created_at=client.get_current_time_iso(), 83 + response = client.com.atproto.repo.put_record( 84 + models.ComAtprotoRepoPutRecord.Data( 85 + repo=client.me.did, 86 + collection=models.ids.AppBskyFeedGenerator, 87 + rkey=RECORD_NAME, 88 + record=models.AppBskyFeedGenerator.Record( 89 + did=feed_did, 90 + display_name=DISPLAY_NAME, 91 + accepts_interactions=ACCEPTS_INTERACTIONS, 92 + description=DESCRIPTION, 93 + avatar=avatar_blob, 94 + content_mode="app.bsky.feed.defs#contentModeVideo" 95 + if IS_VIDEO_FEED 96 + else None, 97 + created_at=client.get_current_time_iso(), 98 + ), 94 99 ) 95 - )) 100 + ) 96 101 97 - print('Successfully published!') 102 + print("Successfully published!") 98 103 print('Feed URI (put in "FEED_URI" env var):', response.uri) 99 104 100 105 101 - if __name__ == '__main__': 106 + if __name__ == "__main__": 102 107 main()
+14 -1
pyproject.toml
··· 1 1 [project] 2 2 name = "" 3 3 version = "0.0.1" 4 + requires-python = ">=3.12" 4 5 dependencies = [ 5 6 "atproto==0.0.59", 6 - "peewee~=3.16.2", 7 + "peewee~=3.18.2", 7 8 "flask~=2.3.2", 8 9 "python-dotenv~=1.0.0", 10 + ] 11 + 12 + [dependency-groups] 13 + dev = [ 14 + {include-group = "lint"}, 15 + {include-group = "test"} 16 + ] 17 + lint = [ 18 + "ruff" 19 + ] 20 + test = [ 21 + "pytest" 9 22 ] 10 23 11 24 [tool.uv]
+8
server/__init__.py
··· 1 + from atproto import models 2 + 3 + RELEVANT_RECORDS = { 4 + models.AppBskyFeedLike: models.ids.AppBskyFeedLike, 5 + models.AppBskyFeedPost: models.ids.AppBskyFeedPost, 6 + models.AppBskyGraphFollow: models.ids.AppBskyGraphFollow, 7 + models.AppBskyFeedRepost: models.ids.AppBskyFeedRepost, 8 + }
+2 -2
server/__main__.py
··· 3 3 from app import app 4 4 from server.logger import logger 5 5 6 - if __name__ == '__main__': 6 + if __name__ == "__main__": 7 7 # FOR DEBUG PURPOSE ONLY 8 8 logger.setLevel(logging.DEBUG) 9 - app.run(host='127.0.0.1', port=8000, debug=True) 9 + app.run(host="127.0.0.1", port=8000, debug=True)
+3 -2
server/algos/__init__.py
··· 1 - from . import feed 1 + from . import following_sans_politics, since_u_been_gone 2 2 3 3 algos = { 4 - feed.uri: feed.handler 4 + since_u_been_gone.uri: since_u_been_gone.handler, 5 + following_sans_politics.uri: following_sans_politics.handler, 5 6 }
-38
server/algos/feed.py
··· 1 - from datetime import datetime 2 - from typing import Optional 3 - 4 - from server import config 5 - from server.database import Post 6 - 7 - uri = config.FEED_URI 8 - CURSOR_EOF = 'eof' 9 - 10 - 11 - def handler(cursor: Optional[str], limit: int) -> dict: 12 - posts = Post.select().order_by(Post.cid.desc()).order_by(Post.indexed_at.desc()).limit(limit) 13 - 14 - if cursor: 15 - if cursor == CURSOR_EOF: 16 - return { 17 - 'cursor': CURSOR_EOF, 18 - 'feed': [] 19 - } 20 - cursor_parts = cursor.split('::') 21 - if len(cursor_parts) != 2: 22 - raise ValueError('Malformed cursor') 23 - 24 - indexed_at, cid = cursor_parts 25 - indexed_at = datetime.fromtimestamp(int(indexed_at) / 1000) 26 - posts = posts.where(((Post.indexed_at == indexed_at) & (Post.cid < cid)) | (Post.indexed_at < indexed_at)) 27 - 28 - feed = [{'post': post.uri} for post in posts] 29 - 30 - cursor = CURSOR_EOF 31 - last_post = posts[-1] if posts else None 32 - if last_post: 33 - cursor = f'{int(last_post.indexed_at.timestamp() * 1000)}::{last_post.cid}' 34 - 35 - return { 36 - 'cursor': cursor, 37 - 'feed': feed 38 - }
+44
server/algos/following_sans_politics.py
··· 1 + from datetime import datetime 2 + from typing import Optional 3 + 4 + from server import config 5 + from server.database import Follow, Post 6 + 7 + uri = config.FEED_URI 8 + CURSOR_EOF = "eof" 9 + 10 + 11 + def handler(cursor: Optional[str], limit: int, requester_did: str | None = None) -> dict: 12 + following = Follow.select('subject').where(Follow.author == requester_did).dicts() 13 + following_dids: list[str] = [follow['subject'] for follow in following] 14 + 15 + posts_by_following = ( 16 + Post.select() 17 + .where(Post.author.in_(following_dids)) 18 + .order_by(Post.cid.desc()) 19 + .order_by(Post.indexed_at.desc()) 20 + .limit(limit) 21 + ) 22 + 23 + if cursor: 24 + if cursor == CURSOR_EOF: 25 + return {"cursor": CURSOR_EOF, "feed": []} 26 + cursor_parts = cursor.split("::") 27 + if len(cursor_parts) != 2: 28 + raise ValueError("Malformed cursor") 29 + 30 + indexed_at, cid = cursor_parts 31 + indexed_at = datetime.fromtimestamp(int(indexed_at) / 1000) 32 + posts_by_following = posts_by_following.where( 33 + ((Post.indexed_at == indexed_at) & (Post.cid < cid)) 34 + | (Post.indexed_at < indexed_at) 35 + ) 36 + 37 + feed = [{"post": post.uri} for post in posts_by_following] 38 + 39 + cursor = CURSOR_EOF 40 + last_post = posts_by_following[-1] if posts_by_following else None 41 + if last_post: 42 + cursor = f"{int(last_post.indexed_at.timestamp() * 1000)}::{last_post.cid}" 43 + 44 + return {"cursor": cursor, "feed": feed}
+40
server/algos/since_u_been_gone.py
··· 1 + from datetime import datetime 2 + from typing import Optional 3 + 4 + from server import config 5 + from server.database import Post 6 + 7 + uri = config.FEED_URI 8 + CURSOR_EOF = "eof" 9 + 10 + 11 + def handler(cursor: Optional[str], limit: int, _: str | None = None) -> dict: 12 + posts = ( 13 + Post.select() 14 + .order_by(Post.cid.desc()) 15 + .order_by(Post.indexed_at.desc()) 16 + .limit(limit) 17 + ) 18 + 19 + if cursor: 20 + if cursor == CURSOR_EOF: 21 + return {"cursor": CURSOR_EOF, "feed": []} 22 + cursor_parts = cursor.split("::") 23 + if len(cursor_parts) != 2: 24 + raise ValueError("Malformed cursor") 25 + 26 + indexed_at, cid = cursor_parts 27 + indexed_at = datetime.fromtimestamp(int(indexed_at) / 1000) 28 + posts = posts.where( 29 + ((Post.indexed_at == indexed_at) & (Post.cid < cid)) 30 + | (Post.indexed_at < indexed_at) 31 + ) 32 + 33 + feed = [{"post": post.uri} for post in posts] 34 + 35 + cursor = CURSOR_EOF 36 + last_post = posts[-1] if posts else None 37 + if last_post: 38 + cursor = f"{int(last_post.indexed_at.timestamp() * 1000)}::{last_post.cid}" 39 + 40 + return {"cursor": cursor, "feed": feed}
+41 -35
server/app.py
··· 1 - import sys 2 1 import signal 2 + import sys 3 3 import threading 4 - 5 - from server import config 6 - from server import data_stream 7 4 8 5 from flask import Flask, jsonify, request 9 6 7 + from server import config, data_stream 10 8 from server.algos import algos 9 + from server.auth import AuthorizationError, validate_auth 11 10 from server.data_filter import operations_callback 12 11 13 12 app = Flask(__name__) 14 13 15 14 stream_stop_event = threading.Event() 16 15 stream_thread = threading.Thread( 17 - target=data_stream.run, args=(config.SERVICE_DID, operations_callback, stream_stop_event,) 16 + target=data_stream.run, 17 + args=( 18 + config.SERVICE_DID, 19 + operations_callback, 20 + stream_stop_event, 21 + ), 18 22 ) 19 23 stream_thread.start() 20 24 21 25 22 26 def sigint_handler(*_): 23 - print('Stopping data stream...') 27 + print("Stopping data stream...") 24 28 stream_stop_event.set() 25 29 sys.exit(0) 26 30 ··· 28 32 signal.signal(signal.SIGINT, sigint_handler) 29 33 30 34 31 - @app.route('/') 35 + @app.route("/") 32 36 def index(): 33 - return 'ATProto Feed Generator powered by The AT Protocol SDK for Python (https://github.com/MarshalX/atproto).' 37 + return "ATProto Feed Generator powered by The AT Protocol SDK for Python (https://github.com/MarshalX/atproto)." 34 38 35 39 36 - @app.route('/.well-known/did.json', methods=['GET']) 40 + @app.route("/.well-known/did.json", methods=["GET"]) 37 41 def did_json(): 38 42 if not config.SERVICE_DID.endswith(config.HOSTNAME): 39 - return '', 404 43 + return "", 404 40 44 41 - return jsonify({ 42 - '@context': ['https://www.w3.org/ns/did/v1'], 43 - 'id': config.SERVICE_DID, 44 - 'service': [ 45 - { 46 - 'id': '#bsky_fg', 47 - 'type': 'BskyFeedGenerator', 48 - 'serviceEndpoint': f'https://{config.HOSTNAME}' 49 - } 50 - ] 51 - }) 45 + return jsonify( 46 + { 47 + "@context": ["https://www.w3.org/ns/did/v1"], 48 + "id": config.SERVICE_DID, 49 + "service": [ 50 + { 51 + "id": "#bsky_fg", 52 + "type": "BskyFeedGenerator", 53 + "serviceEndpoint": f"https://{config.HOSTNAME}", 54 + } 55 + ], 56 + } 57 + ) 52 58 53 59 54 - @app.route('/xrpc/app.bsky.feed.describeFeedGenerator', methods=['GET']) 60 + @app.route("/xrpc/app.bsky.feed.describeFeedGenerator", methods=["GET"]) 55 61 def describe_feed_generator(): 56 - feeds = [{'uri': uri} for uri in algos.keys()] 62 + feeds = [{"uri": uri} for uri in algos.keys()] 57 63 response = { 58 - 'encoding': 'application/json', 59 - 'body': { 60 - 'did': config.SERVICE_DID, 61 - 'feeds': feeds 62 - } 64 + "encoding": "application/json", 65 + "body": {"did": config.SERVICE_DID, "feeds": feeds}, 63 66 } 64 67 return jsonify(response) 65 68 66 69 67 - @app.route('/xrpc/app.bsky.feed.getFeedSkeleton', methods=['GET']) 70 + @app.route("/xrpc/app.bsky.feed.getFeedSkeleton", methods=["GET"]) 68 71 def get_feed_skeleton(): 69 - feed = request.args.get('feed', default=None, type=str) 72 + feed = request.args.get("feed", default=None, type=str) 70 73 algo = algos.get(feed) 71 74 if not algo: 72 - return 'Unsupported algorithm', 400 75 + return "Unsupported algorithm", 400 73 76 74 77 # Example of how to check auth if giving user-specific results: 75 78 """ ··· 81 84 """ 82 85 83 86 try: 84 - cursor = request.args.get('cursor', default=None, type=str) 85 - limit = request.args.get('limit', default=20, type=int) 86 - body = algo(cursor, limit) 87 + requester_did = validate_auth(request) 88 + cursor = request.args.get("cursor", default=None, type=str) 89 + limit = request.args.get("limit", default=20, type=int) 90 + body = algo(cursor, limit, requester_did) 91 + except AuthorizationError: 92 + return "Unauthorized", 401 87 93 except ValueError: 88 - return 'Malformed cursor', 400 94 + return "Malformed cursor", 400 89 95 90 96 return jsonify(body)
+7 -8
server/auth.py
··· 6 6 _CACHE = DidInMemoryCache() 7 7 _ID_RESOLVER = IdResolver(cache=_CACHE) 8 8 9 - _AUTHORIZATION_HEADER_NAME = 'Authorization' 10 - _AUTHORIZATION_HEADER_VALUE_PREFIX = 'Bearer ' 9 + _AUTHORIZATION_HEADER_NAME = "Authorization" 10 + _AUTHORIZATION_HEADER_VALUE_PREFIX = "Bearer " 11 11 12 12 13 - class AuthorizationError(Exception): 14 - ... 13 + class AuthorizationError(Exception): ... 15 14 16 15 17 - def validate_auth(request: 'Request') -> str: 16 + def validate_auth(request: "Request") -> str: 18 17 """Validate authorization header. 19 18 20 19 Args: ··· 28 27 """ 29 28 auth_header = request.headers.get(_AUTHORIZATION_HEADER_NAME) 30 29 if not auth_header: 31 - raise AuthorizationError('Authorization header is missing') 30 + raise AuthorizationError("Authorization header is missing") 32 31 33 32 if not auth_header.startswith(_AUTHORIZATION_HEADER_VALUE_PREFIX): 34 - raise AuthorizationError('Invalid authorization header') 33 + raise AuthorizationError("Invalid authorization header") 35 34 36 35 jwt = auth_header[len(_AUTHORIZATION_HEADER_VALUE_PREFIX) :].strip() 37 36 38 37 try: 39 38 return verify_jwt(jwt, _ID_RESOLVER.did.resolve_atproto_key).iss 40 39 except TokenInvalidSignatureError as e: 41 - raise AuthorizationError('Invalid signature') from e 40 + raise AuthorizationError("Invalid signature") from e
+12 -10
server/config.py
··· 7 7 8 8 load_dotenv() 9 9 10 - SERVICE_DID = os.environ.get('SERVICE_DID') 11 - HOSTNAME = os.environ.get('HOSTNAME') 12 - FLASK_RUN_FROM_CLI = os.environ.get('FLASK_RUN_FROM_CLI') 10 + SERVICE_DID = os.environ.get("SERVICE_DID") 11 + HOSTNAME = os.environ.get("HOSTNAME") 12 + FLASK_RUN_FROM_CLI = os.environ.get("FLASK_RUN_FROM_CLI") 13 13 14 14 if FLASK_RUN_FROM_CLI: 15 15 logger.setLevel(logging.DEBUG) ··· 18 18 raise RuntimeError('You should set "HOSTNAME" environment variable first.') 19 19 20 20 if not SERVICE_DID: 21 - SERVICE_DID = f'did:web:{HOSTNAME}' 21 + SERVICE_DID = f"did:web:{HOSTNAME}" 22 22 23 23 24 - FEED_URI = os.environ.get('FEED_URI') 24 + FEED_URI = os.environ.get("FEED_URI") 25 25 if not FEED_URI: 26 - raise RuntimeError('Publish your feed first (run publish_feed.py) to obtain Feed URI. ' 27 - 'Set this URI to "FEED_URI" environment variable.') 26 + raise RuntimeError( 27 + "Publish your feed first (run publish_feed.py) to obtain Feed URI. " 28 + 'Set this URI to "FEED_URI" environment variable.' 29 + ) 28 30 29 31 30 32 def _get_bool_env_var(value: str) -> bool: ··· 32 34 return False 33 35 34 36 normalized_value = value.strip().lower() 35 - if normalized_value in {'1', 'true', 't', 'yes', 'y'}: 37 + if normalized_value in {"1", "true", "t", "yes", "y"}: 36 38 return True 37 39 38 40 return False 39 41 40 42 41 - IGNORE_ARCHIVED_POSTS = _get_bool_env_var(os.environ.get('IGNORE_ARCHIVED_POSTS')) 42 - IGNORE_REPLY_POSTS = _get_bool_env_var(os.environ.get('IGNORE_REPLY_POSTS')) 43 + IGNORE_ARCHIVED_POSTS = _get_bool_env_var(os.environ.get("IGNORE_ARCHIVED_POSTS")) 44 + IGNORE_REPLY_POSTS = _get_bool_env_var(os.environ.get("IGNORE_REPLY_POSTS"))
+126 -38
server/data_filter.py
··· 1 1 import datetime 2 - 3 2 from collections import defaultdict 4 3 5 4 from atproto import models 6 5 7 6 from server import config 7 + from server.database import Follow, Like, Post, Repost, db 8 8 from server.logger import logger 9 - from server.database import db, Post 9 + from server.utils import get_author_from_uri 10 10 11 11 12 - def is_archive_post(record: 'models.AppBskyFeedPost.Record') -> bool: 12 + def is_archive_post(record: "models.AppBskyFeedPost.Record") -> bool: 13 13 # Sometimes users will import old posts from Twitter/X which con flood a feed with 14 14 # old posts. Unfortunately, the only way to test for this is to look an old 15 15 # created_at date. However, there are other reasons why a post might have an old ··· 27 27 28 28 29 29 def should_ignore_post(created_post: dict) -> bool: 30 - record = created_post['record'] 31 - uri = created_post['uri'] 30 + record = created_post["record"] 31 + uri = created_post["uri"] 32 32 33 33 if config.IGNORE_ARCHIVED_POSTS and is_archive_post(record): 34 - logger.debug(f'Ignoring archived post: {uri}') 34 + logger.debug(f"Ignoring archived post: {uri}") 35 35 return True 36 36 37 37 if config.IGNORE_REPLY_POSTS and record.reply: 38 - logger.debug(f'Ignoring reply post: {uri}') 38 + logger.debug(f"Ignoring reply post: {uri}") 39 39 return True 40 40 41 41 return False ··· 46 46 # After our feed alg we can save posts into our DB 47 47 # Also, we should process deleted posts to remove them from our DB and keep it in sync 48 48 49 - # for example, let's create our custom feed that will contain all posts that contains 'python' related text 50 - 49 + # Create posts 51 50 posts_to_create = [] 52 - for created_post in ops[models.ids.AppBskyFeedPost]['created']: 53 - author = created_post['author'] 54 - record = created_post['record'] 51 + for created_post in ops[models.ids.AppBskyFeedPost]["created"]: 52 + author = get_author_from_uri(created_post["uri"]) 53 + record = created_post["record"] 55 54 56 55 is_post_with_images = isinstance(record.embed, models.AppBskyEmbedImages.Main) 57 56 is_post_with_video = isinstance(record.embed, models.AppBskyEmbedVideo.Main) 58 - inlined_text = record.text.replace('\n', ' ') 57 + inlined_text = record.text.replace("\n", " ") 59 58 60 59 # print all texts just as demo that data stream works 61 60 logger.debug( 62 - f'NEW POST ' 63 - f'[CREATED_AT={record.created_at}]' 64 - f'[AUTHOR={author}]' 65 - f'[WITH_IMAGE={is_post_with_images}]' 66 - f'[WITH_VIDEO={is_post_with_video}]' 67 - f': {inlined_text}' 61 + f"NEW POST " 62 + f"[CREATED_AT={record.created_at}]" 63 + f"[AUTHOR={author}]" 64 + f"[WITH_IMAGE={is_post_with_images}]" 65 + f"[WITH_VIDEO={is_post_with_video}]" 66 + f": {inlined_text}" 68 67 ) 69 68 70 69 if should_ignore_post(created_post): 71 70 continue 72 71 73 - # only python-related posts 74 - if 'python' in record.text.lower(): 75 - reply_root = reply_parent = None 76 - if record.reply: 77 - reply_root = record.reply.root.uri 78 - reply_parent = record.reply.parent.uri 72 + reply_root = reply_parent = None 73 + if record.reply: 74 + reply_root = record.reply.root.uri 75 + reply_parent = record.reply.parent.uri 79 76 80 - post_dict = { 81 - 'uri': created_post['uri'], 82 - 'cid': created_post['cid'], 83 - 'reply_parent': reply_parent, 84 - 'reply_root': reply_root, 85 - } 86 - posts_to_create.append(post_dict) 77 + post_dict = { 78 + "created_at": record.created_at, 79 + "author": author, 80 + "uri": created_post["uri"], 81 + "cid": created_post["cid"], 82 + "text": record.text, 83 + "reply_parent": reply_parent, 84 + "reply_root": reply_root, 85 + } 86 + posts_to_create.append(post_dict) 87 87 88 - posts_to_delete = ops[models.ids.AppBskyFeedPost]['deleted'] 89 - if posts_to_delete: 90 - post_uris_to_delete = [post['uri'] for post in posts_to_delete] 91 - Post.delete().where(Post.uri.in_(post_uris_to_delete)) 92 - logger.debug(f'Deleted from feed: {len(post_uris_to_delete)}') 88 + # Create likes 89 + likes_to_create = [] 90 + for like in ops[models.ids.AppBskyFeedLike]["created"]: 91 + like_dict = { 92 + "created_at": like["record"]["created_at"], 93 + "author": like["author"], 94 + "uri": like["record"]["subject"]["uri"], 95 + "cid": like["record"]["subject"]["cid"], 96 + } 97 + likes_to_create.append(like_dict) 93 98 99 + # Create reposts 100 + reposts_to_create = [] 101 + for repost in ops[models.ids.AppBskyFeedRepost]["created"]: 102 + repost_dict = { 103 + "created_at": repost["record"]["created_at"], 104 + "author": repost["author"], 105 + "uri": repost["record"]["subject"]["uri"], 106 + "cid": repost["record"]["subject"]["cid"], 107 + } 108 + reposts_to_create.append(repost_dict) 109 + 110 + # Create follows 111 + follows_to_create = [] 112 + for follow in ops[models.ids.AppBskyGraphFollow]["created"]: 113 + follow_dict = { 114 + "created_at": follow["record"]["created_at"], 115 + "author": follow["author"], 116 + "subject": follow["record"]["subject"], 117 + } 118 + follows_to_create.append(follow_dict) 119 + 120 + # Create follows in db 121 + if follows_to_create: 122 + with db.atomic(): 123 + for follow_dict in follows_to_create: 124 + Follow.create(**follow_dict) 125 + logger.debug(f"Follows indexed: {len(follows_to_create)}") 126 + 127 + # Create posts in db 94 128 if posts_to_create: 95 129 with db.atomic(): 96 130 for post_dict in posts_to_create: 97 131 Post.create(**post_dict) 98 - logger.debug(f'Added to feed: {len(posts_to_create)}') 132 + logger.debug(f"Posts indexed: {len(posts_to_create)}") 133 + 134 + # Create likes in db 135 + if likes_to_create: 136 + with db.atomic(): 137 + for like_dict in likes_to_create: 138 + Like.create(**like_dict) 139 + logger.debug(f"Likes indexed: {len(likes_to_create)}") 140 + 141 + # Create reposts in db 142 + if reposts_to_create: 143 + with db.atomic(): 144 + for repost_dict in reposts_to_create: 145 + Repost.create(**repost_dict) 146 + logger.debug(f"Reposts indexed: {len(reposts_to_create)}") 147 + 148 + # Delete posts in db 149 + posts_to_delete = ops[models.ids.AppBskyFeedPost]["deleted"] 150 + if posts_to_delete: 151 + post_uris_to_delete = [post["uri"] for post in posts_to_delete] 152 + Post.delete().where(Post.uri.in_(post_uris_to_delete)) 153 + logger.debug(f"Posts deleted: {len(post_uris_to_delete)}") 154 + 155 + # Delete follows in db 156 + follows_to_delete = ops[models.ids.AppBskyGraphFollow]["deleted"] 157 + follow_uris = [] 158 + if follows_to_delete: 159 + for follow in follows_to_delete: 160 + follow_uris.append(follow["uri"]) 161 + Follow.delete().where( 162 + Follow.uri.in_(follow_uris) 163 + ) 164 + logger.debug(f"Follows deleted: {len(follows_to_delete)}") 165 + 166 + # Delete likes in db 167 + likes_to_delete = ops[models.ids.AppBskyFeedLike]["deleted"] 168 + like_uris = [] 169 + if likes_to_delete: 170 + for like in likes_to_delete: 171 + like_uris.append(like["uri"]) 172 + Like.delete().where( 173 + Like.uri.in_(like_uris) 174 + ) 175 + logger.debug(f"Likes deleted: {len(likes_to_delete)}") 176 + 177 + # Delete reposts in db 178 + reposts_to_delete = ops[models.ids.AppBskyFeedRepost]["deleted"] 179 + repost_uris = [] 180 + if reposts_to_delete: 181 + for repost in reposts_to_delete: 182 + repost_uris.append(repost["uri"]) 183 + Repost.delete().where( 184 + Repost.uri.in_(repost_uris) 185 + ) 186 + logger.debug(f"Reposts deleted: {len(reposts_to_delete)}")
+59 -24
server/data_stream.py
··· 1 1 import logging 2 2 from collections import defaultdict 3 3 4 - from atproto import AtUri, CAR, firehose_models, FirehoseSubscribeReposClient, models, parse_subscribe_repos_message 4 + from atproto import ( 5 + CAR, 6 + AtUri, 7 + FirehoseSubscribeReposClient, 8 + firehose_models, 9 + models, 10 + parse_subscribe_repos_message, 11 + ) 5 12 from atproto.exceptions import FirehoseError 6 13 7 - from server.database import SubscriptionState 14 + from server import RELEVANT_RECORDS 15 + from server.database import FirehoseSubscriptionState 8 16 from server.logger import logger 9 17 10 - _INTERESTED_RECORDS = { 11 - models.AppBskyFeedLike: models.ids.AppBskyFeedLike, 12 - models.AppBskyFeedPost: models.ids.AppBskyFeedPost, 13 - models.AppBskyGraphFollow: models.ids.AppBskyGraphFollow, 14 - } 15 - 16 18 17 19 def _get_ops_by_type(commit: models.ComAtprotoSyncSubscribeRepos.Commit) -> defaultdict: 18 - operation_by_type = defaultdict(lambda: {'created': [], 'deleted': []}) 20 + """ 21 + Returns a dictionary of operations by type. 22 + 23 + The keys are the record types, and the values are dictionaries with two keys: 24 + - 'created': a list of dictionaries with the following keys: 25 + - 'record': the record 26 + - 'uri': the URI of the record 27 + - 'cid': the CID of the record 28 + - 'author': the author of the record 29 + - 'deleted': a list of dictionaries with the following keys: 30 + - 'uri': the URI of the record 31 + 32 + Args: 33 + commit: The commit object. 34 + 35 + Returns: 36 + A dictionary of operations by type. 37 + """ 38 + 39 + operation_by_type = defaultdict(lambda: {"created": [], "deleted": []}) 19 40 20 41 car = CAR.from_bytes(commit.blocks) 21 42 for op in commit.ops: 22 - if op.action == 'update': 43 + if op.action == "update": 23 44 # we are not interested in updates 24 45 continue 25 46 26 - uri = AtUri.from_str(f'at://{commit.repo}/{op.path}') 47 + at_uri = AtUri.from_str(f"at://{commit.repo}/{op.path}") 27 48 28 - if op.action == 'create': 49 + if op.action == "create": 29 50 if not op.cid: 30 51 continue 31 52 32 - create_info = {'uri': str(uri), 'cid': str(op.cid), 'author': commit.repo} 53 + create_info = { 54 + "uri": str(at_uri), 55 + "cid": str(op.cid), 56 + "author": commit.repo, 57 + } 33 58 34 59 record_raw_data = car.blocks.get(op.cid) 35 60 if not record_raw_data: ··· 39 64 if record is None: # unknown record (out of bsky lexicon) 40 65 continue 41 66 42 - for record_type, record_nsid in _INTERESTED_RECORDS.items(): 43 - if uri.collection == record_nsid and models.is_record_type(record, record_type): 44 - operation_by_type[record_nsid]['created'].append({'record': record, **create_info}) 67 + for record_type, record_nsid in RELEVANT_RECORDS.items(): 68 + if at_uri.collection == record_nsid and models.is_record_type( 69 + record, record_type 70 + ): 71 + operation_by_type[record_nsid]["created"].append( 72 + {"record": record, **create_info} 73 + ) 45 74 break 46 75 47 - if op.action == 'delete': 48 - operation_by_type[uri.collection]['deleted'].append({'uri': str(uri)}) 76 + if op.action == "delete": 77 + operation_by_type[at_uri.collection]["deleted"].append({"uri": str(at_uri)}) 49 78 50 79 return operation_by_type 51 80 ··· 57 86 except FirehoseError as e: 58 87 if logger.level == logging.DEBUG: 59 88 raise e 60 - logger.error(f'Firehose error: {e}. Reconnecting to the firehose.') 89 + logger.error(f"Firehose error: {e}. Reconnecting to the firehose.") 61 90 62 91 63 92 def _run(name, operations_callback, stream_stop_event=None): 64 - state = SubscriptionState.get_or_none(SubscriptionState.service == name) 93 + state = FirehoseSubscriptionState.get_or_none( 94 + FirehoseSubscriptionState.service == name 95 + ) 65 96 66 97 params = None 67 98 if state: ··· 70 101 client = FirehoseSubscribeReposClient(params) 71 102 72 103 if not state: 73 - SubscriptionState.create(service=name, cursor=0) 104 + FirehoseSubscriptionState.create(service=name, cursor=0) 74 105 75 106 def on_message_handler(message: firehose_models.MessageFrame) -> None: 76 107 # stop on next message if requested ··· 84 115 85 116 # update stored state every ~1k events 86 117 if commit.seq % 1000 == 0: # lower value could lead to performance issues 87 - logger.debug(f'Updated cursor for {name} to {commit.seq}') 88 - client.update_params(models.ComAtprotoSyncSubscribeRepos.Params(cursor=commit.seq)) 89 - SubscriptionState.update(cursor=commit.seq).where(SubscriptionState.service == name).execute() 118 + logger.debug(f"Updated cursor for {name} to {commit.seq}") 119 + client.update_params( 120 + models.ComAtprotoSyncSubscribeRepos.Params(cursor=commit.seq) 121 + ) 122 + FirehoseSubscriptionState.update(cursor=commit.seq).where( 123 + FirehoseSubscriptionState.service == name 124 + ).execute() 90 125 91 126 if not commit.blocks: 92 127 return
+30 -3
server/database.py
··· 2 2 3 3 import peewee 4 4 5 - db = peewee.SqliteDatabase('feed_database.db') 5 + db = peewee.SqliteDatabase("feed_database.db") 6 6 7 7 8 8 class BaseModel(peewee.Model): ··· 11 11 12 12 13 13 class Post(BaseModel): 14 + author = peewee.CharField(index=True) 14 15 uri = peewee.CharField(index=True) 15 16 cid = peewee.CharField() 17 + text = peewee.TextField() 16 18 reply_parent = peewee.CharField(null=True, default=None) 17 19 reply_root = peewee.CharField(null=True, default=None) 20 + created_at = peewee.DateTimeField() 18 21 indexed_at = peewee.DateTimeField(default=datetime.now(timezone.utc)) 19 22 20 23 21 - class SubscriptionState(BaseModel): 24 + class Like(BaseModel): 25 + author = peewee.CharField(index=True) 26 + uri = peewee.CharField(index=True) 27 + cid = peewee.CharField() 28 + created_at = peewee.DateTimeField() 29 + indexed_at = peewee.DateTimeField(default=datetime.now(timezone.utc)) 30 + 31 + 32 + class Repost(BaseModel): 33 + author = peewee.CharField(index=True) 34 + uri = peewee.CharField(index=True) 35 + cid = peewee.CharField() 36 + created_at = peewee.DateTimeField() 37 + indexed_at = peewee.DateTimeField(default=datetime.now(timezone.utc)) 38 + 39 + 40 + class Follow(BaseModel): 41 + author = peewee.CharField(index=True) 42 + subject = peewee.CharField(index=True) 43 + uri = peewee.CharField(index=True) 44 + created_at = peewee.DateTimeField() 45 + indexed_at = peewee.DateTimeField(default=datetime.now(timezone.utc)) 46 + 47 + 48 + class FirehoseSubscriptionState(BaseModel): 22 49 service = peewee.CharField(unique=True) 23 50 cursor = peewee.BigIntegerField() 24 51 25 52 26 53 if db.is_closed(): 27 54 db.connect() 28 - db.create_tables([Post, SubscriptionState]) 55 + db.create_tables([Post, Like, Repost, Follow, FirehoseSubscriptionState])
+2
server/utils.py
··· 1 + def get_author_from_uri(uri: str) -> str: 2 + return uri.split("/")[2]
+101 -3
uv.lock
··· 13 13 { name = "python-dotenv" }, 14 14 ] 15 15 16 + [package.dev-dependencies] 17 + dev = [ 18 + { name = "pytest" }, 19 + { name = "ruff" }, 20 + ] 21 + lint = [ 22 + { name = "ruff" }, 23 + ] 24 + test = [ 25 + { name = "pytest" }, 26 + ] 27 + 16 28 [package.metadata] 17 29 requires-dist = [ 18 30 { name = "atproto", specifier = "==0.0.59" }, 19 31 { name = "flask", specifier = "~=2.3.2" }, 20 - { name = "peewee", specifier = "~=3.16.2" }, 32 + { name = "peewee", specifier = "~=3.18.2" }, 21 33 { name = "python-dotenv", specifier = "~=1.0.0" }, 22 34 ] 35 + 36 + [package.metadata.requires-dev] 37 + dev = [ 38 + { name = "pytest" }, 39 + { name = "ruff" }, 40 + ] 41 + lint = [{ name = "ruff" }] 42 + test = [{ name = "pytest" }] 23 43 24 44 [[package]] 25 45 name = "annotated-types" ··· 242 262 ] 243 263 244 264 [[package]] 265 + name = "iniconfig" 266 + version = "2.3.0" 267 + source = { registry = "https://pypi.org/simple" } 268 + sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } 269 + wheels = [ 270 + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, 271 + ] 272 + 273 + [[package]] 245 274 name = "itsdangerous" 246 275 version = "2.2.0" 247 276 source = { registry = "https://pypi.org/simple" } ··· 335 364 ] 336 365 337 366 [[package]] 367 + name = "packaging" 368 + version = "25.0" 369 + source = { registry = "https://pypi.org/simple" } 370 + sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 371 + wheels = [ 372 + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 373 + ] 374 + 375 + [[package]] 338 376 name = "peewee" 339 - version = "3.16.3" 377 + version = "3.18.2" 340 378 source = { registry = "https://pypi.org/simple" } 341 - sdist = { url = "https://files.pythonhosted.org/packages/e2/1e/6455dc3c759af3e565414985c5c6f845d3e5f83bbf4a24cdd0aef9cc3f83/peewee-3.16.3.tar.gz", hash = "sha256:12b30e931193bc37b11f7c2ac646e3f67125a8b1a543ad6ab37ad124c8df7d16", size = 928003, upload-time = "2023-08-14T14:15:20.65Z" } 379 + sdist = { url = "https://files.pythonhosted.org/packages/04/89/76f6f1b744c8608e0d416b588b9d63c2a500ff800065ae610f7c80f532d6/peewee-3.18.2.tar.gz", hash = "sha256:77a54263eb61aff2ea72f63d2eeb91b140c25c1884148e28e4c0f7c4f64996a0", size = 949220, upload-time = "2025-07-08T12:52:03.941Z" } 380 + 381 + [[package]] 382 + name = "pluggy" 383 + version = "1.6.0" 384 + source = { registry = "https://pypi.org/simple" } 385 + sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 386 + wheels = [ 387 + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 388 + ] 342 389 343 390 [[package]] 344 391 name = "pycparser" ··· 407 454 ] 408 455 409 456 [[package]] 457 + name = "pygments" 458 + version = "2.19.2" 459 + source = { registry = "https://pypi.org/simple" } 460 + sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } 461 + wheels = [ 462 + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, 463 + ] 464 + 465 + [[package]] 466 + name = "pytest" 467 + version = "8.4.2" 468 + source = { registry = "https://pypi.org/simple" } 469 + dependencies = [ 470 + { name = "colorama", marker = "sys_platform == 'win32'" }, 471 + { name = "iniconfig" }, 472 + { name = "packaging" }, 473 + { name = "pluggy" }, 474 + { name = "pygments" }, 475 + ] 476 + sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } 477 + wheels = [ 478 + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, 479 + ] 480 + 481 + [[package]] 410 482 name = "python-dotenv" 411 483 version = "1.0.1" 412 484 source = { registry = "https://pypi.org/simple" } 413 485 sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } 414 486 wheels = [ 415 487 { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, 488 + ] 489 + 490 + [[package]] 491 + name = "ruff" 492 + version = "0.14.2" 493 + source = { registry = "https://pypi.org/simple" } 494 + sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } 495 + wheels = [ 496 + { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, 497 + { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, 498 + { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, 499 + { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, 500 + { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, 501 + { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, 502 + { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, 503 + { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, 504 + { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, 505 + { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, 506 + { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, 507 + { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, 508 + { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, 509 + { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, 510 + { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, 511 + { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, 512 + { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, 513 + { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, 416 514 ] 417 515 418 516 [[package]]