From ad62d8ffefae4df9600ad8fb25b6cc4b18782d77 Mon Sep 17 00:00:00 2001 From: zenfyr Date: Sat, 21 Feb 2026 17:17:20 +0700 Subject: [PATCH] refactor: use a result class to wrap and pass errors --- atproto/models.py | 14 +++++---- bluesky/input.py | 46 ++++++++++++++++----------- bluesky/output.py | 38 +++++++++++++++-------- cross/media.py | 18 ++++++----- mastodon/input.py | 12 +++----- mastodon/output.py | 77 +++++++++++++++++++++++----------------------- misskey/input.py | 10 +++--- util/splitter.py | 7 +++-- util/util.py | 30 +++++++++++++++++- 9 files changed, 153 insertions(+), 99 deletions(-) diff --git a/atproto/models.py b/atproto/models.py index aba16cb..3829002 100644 --- a/atproto/models.py +++ b/atproto/models.py @@ -3,19 +3,21 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any +from util.util import Result + URI = "at://" URI_LEN = len(URI) -def cid_from_json(data: str | None) -> str | None: - if not data: - return None +def cid_from_json(data: str | None) -> Result[str, str]: + if data is None: + return Result.err("Expected json, got None") try: - return str(json.loads(data)["cid"]) - except (json.JSONDecodeError, AttributeError, KeyError): - return None + return Result.ok(str(json.loads(data)["cid"])) + except (json.JSONDecodeError, AttributeError, KeyError) as e: + return Result.err(str(e)) class AtUri: diff --git a/bluesky/input.py b/bluesky/input.py index e8c743d..bc2d034 100644 --- a/bluesky/input.py +++ b/bluesky/input.py @@ -14,6 +14,7 @@ from atproto.store import get_store from bluesky.info import SERVICE, BlueskyService, validate_and_transform from bluesky.richtext import richtext_to_tokens from cross.attachments import ( + Attachment, LabelsAttachment, LanguagesAttachment, MediaAttachment, @@ -24,6 +25,7 @@ from cross.media import Blob, download_blob from cross.post import Post, PostRef from cross.service import InputService from database.connection import DatabasePool +from util.util import Result @dataclass(kw_only=True) @@ -88,10 +90,13 @@ class BlueskyBaseInputService(BlueskyService, InputService, ABC): ) embed: dict[str, Any] = record.get("embed", {}) - blob_urls: list[tuple[str, str, str | None]] = [] - def handle_embeds(embed: dict[str, Any]) -> str | None: - nonlocal blob_urls, post + def handle_embeds( + embed: dict[str, Any], + ) -> Result[tuple[list[tuple[str, str, str | None]], list[Attachment]], str]: + attachments: list[Attachment] = [] + blob_urls: list[tuple[str, str, str | None]] = [] + match cast(str, embed["$type"]): case "app.bsky.embed.record" | "app.bsky.embed.recordWithMedia": rcrd = ( @@ -101,17 +106,17 @@ class BlueskyBaseInputService(BlueskyService, InputService, ABC): ) did, collection, _ = AtUri.record_uri(rcrd["uri"]) if collection != "app.bsky.feed.post": - return f"unhandled record collection '{collection}'" + return Result.err(f"unhandled record collection '{collection}'") if did != self.did: - return "" + return Result.err(f"quote of other user '{did}'") rquote = self._get_post(self.url, did, rcrd["uri"]) if not rquote: - return f"quote '{rcrd['uri']}' not found in db" - post.attachments.put( + return Result.err(f"quote '{rcrd['uri']}' not found in db") + + attachments.append( QuoteAttachment(quoted_id=rcrd["uri"], quoted_user=did) ) - if embed.get("media"): return handle_embeds(embed["media"]) case "app.bsky.embed.images": @@ -125,25 +130,30 @@ class BlueskyBaseInputService(BlueskyService, InputService, ABC): blob_urls.append((url, blob_cid, embed.get("alt"))) case _: self.log.warning(f"unhandled embed type '{embed['$type']}'") - return None + return Result.ok((blob_urls, attachments)) - if embed: - fexit = handle_embeds(embed) - if fexit is not None: - self.log.info("Skipping '%s': %s", post_uri, fexit) - return + embeds = handle_embeds(embed) + if not embeds.is_ok(): + self.log.info("Skipping '%s': %s", post_uri, embeds.error()) + return + + blob_urls, attachments = embeds.value() + for a in attachments: + post.attachments.put(a) if blob_urls: blobs: list[Blob] = [] for url, cid, alt in blob_urls: self.log.info("Downloading '%s'...", cid) - blob: Blob | None = download_blob(url, alt, client=self.http) - if not blob: + blob = download_blob(url, alt, client=self.http) + if not blob.is_ok(): self.log.error( - "Skipping '%s': failed to download blob '%s'", post_uri, cid + "Skipping '%s': failed to download blob. %s", + post_uri, + blob.error(), ) return - blobs.append(blob) + blobs.append(blob.value()) post.attachments.put(MediaAttachment(blobs=blobs)) if "langs" in record: diff --git a/bluesky/output.py b/bluesky/output.py index ff9257b..c86bf27 100644 --- a/bluesky/output.py +++ b/bluesky/output.py @@ -222,12 +222,19 @@ class BlueskyOutputService(BlueskyService, OutputService): root_cid = cid_from_json(root_post["extra_data"]) reply_cid = cid_from_json(reply_post["extra_data"]) - if not root_cid or not reply_cid: - self.log.error("Skipping '%s': failed to parse CID from db", post.id) + if not root_cid.is_ok(): + self.log.error( + "Skipping '%s': failed to parse CID. %s", post.id, root_cid.error() + ) + return + if not reply_cid.is_ok(): + self.log.error( + "Skipping '%s': failed to parse CID. %s", post.id, reply_cid.error() + ) return - root_ref = StrongRef(uri=root_uri, cid=root_cid) - reply_ref = StrongRef(uri=reply_uri, cid=reply_cid) + root_ref = StrongRef(uri=root_uri, cid=root_cid.value()) + reply_ref = StrongRef(uri=reply_uri, cid=reply_cid.value()) reply_to = ReplyRef(root=root_ref, parent=reply_ref) labels_attachment = post.attachments.get(LabelsAttachment) @@ -314,18 +321,23 @@ class BlueskyOutputService(BlueskyService, OutputService): ) return - quoted_cid = cid_from_json(quoted_mappings[0]["extra_data"]) - if not quoted_cid: - self.log.error("Skipping '%s': failed to parse CID from db", post.id) + quoted_result = cid_from_json(quoted_mappings[0]["extra_data"]) + if not quoted_result.is_ok(): + self.log.error( + "Skipping '%s': failed to parse CID. %s", + post.id, + quoted_result.error(), + ) return + quoted_cid = quoted_result.value() quoted_uri = quoted_mappings[0]["identifier"] splitter = TokenSplitter(max_chars=300, max_link_len=30) token_blocks = splitter.split(tokens) - if token_blocks is None: - self.log.error("Skipping '%s': links/tags are too long", post.id) + if not token_blocks.is_ok(): + self.log.error("Skipping '%s': %s", post.id, token_blocks.error()) return for blob in supported_media: @@ -350,12 +362,12 @@ class BlueskyOutputService(BlueskyService, OutputService): return baked_media = self._split_media_per_post( - [list(block) for block in token_blocks], + [list(block) for block in token_blocks.value()], supported_media, ) precomputed_richtexts: list[tuple[str, list[Facet]]] = [] - for block in token_blocks: + for block in token_blocks.value(): result = tokens_to_richtext(block) if result is None: self.log.error( @@ -555,13 +567,13 @@ class BlueskyOutputService(BlueskyService, OutputService): return cid = cid_from_json(mappings[0]["extra_data"]) - if not cid: + if not cid.is_ok(): self.log.exception( "Skipping repost '%s': failed to parse CID from extra_data", repost.id ) return - response = self._client.repost(mappings[0]["identifier"], cid) + response = self._client.repost(mappings[0]["identifier"], cid.value()) self._insert_post( { diff --git a/cross/media.py b/cross/media.py index c86d65d..928e2ec 100644 --- a/cross/media.py +++ b/cross/media.py @@ -9,6 +9,8 @@ from typing import Any, cast import httpx import magic +from util.util import Result + FILENAME = re.compile(r'filename="?([^\";]*)"?') MAGIC = magic.Magic(mime=True) @@ -42,22 +44,22 @@ def download_blob( alt: str | None = None, max_bytes: int = 100_000_000, client: httpx.Client | None = None, -) -> Blob | None: +) -> Result[Blob, str]: name = get_filename_from_url(url, client) io = download_chuncked(url, max_bytes, client) - if not io: - return None - return Blob(url, mime_from_bytes(io), io, name, alt) + if not io.is_ok(): + return Result.err(io.error()) + return Result.ok(Blob(url, mime_from_bytes(io.value()), io.value(), name, alt)) def download_chuncked( url: str, max_bytes: int = 100_000_000, client: httpx.Client | None = None -) -> bytes | None: +) -> Result[bytes, str]: if client is None: client = httpx.Client() with client.stream("GET", url, timeout=20) as response: if response.status_code != 200: - return None + return Result.err(f"HTTP {response.status_code}: {response.text}") downloaded_bytes = b"" current_size = 0 @@ -68,11 +70,11 @@ def download_chuncked( current_size += len(chunk) if current_size > max_bytes: - return None + return Result.err(f"'{url}' larger than max_bytes ({max_bytes})") downloaded_bytes += chunk - return downloaded_bytes + return Result.ok(downloaded_bytes) def get_filename_from_url(url: str, client: httpx.Client | None = None) -> str: diff --git a/mastodon/input.py b/mastodon/input.py index 88d6d8c..8be3490 100644 --- a/mastodon/input.py +++ b/mastodon/input.py @@ -155,17 +155,15 @@ class MastodonInputService(MastodonService, InputService): blobs: list[Blob] = [] for media in status.get("media_attachments", []): self.log.info("Downloading '%s'...", media["url"]) - blob: Blob | None = download_blob( - media["url"], media.get("alt"), client=self.http - ) - if not blob: + blob = download_blob(media["url"], media.get("alt"), client=self.http) + if not blob.is_ok(): self.log.error( - "Skipping '%s': failed to download attachment '%s'", + "Skipping '%s': failed to download attachment. %s", status["id"], - media["url"], + blob.value(), ) return - blobs.append(blob) + blobs.append(blob.value()) if blobs: post.attachments.put(MediaAttachment(blobs=blobs)) diff --git a/mastodon/output.py b/mastodon/output.py index 166c11f..c08c232 100644 --- a/mastodon/output.py +++ b/mastodon/output.py @@ -19,6 +19,7 @@ from cross.tokens import LinkToken, TagToken, TextToken, Token from database.connection import DatabasePool from mastodon.info import InstanceInfo, MastodonService, validate_and_transform from util.splitter import TokenSplitter, canonical_label +from util.util import Result ALLOWED_POSTING_VISIBILITY: list[str] = ["public", "unlisted", "private"] @@ -65,7 +66,7 @@ class MastodonOutputService(MastodonService, OutputService): response = self.fetch_instance_info() self.instance_info: InstanceInfo = InstanceInfo.from_api(response) - def _token_to_string(self, tokens: list[Token]) -> str | None: + def _token_to_string(self, tokens: list[Token]) -> Result[str, str]: text: str = "" for token in tokens: match token: @@ -91,29 +92,36 @@ class MastodonOutputService(MastodonService, OutputService): else: text += token.href else: - return None - return text + return Result.err( + f"unsupported instance text format '{self.instance_info.text_format}'" + ) + case _: + return Result.err( + f"unsupported token type '{type(token).__name__}'" + ) + + return Result.ok(text) def _split_tokens_and_media( self, tokens: list[Token], media: list[Blob], - ) -> list[tuple[str, list[Blob]]] | None: + ) -> Result[list[tuple[str, list[Blob]]], str]: splitter = TokenSplitter( max_chars=self.instance_info.max_characters, max_link_len=self.instance_info.characters_reserved_per_url, ) split_token_blocks = splitter.split(tokens) - if split_token_blocks is None: - return None + if not split_token_blocks.is_ok(): + return Result.err(split_token_blocks.error()) post_texts: list[str] = [] - for block in split_token_blocks: + for block in split_token_blocks.value(): baked_text = self._token_to_string(block) - if baked_text is None: - return None - post_texts.append(baked_text) + if not baked_text.is_ok(): + return Result.err(baked_text.error()) + post_texts.append(baked_text.value()) if not post_texts: post_texts = [""] @@ -123,7 +131,6 @@ class MastodonOutputService(MastodonService, OutputService): ] available_indices: list[int] = list(range(len(posts))) current_image_post_idx: int | None = None - # video_post_idx: int | None = None def make_blank_post() -> dict[str, Any]: return {"text": "", "attachments": []} @@ -155,36 +162,31 @@ class MastodonOutputService(MastodonService, OutputService): result: list[tuple[str, list[Blob]]] = [] for p in posts: result.append((p["text"], p["attachments"])) - return result + return Result.ok(result) - def _upload_media(self, attachments: list[Blob]) -> list[str] | None: + def _upload_media(self, attachments: list[Blob]) -> Result[list[str], str]: for blob in attachments: if ( blob.mime.startswith("image/") and len(blob.io) > self.instance_info.image_size_limit ): - self.log.error( - "Image too large: %s bytes (limit: %s)", - len(blob.io), - self.instance_info.image_size_limit, + return Result.err( + f"image too large: {len(blob.io)} bytes (limit: {self.instance_info.image_size_limit})" ) - return None if ( blob.mime.startswith("video/") and len(blob.io) > self.instance_info.video_size_limit ): - self.log.error( - "Video too large: %s bytes (limit: %s)", - len(blob.io), - self.instance_info.video_size_limit, + return Result.err( + f"video too large: {len(blob.io)} bytes (limit: {self.instance_info.video_size_limit})" ) - return None if ( not blob.mime.startswith(("image/", "video/")) and len(blob.io) > 7_000_000 ): - self.log.error("File too large: %s bytes", len(blob.io)) - return None + return Result.err( + f"file too large: {len(blob.io)} bytes (limit: 7000000)" + ) uploads: list[MediaUploadResult] = [] @@ -244,7 +246,7 @@ class MastodonOutputService(MastodonService, OutputService): continue response.raise_for_status() - return [result.id for result in uploads] + return Result.ok([result.id for result in uploads]) @override def accept_post(self, post: Post): @@ -321,21 +323,20 @@ class MastodonOutputService(MastodonService, OutputService): media_blobs = media_attachment.blobs if media_attachment else [] raw_statuses = self._split_tokens_and_media(post_tokens, media_blobs) - if not raw_statuses: - self.log.error("Skipping '%s': couldn't split post into statuses", post.id) + if not raw_statuses.is_ok(): + self.log.error("Skipping '%s': %s", post.id, raw_statuses.error()) return - baked_statuses: list[tuple[str, list[str] | None]] = [] - for status_text, raw_media in raw_statuses: - media_ids: list[str] | None = None + baked_statuses: list[tuple[str, list[str]]] = [] + for status_text, raw_media in raw_statuses.value(): if raw_media: - media_ids = self._upload_media(raw_media) - if not media_ids: - self.log.error( - "Skipping '%s': failed to upload attachments", post.id - ) + baked_media = self._upload_media(raw_media) + if not baked_media.is_ok(): + self.log.error("Skipping '%s': %s", post.id, baked_media.error()) return - baked_statuses.append((status_text, media_ids)) + baked_statuses.append((status_text, baked_media.value())) + else: + baked_statuses.append((status_text, [])) created_statuses: list[str] = [] payload_sensitive = sensitive.sensitive if sensitive else False @@ -343,7 +344,7 @@ class MastodonOutputService(MastodonService, OutputService): for i, (status_text, media_ids) in enumerate(baked_statuses): payload: dict[str, Any] = { "status": status_text or "", - "media_ids": media_ids or [], + "media_ids": media_ids, "visibility": self.options.visibility, "content_type": self.instance_info.text_format, "language": lang, diff --git a/misskey/input.py b/misskey/input.py index 297c9cb..9416fde 100644 --- a/misskey/input.py +++ b/misskey/input.py @@ -154,17 +154,17 @@ class MisskeyInputService(MisskeyService, InputService): blobs: list[Blob] = [] for media in note.get("files", []): self.log.info("Downloading '%s'...", media["url"]) - blob: Blob | None = download_blob( + blob = download_blob( media["url"], media.get("comment", ""), client=self.http ) - if not blob: + if not blob.is_ok(): self.log.error( - "Skipping '%s': failed to download media '%s'.", + "Skipping '%s': failed to download media. %s", note["id"], - media["url"], + blob.error(), ) return - blobs.append(blob) + blobs.append(blob.value()) if blobs: post.attachments.put(MediaAttachment(blobs=blobs)) diff --git a/util/splitter.py b/util/splitter.py index e2c2c6b..14e00d0 100644 --- a/util/splitter.py +++ b/util/splitter.py @@ -4,6 +4,7 @@ from functools import lru_cache import grapheme from cross.tokens import LinkToken, TagToken, TextToken, Token +from util.util import Result def canonical_label(label: str | None, href: str): @@ -178,10 +179,10 @@ class TokenSplitter: self.best_split_idx = (len(self.current_block) - 1, self.current_length, 0) return True - def split(self, tokens: list[Token]) -> list[list[Token]] | None: + def split(self, tokens: list[Token]) -> Result[list[list[Token]], str]: for token in tokens: if not self._process_token(token): - return None + return Result.err("token larger than character limit") self._save_block() - return self.blocks + return Result.ok(self.blocks) diff --git a/util/util.py b/util/util.py index 099f334..cafb69b 100644 --- a/util/util.py +++ b/util/util.py @@ -2,7 +2,7 @@ import logging import os import sys from collections.abc import Callable -from typing import Any +from typing import Any, cast import env @@ -15,6 +15,34 @@ logging.getLogger("httpcore").setLevel(logging.WARNING) LOGGER = logging.getLogger("XPost") +class Result[V, E]: + _value: V + _error: E | None + + def __init__(self, value: V, err: E) -> None: + self._value = value + self._error = err + + def error(self) -> E: + if self._error is None: + raise ValueError("self._error not set!") + return self._error + + def value(self) -> V: + return self._value + + def is_ok(self) -> bool: + return self._error is None + + @classmethod + def err(cls, err: E) -> "Result[V, E]": + return cast("Result[V, E]", Result(None, err)) + + @classmethod + def ok(cls, val: V) -> "Result[V, E]": + return cast("Result[V, E]", Result(val, None)) + + def normalize_service_url(url: str) -> str: if not url.startswith("https://") and not url.startswith("http://"): raise ValueError(f"Invalid service url {url}! Must start with http/https!") -- 2.51.2