diff --git a/examples/send_images.py b/examples/send_images.py new file mode 100644 index 00000000..57aa18d5 --- /dev/null +++ b/examples/send_images.py @@ -0,0 +1,21 @@ +from atproto import Client + + +def main() -> None: + client = Client() + client.login('my-handle', 'my-password') + + # replace the path to your image file + paths = ['cat.jpg', 'dog.jpg', 'bird.jpg'] + image_alts = ['Text version', 'of the image (ALT)', 'This parameter is optional'] + + images = [] + for path in paths: + with open(path, 'rb') as f: + images.append(f.read()) + + client.send_images(text='Post with image from Python', images=images, image_alts=image_alts) + + +if __name__ == '__main__': + main() diff --git a/packages/atproto_client/client/async_client.py b/packages/atproto_client/client/async_client.py index 8c2476b2..0dfa0aa8 100644 --- a/packages/atproto_client/client/async_client.py +++ b/packages/atproto_client/client/async_client.py @@ -4,6 +4,7 @@ # This file is part of Python atproto SDK. Licenced under MIT. ################################################################## +import asyncio import typing as t from asyncio import Lock @@ -181,6 +182,58 @@ async def delete_post(self, post_uri: str) -> bool: uri = AtUri.from_str(post_uri) return await self.app.bsky.feed.post.delete(uri.hostname, uri.rkey) + async def send_images( + self, + text: t.Union[str, TextBuilder], + images: t.List[bytes], + image_alts: t.Optional[t.List[str]] = None, + profile_identify: t.Optional[str] = None, + reply_to: t.Optional['models.AppBskyFeedPost.ReplyRef'] = None, + langs: t.Optional[t.List[str]] = None, + facets: t.Optional[t.List['models.AppBskyRichtextFacet.Main']] = None, + ) -> 'models.AppBskyFeedPost.CreateRecordResponse': + """Send post with multiple attached images (up to 4 images). + + Note: + If `profile_identify` is not provided will be sent to the current profile. + + Args: + text: Text of the post. + images: List of binary images to attach. The length must be less than or equal to 4. + image_alts: List of text version of the images. + The length should be shorter than or equal to the length of `images`. + profile_identify: Handle or DID. Where to send post. + reply_to: Root and parent of the post to reply to. + langs: List of used languages in the post. + facets: List of facets (rich text items). + + Returns: + :obj:`models.AppBskyFeedPost.CreateRecordResponse`: Reference to the created record. + + Raises: + :class:`atproto.exceptions.AtProtocolError`: Base exception. + """ + if image_alts is None: + image_alts = [''] * len(images) + else: + # padding with empty string if len is insufficient + diff = len(images) - len(image_alts) + image_alts = image_alts + [''] * diff # [''] * (minus) => [] + + uploads = await asyncio.gather(*[self.upload_blob(image) for image in images]) + embed_images = [ + models.AppBskyEmbedImages.Image(alt=alt, image=upload.blob) for alt, upload in zip(image_alts, uploads) + ] + + return await self.send_post( + text, + profile_identify=profile_identify, + reply_to=reply_to, + embed=models.AppBskyEmbedImages.Main(images=embed_images), + langs=langs, + facets=facets, + ) + async def send_image( self, text: t.Union[str, TextBuilder], @@ -199,7 +252,7 @@ async def send_image( Args: text: Text of the post. image: Binary image to attach. - image_alt: Text version of the image + image_alt: Text version of the image. profile_identify: Handle or DID. Where to send post. reply_to: Root and parent of the post to reply to. langs: List of used languages in the post. @@ -211,13 +264,12 @@ async def send_image( Raises: :class:`atproto.exceptions.AtProtocolError`: Base exception. """ - upload = await self.com.atproto.repo.upload_blob(image) - images = [models.AppBskyEmbedImages.Image(alt=image_alt, image=upload.blob)] - return await self.send_post( + return await self.send_images( text, + images=[image], + image_alts=[image_alt], profile_identify=profile_identify, reply_to=reply_to, - embed=models.AppBskyEmbedImages.Main(images=images), langs=langs, facets=facets, ) diff --git a/packages/atproto_client/client/client.py b/packages/atproto_client/client/client.py index f71536b5..d29c2828 100644 --- a/packages/atproto_client/client/client.py +++ b/packages/atproto_client/client/client.py @@ -173,6 +173,58 @@ def delete_post(self, post_uri: str) -> bool: uri = AtUri.from_str(post_uri) return self.app.bsky.feed.post.delete(uri.hostname, uri.rkey) + def send_images( + self, + text: t.Union[str, TextBuilder], + images: t.List[bytes], + image_alts: t.Optional[t.List[str]] = None, + profile_identify: t.Optional[str] = None, + reply_to: t.Optional['models.AppBskyFeedPost.ReplyRef'] = None, + langs: t.Optional[t.List[str]] = None, + facets: t.Optional[t.List['models.AppBskyRichtextFacet.Main']] = None, + ) -> 'models.AppBskyFeedPost.CreateRecordResponse': + """Send post with multiple attached images (up to 4 images). + + Note: + If `profile_identify` is not provided will be sent to the current profile. + + Args: + text: Text of the post. + images: List of binary images to attach. The length must be less than or equal to 4. + image_alts: List of text version of the images. + The length should be shorter than or equal to the length of `images`. + profile_identify: Handle or DID. Where to send post. + reply_to: Root and parent of the post to reply to. + langs: List of used languages in the post. + facets: List of facets (rich text items). + + Returns: + :obj:`models.AppBskyFeedPost.CreateRecordResponse`: Reference to the created record. + + Raises: + :class:`atproto.exceptions.AtProtocolError`: Base exception. + """ + if image_alts is None: + image_alts = [''] * len(images) + else: + # padding with empty string if len is insufficient + diff = len(images) - len(image_alts) + image_alts = image_alts + [''] * diff # [''] * (minus) => [] + + uploads = [self.upload_blob(image) for image in images] + embed_images = [ + models.AppBskyEmbedImages.Image(alt=alt, image=upload.blob) for alt, upload in zip(image_alts, uploads) + ] + + return self.send_post( + text, + profile_identify=profile_identify, + reply_to=reply_to, + embed=models.AppBskyEmbedImages.Main(images=embed_images), + langs=langs, + facets=facets, + ) + def send_image( self, text: t.Union[str, TextBuilder], @@ -191,7 +243,7 @@ def send_image( Args: text: Text of the post. image: Binary image to attach. - image_alt: Text version of the image + image_alt: Text version of the image. profile_identify: Handle or DID. Where to send post. reply_to: Root and parent of the post to reply to. langs: List of used languages in the post. @@ -203,13 +255,12 @@ def send_image( Raises: :class:`atproto.exceptions.AtProtocolError`: Base exception. """ - upload = self.com.atproto.repo.upload_blob(image) - images = [models.AppBskyEmbedImages.Image(alt=image_alt, image=upload.blob)] - return self.send_post( + return self.send_images( text, + images=[image], + image_alts=[image_alt], profile_identify=profile_identify, reply_to=reply_to, - embed=models.AppBskyEmbedImages.Main(images=images), langs=langs, facets=facets, ) diff --git a/packages/atproto_codegen/clients/generate_async_client.py b/packages/atproto_codegen/clients/generate_async_client.py index 9b135962..5a9fcb9b 100644 --- a/packages/atproto_codegen/clients/generate_async_client.py +++ b/packages/atproto_codegen/clients/generate_async_client.py @@ -1,3 +1,4 @@ +import re from pathlib import Path from atproto_codegen.consts import DISCLAIMER @@ -14,6 +15,7 @@ def gen_client(input_filename: str, output_filename: str) -> None: methods = [ 'send_post', 'send_image', + 'send_images', '_set_session', '_get_and_set_session', '_refresh_and_set_session', @@ -22,7 +24,7 @@ def gen_client(input_filename: str, output_filename: str) -> None: '_invoke', ] - code = code.replace('from threading', 'from asyncio') + code = code.replace('from threading', 'import asyncio\nfrom asyncio') code = code.replace('client.raw', 'client.async_raw') code = code.replace('class Client', 'class AsyncClient') code = code.replace('ClientRaw', 'AsyncClientRaw') @@ -40,6 +42,8 @@ def gen_client(input_filename: str, output_filename: str) -> None: code = code.replace(f'self.{method}(', f'await self.{method}(') code = code.replace(f'super().{method}(', f'await super().{method}(') + code = re.sub(r'(\[self\.upload_blob.*\])', r'await asyncio.gather(*\1)', code) + code = DISCLAIMER + code write_code(_CLIENT_DIR.joinpath(output_filename), code)