From 6e570aa102fd54dd73a0d2ce43a88ff6e75a8e99 Mon Sep 17 00:00:00 2001 From: Ricardo Filipe dos Santos <56833915+rf-santos@users.noreply.github.com> Date: Mon, 25 Mar 2024 23:52:08 +0000 Subject: [PATCH 1/2] Including a CLI wrapper for the SDK to facilitate usage in CI/CD workflows --- medium/cli.py | 197 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + setup.py | 5 +- 3 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 medium/cli.py diff --git a/medium/cli.py b/medium/cli.py new file mode 100644 index 0000000..329cb17 --- /dev/null +++ b/medium/cli.py @@ -0,0 +1,197 @@ +from pathlib import Path + +import keyring +import typer + +from medium import Client, MediumError + +client = Client() + +app = typer.Typer( + name="medium", + help="A CLI app for the Medium API using the Medium Python SDK", + add_completion=False, + invoke_without_command=True, + no_args_is_help=True +) + +config_app = typer.Typer(name="config", + help="Configuration of Medium CLI", + add_completion=False, + add_help_option=True, + no_args_is_help=True) + +app.add_typer(config_app, name="config", help="Configuration of Medium CLI") + + +@config_app.command() +def set_token(token: str) -> None: + """ + Sets the access token for the Medium CLI. + + Parameters: + token (str): The access token to be set. + + Returns: + None + """ + keyring.set_password("medium_cli", "access_token", token) + typer.echo("Token set successfully") + + +@config_app.command() +def get_token() -> str: + """ + Retrieves the access token from the keyring and returns it. + + Raises: + AssertionError: If the access token is not set. + + Returns: + str: The access token. + """ + token = keyring.get_password("medium_cli", "access_token") + assert token, "Token not set. Please run `medium config set-token`" + + typer.echo(f"Token: {token}") + return token + + +@config_app.command() +def rm_token() -> None: + """ + Removes the access token from the keyring. + + Returns: + None + """ + keyring.delete_password("medium_cli", "access_token") + + typer.echo("Token removed successfully") + + +@app.command() +def get_user() -> dict: + """ + Retrieves the current user's information from the Medium API. + + Raises: + AssertionError: If the access token is not set. + MediumError: If there is an error retrieving the user's information. + + Returns: + dict: The user's information. + """ + client.access_token = keyring.get_password("medium_cli", "access_token") + assert client.access_token, "Access token not set. Please run `medium config set-token`" + + try: + resp = client.get_current_user() + typer.echo(f"Authenticated as {resp['name']}") + return resp + except MediumError as e: + typer.echo(f"Error: {e}") + raise typer.Abort() + + +@app.command() +def upload_image(image: str) -> dict: + """ + Uploads an image to the Medium API. + + Parameters: + image (str): The path to the image to be uploaded. + + Raises: + AssertionError: If the access token is not set, the image does not exist, or the image format is not supported. + MediumError: If there is an error uploading the image. + + Returns: + dict: The response from the Medium API. + """ + client.access_token = keyring.get_password("medium_cli", "access_token") + assert client.access_token, "Access token not set. Please run `medium config set-token`" + + img_suffix = Path(image).suffix.lower() + img_path = Path(image).resolve() + + assert img_path.exists(), "Image not found in provided path" + + assert img_suffix in [".png", ".jpg", ".jpeg", ".gif", ".tif", + ".tiff"], "Invalid image format. Supported formats are: png, jpg, jpeg, gif, tif, tiff" + + try: + resp = client.upload_image(file_path=img_path.__str__(), content_type=f"image/{img_suffix[1:]}") + typer.echo(f"Image uploaded successfully: {resp['url']}") + return resp + except MediumError as e: + typer.echo(f"Error: {e}") + raise typer.Abort() + + +@app.command() +def create_post(title: str = typer.Argument(..., help="Title of the post"), + content: str = typer.Argument(..., exists=True, file_okay=True, dir_okay=False, readable=True, + resolve_path=True, + help="Content of the post. Can be a string (raw content) or a file path " + "to an .html or .md file"), + content_format: str = typer.Option(None, "--content-format", "-f", + help="Format of the content. Options: html, markdown"), + canonical_url: str = typer.Option(None, "--canonical-url", "-c", help="Canonical URL of the post"), + tags: str = typer.Option(None, "--tags", "-t", help="Comma-separated list of tags"), + publish_status: str = typer.Option("public", "--publish-status", "-p", + help="Publish status of the post. Options: draft, public, unlisted"), + license: str = typer.Option("all-rights-reserved", "--license", "-l", + help="License to publish under. Options: all-rights-reserved (default), " + "cc-40-by, cc-40-by-sa, cc-40-by-nd, cc-40-by-nc, cc-40-by-nc-nd, " + "cc-40-by-nc-sa, cc-40-zero, public-domain")) -> dict: + """ + Creates a post on Medium. + + Parameters: title (str): The title of the post. content (str): The content of the post. Can be a string (raw + content) or a file path to an .html or .md file. content_format (str, optional): The format of the content. + Options: html, markdown. Defaults to None. canonical_url (str, optional): The canonical URL of the post. Defaults + to None. tags (str, optional): A comma-separated list of tags. Defaults to None. publish_status (str, optional): + The publish status of the post. Options: draft, public, unlisted. Defaults to "public". license (str, optional): + The license to publish under. Options: all-rights-reserved (default), cc-40-by, cc-40-by-sa, cc-40-by-nd, + cc-40-by-nc, cc-40-by-nc-nd, cc-40-by-nc-sa, cc-40-zero, public-domain. Defaults to "all-rights-reserved". + + Raises: AssertionError: If the access token is not set, the content does not exist, or the content format is not + supported. MediumError: If there is an error creating the post. + + Returns: + dict: The response from the Medium API. + """ + client.access_token = keyring.get_password("medium_cli", "access_token") + assert client.access_token, "Access token not set. Please run `medium config set-token`" + + if Path(content).exists(): + content = Path(content).resolve() + content_format = content.suffix.lower() if content_format is None else content_format + content_format = content_format[ + 1:] if content_format == ".html" else "markdown" if content_format == ".md" else None + else: + content = content + typer.echo("Treating post content as raw input. If you want to use a file, please provide a valid file path.") + assert content_format, ("To use raw input as content, please provide the format of the content using the " + "--content-format option") + + assert content_format in ["html", "markdown"], "Invalid content format. Options: html, markdown" + + tags = tags.split(",") if tags else None + + user_id = client.get_current_user()["id"] + + try: + resp = client.create_post(user_id=user_id, title=title, content=content, content_format=content_format, + canonical_url=canonical_url, tags=tags, publish_status=publish_status, + license=license) + typer.echo(f"Post created successfully: {resp['url']}") + return resp + except MediumError as e: + typer.echo(f"Error: {e}") + raise typer.Abort() + + +if __name__ == "__main__": + app() diff --git a/requirements.txt b/requirements.txt index c20f36f..c80d989 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ requests==2.20.0 +typer==0.10.0 +keyring~=25.0 diff --git a/setup.py b/setup.py index 33a60e6..7e8e593 100644 --- a/setup.py +++ b/setup.py @@ -2,13 +2,14 @@ setup( name='medium', packages=['medium'], - install_requires=['requests'], + install_requires=['requests', 'keyring', 'typer'], + entry_points={'console_scripts': ['medium=medium.cli:app']}, version='0.3.0', description='SDK for working with the Medium API', author='Kyle Hardgrave', author_email='kyle@medium.com', url='https://github.com/Medium/medium-sdk-python', download_url='https://github.com/Medium/medium-sdk-python/tarball/v0.3.0', - keywords=['medium', 'sdk', 'oauth', 'api'], + keywords=['medium', 'sdk', 'oauth', 'api', 'cli'], classifiers=[], ) From e8564ee5a574091ca26a2f8640df252d2391ce19 Mon Sep 17 00:00:00 2001 From: Ricardo Filipe dos Santos <56833915+rf-santos@users.noreply.github.com> Date: Tue, 26 Mar 2024 00:22:50 +0000 Subject: [PATCH 2/2] adding the option to pass the token directly --- medium/cli.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/medium/cli.py b/medium/cli.py index 329cb17..ae87f57 100644 --- a/medium/cli.py +++ b/medium/cli.py @@ -71,10 +71,14 @@ def rm_token() -> None: @app.command() -def get_user() -> dict: +def get_user(token: str = typer.Option(None, "--token", "-T", + help="Optionally pass the self-issued access token directly")) -> dict: """ Retrieves the current user's information from the Medium API. + Parameters: + token (str, optional): Optionally pass the self-issued access token directly. Defaults to None. + Raises: AssertionError: If the access token is not set. MediumError: If there is an error retrieving the user's information. @@ -82,7 +86,7 @@ def get_user() -> dict: Returns: dict: The user's information. """ - client.access_token = keyring.get_password("medium_cli", "access_token") + client.access_token = keyring.get_password("medium_cli", "access_token") if not token else token assert client.access_token, "Access token not set. Please run `medium config set-token`" try: @@ -95,12 +99,14 @@ def get_user() -> dict: @app.command() -def upload_image(image: str) -> dict: +def upload_image(image: str, token: str = typer.Option(None, "--token", "-T", + help="Optionally pass the self-issued access token directly")) -> dict: """ Uploads an image to the Medium API. Parameters: image (str): The path to the image to be uploaded. + token (str, optional): Optionally pass the self-issued access token directly. Defaults to None. Raises: AssertionError: If the access token is not set, the image does not exist, or the image format is not supported. @@ -109,7 +115,7 @@ def upload_image(image: str) -> dict: Returns: dict: The response from the Medium API. """ - client.access_token = keyring.get_password("medium_cli", "access_token") + client.access_token = keyring.get_password("medium_cli", "access_token") if not token else token assert client.access_token, "Access token not set. Please run `medium config set-token`" img_suffix = Path(image).suffix.lower() @@ -144,7 +150,9 @@ def create_post(title: str = typer.Argument(..., help="Title of the post"), license: str = typer.Option("all-rights-reserved", "--license", "-l", help="License to publish under. Options: all-rights-reserved (default), " "cc-40-by, cc-40-by-sa, cc-40-by-nd, cc-40-by-nc, cc-40-by-nc-nd, " - "cc-40-by-nc-sa, cc-40-zero, public-domain")) -> dict: + "cc-40-by-nc-sa, cc-40-zero, public-domain"), + token: str = typer.Option(None, "--token", "-T", + help="Optionally pass the self-issued access token directly")) -> dict: """ Creates a post on Medium. @@ -155,6 +163,7 @@ def create_post(title: str = typer.Argument(..., help="Title of the post"), The publish status of the post. Options: draft, public, unlisted. Defaults to "public". license (str, optional): The license to publish under. Options: all-rights-reserved (default), cc-40-by, cc-40-by-sa, cc-40-by-nd, cc-40-by-nc, cc-40-by-nc-nd, cc-40-by-nc-sa, cc-40-zero, public-domain. Defaults to "all-rights-reserved". + token (str, optional): Optionally pass the self-issued access token directly. Defaults to None. Raises: AssertionError: If the access token is not set, the content does not exist, or the content format is not supported. MediumError: If there is an error creating the post. @@ -162,7 +171,7 @@ def create_post(title: str = typer.Argument(..., help="Title of the post"), Returns: dict: The response from the Medium API. """ - client.access_token = keyring.get_password("medium_cli", "access_token") + client.access_token = keyring.get_password("medium_cli", "access_token") if not token else token assert client.access_token, "Access token not set. Please run `medium config set-token`" if Path(content).exists():