From d41aad6f53b4d24a42aa8d18219604189296a0f0 Mon Sep 17 00:00:00 2001 From: "D. Scott Boggs" Date: Mon, 26 May 2025 06:40:35 -0400 Subject: [PATCH] Add CLI --- blastodon/__main__.py | 8 +--- blastodon/auth/cli.py | 4 +- blastodon/interface.py | 98 ++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 4 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 blastodon/interface.py diff --git a/blastodon/__main__.py b/blastodon/__main__.py index 072c822..366c382 100644 --- a/blastodon/__main__.py +++ b/blastodon/__main__.py @@ -1,7 +1,3 @@ -from blastodon.client import Client +from blastodon.interface import interface -client = Client.init_cli() - -post_text = input(' enter a status to post: ') - -client.send_text_post(post_text) \ No newline at end of file +interface() \ No newline at end of file diff --git a/blastodon/auth/cli.py b/blastodon/auth/cli.py index a0b3705..bb45c36 100644 --- a/blastodon/auth/cli.py +++ b/blastodon/auth/cli.py @@ -59,7 +59,7 @@ def auth_bsky() -> BskyClient: def _mastodon_find_single_user_login() -> Path | None: found_file = None for file in Path.cwd().iterdir(): - if file.name.startswith('login.') and file.name.endswith('.secret'): + if file.name.startswith('mastodon.') and file.name.endswith('.secret'): print('found secret file', file.name) if found_file is None: found_file = file @@ -72,7 +72,7 @@ def _mastodon_find_single_user_login() -> Path | None: def _bsky_find_single_user_login() -> Path | None: found_file = None for file in Path.cwd().iterdir(): - if file.name.startswith('bsky-') and file.name.endswith('-jwt.secret'): + if file.name.startswith('bsky_') and file.name.endswith('_session.secret'): if found_file is None: found_file = file else: diff --git a/blastodon/interface.py b/blastodon/interface.py new file mode 100644 index 0000000..61ee6e6 --- /dev/null +++ b/blastodon/interface.py @@ -0,0 +1,98 @@ +from functools import cached_property +from os import getenv, SEEK_SET +from pathlib import Path +from shutil import which +from subprocess import run +from tempfile import NamedTemporaryFile +from traceback import print_stack + +from click import command, option, group, argument, pass_context, confirm + +from blastodon.client import Client + +class Context: + """A context object which may be used accross commands""" + @cached_property + def client(self) -> Client: + return Client.init_cli() + +@group +def interface(): + ... + +@interface.group +def post(): + ... + +@post.command('status') +@argument('content', required=False) +@option('--content-file', '-f', help="post the contents of a file") +@pass_context +def text_status(ctx, content: str | None = None, content_file: str | None = None): + ctx.ensure_object(Context) + if content_file and content: + raise SyntaxError('cannot specify content and content source file') + if content_file: + with open(content_file) as file: + content = file.read() + print('status:') + print(content) + if confirm('\n\n Post this status?', show_default=True, default=True): + result = ctx.obj.client.send_text_post(content) + print('status posted ok') + print('mastodon:', result.mastodon.url) + print('bsky: ', result.bsky.uri) + +@post.command(help='open the default editor to compose a status') +@pass_context +def compose(ctx): + ctx.ensure_object(Context) + status_text = _compose_message() + if not status_text: + print('not posting empty status') + return + print('status:') + print(status_text) + if confirm('\n\tPost this status?', show_default=True, default=True): + result = ctx.obj.client.send_text_post(status_text) + print('status posted ok') + print('mastodon:', result.mastodon.url) + print('bsky: ', result.bsky.uri) + else: + print('Ok, not posting.') + +def _compose_message() -> str: + editor = getenv('VISUAL') or getenv('EDITOR') or which('micro') or which('nano') or 'vi' + with NamedTemporaryFile(prefix='blastodon_', suffix='.post') as tempfile: + tempfile.write(b'# compose a status, then save and close the editor to post it.\n') + tempfile.write(b'# empty lines and lines starting with "#" will be ignored\n') + tempfile.flush() + result = run(executable=editor, args=[editor, tempfile.name]) + result.check_returncode() + tempfile.seek(0, SEEK_SET) + return '\n'.join( + line + for l + in (bs.decode() for bs in tempfile.readlines()) + if (line := l.strip()) and not line.startswith('#') + ) + + +@post.command('image') +@option('--compose-message', help='Open an editor to compose a text status which this image will be attached to', is_flag=True, default=False) +@option('--message', help='A text status this image will be attached to') +@option('--alt-text', help='Describe the image', prompt=True) +@option('--mime-type', help='The mime type of the attached file. If not specified, determined from the file contents') +@argument('filepath', callback=lambda _ctx, _param, fp: Path(fp)) +@pass_context +def post_image(ctx, compose_message: bool, message: str, alt_text: str, mime_type: str, filepath: Path): + ctx.ensure_object(Context) + if compose_message and message: + raise SyntaxError("Can't specify both --message and --compose-message") + if compose_message: + message = _compose_message() + + result = ctx.obj.client.send_image_post(post_text=message, image_path=filepath, alt_text=alt_text) + print('status posted ok') + print('mastodon:', result.mastodon.url) + print('bsky: ', result.bsky.uri) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6601a2d..b577bd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "atproto", "mastodon.py", "python-magic", + "click", ] dynamic = ["version"]