+1
.python-version
+1
.python-version
···
1
+
3.12
-8
.vscode/settings.json
-8
.vscode/settings.json
+33
-28
publish_feed.py
+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
+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
+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
+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
+3
-2
server/algos/__init__.py
-38
server/algos/feed.py
-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
+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
+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
+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
+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
+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
+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
+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
+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])
+101
-3
uv.lock
+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]]