到目前为止,我们已经远远超出了主要的石头剪刀布学校项目 - 让我们直接进入它。
在如何为 Trello Board 管理创建 Python CLI 程序(第 1 部分)中,我们成功创建了与 Trello SDK 交互的业务逻辑。
以下是我们 CLI 程序架构的快速回顾:
在本教程中,我们将研究如何将我们的项目转换为 CLI 程序,重点关注功能和非功能需求。
另一方面,我们还将学习如何将我们的程序作为包分发到PyPI上。
之前,我们设法建立了一个框架来托管我们的trelloservice
模块。这一次,我们想要实现一个cli
文件夹,其中包含不同功能的模块,即:
这个想法是,对于每个命令组,其命令将存储在其自己的模块中。至于list
命令,我们将其存储在主 CLI 文件中,因为它不属于任何命令组。
另一方面,让我们研究一下清理文件夹结构。更具体地说,我们应该通过确保目录不混乱来开始考虑软件的可扩展性。
这是对我们的文件夹结构的建议:
trellocli/ __init__.py __main__.py trelloservice.py shared/ models.py custom_exceptions.py cli/ cli.py cli_config.py cli_create.py tests/ test_cli.py test_trelloservice.py assets/ images/ README.md pyproject.toml .env .gitignore
注意assets
文件夹是怎样的吗?这将用于存储我们的README
文件的相关资源,而trellocli
中有一个新实现的shared
文件夹,我们将使用它来存储要在整个软件中使用的模块。
让我们首先修改入口点文件__main__.py
。看看导入本身,因为我们决定将相关模块存储在它们自己的子文件夹中,所以我们必须适应此类更改。另一方面,我们还假设主 CLI 模块cli.py
有一个可以运行的app
实例。
# trellocli/__main__.py # module imports from trellocli import __app_name__ from trellocli.cli import cli from trellocli.trelloservice import TrelloService # dependencies imports # misc imports def main(): cli.app(prog_name=__app_name__) if __name__ == "__main__": main()
快进到我们的cli.py
文件;我们将在这里存储我们的app
实例。这个想法是初始化一个在软件中共享的Typer
对象。
# trellocli/cli/cli.py # module imports # dependencies imports from typer import Typer # misc imports # singleton instances app = Typer()
继续这个概念,让我们修改pyproject.toml
以指定我们的命令行脚本。在这里,我们将为包提供一个名称并定义入口点。
# pyproject.toml [project.scripts] trellocli = "trellocli.__main__:main"
基于上面的示例,我们将trellocli
定义为包名称,并在__main__
脚本中定义了main
函数,该函数存储在trellocli
模块中,将在运行时执行。
现在我们软件的 CLI 部分已经设置完毕,让我们修改trelloservice
模块以更好地为我们的 CLI 程序提供服务。您还记得,我们的trelloservice
模块设置为递归地请求用户授权,直到获得批准。我们将对此进行修改,以便在未授予授权的情况下程序将退出,并敦促用户运行config access
命令。这将确保我们的程序在指令方面更清晰、更具描述性。
简而言之,我们将修改这些函数:
__init__
__load_oauth_token_env_var
authorize
is_authorized
从__init__
函数开始,我们将初始化一个空客户端,而不是在这里处理客户端设置。
# trellocli/trelloservice.py class TrelloService: def __init__(self) -> None: self.__client = None
挑战角💡你能不能修改一下我们的__load_oauth_token_env_var
函数,让它不再递归提示用户授权?提示:递归函数是调用自身的函数。
转到authorize
和is_authorized
辅助函数,其想法是, authorize
将利用__load_oauth_token_env_var
函数执行设置客户端的业务逻辑,而is_authorized
函数仅返回是否授予授权的布尔值。
# trellocli/trelloservice.py class TrelloService: def authorize(self) -> AuthorizeResponse: """Method to authorize program to user's trello account Returns AuthorizeResponse: success / error """ self.__load_oauth_token_env_var() load_dotenv() if not os.getenv("TRELLO_OAUTH_TOKEN"): return AuthorizeResponse(status_code=TRELLO_AUTHORIZATION_ERROR) else: self.__client = TrelloClient( api_key=os.getenv("TRELLO_API_KEY"), api_secret=os.getenv("TRELLO_API_SECRET"), token=os.getenv("TRELLO_OAUTH_TOKEN") ) return AuthorizeResponse(status_code=SUCCESS) def is_authorized(self) -> bool: """Method to check authorization to user's trello account Returns bool: authorization to user's account """ if not self.__client: return False else: return True
了解__load_oauth_token_env_var
和authorize
之间的区别在于__load_oauth_token_env_var
是一个面向内部的函数,用于将授权令牌存储为环境变量,而authorize
是面向公众的函数,它尝试检索所有必要的凭据并初始化 Trello 客户端。
挑战角💡注意我们的authorize
函数如何返回AuthorizeResponse
数据类型。您可以实现具有status_code
属性的模型吗?请参阅如何为 Trello Board 管理创建 Python CLI 程序的第 1 部分(提示:看看我们如何创建模型)
最后,让我们在模块底部实例化一个单例TrelloService
对象。请随意参考此补丁以查看完整代码: trello-cli-kit
# trellocli/trelloservice.py trellojob = TrelloService()
最后,我们想要初始化一些在程序中共享的自定义异常。这与初始化程序中定义的ERRORS
不同,因为这些异常是BaseException
的子类,并且充当典型的用户定义异常,而ERRORS
更多地充当从 0 开始的常量值。
让我们将例外情况保持在最低限度,并考虑一些常见的用例,最值得注意的是:
# trellocli/shared/custom_exceptions.py class TrelloReadError(BaseException): pass class TrelloWriteError(BaseException): pass class TrelloAuthorizationError(BaseException): pass class InvalidUserInputError(BaseException): pass
正如第一部分中提到的,我们不会在本教程中广泛讨论单元测试,所以让我们只使用必要的元素:
这个想法是模拟一个命令行解释器,就像一个shell
来测试预期结果。 Typer
模块的伟大之处在于它带有自己的runner
对象。至于运行测试,我们将其与pytest
模块配对。更多信息请查看Typer 的官方文档。
让我们一起完成第一个测试,即配置访问。了解我们正在测试该函数是否正确执行。为此,我们将检查系统响应以及退出代码是否success
(即 0)。这是 RedHat 撰写的一篇精彩文章,介绍了退出代码是什么以及系统如何使用它们来进行进程通信。
# trellocli/tests/test_cli.py # module imports from trellocli.cli.cli import app # dependencies imports from typer.testing import CliRunner # misc imports runner = CliRunner() def test_config_access(): res = runner.invoke(app, ["config", "access"]) assert result.exit_code == 0 assert "Go to the following link in your browser:" in result.stdout
挑战角💡现在您已经掌握了要点,您可以自己实现其他测试用例吗? (提示:您还应该考虑测试失败案例)
了解这将是我们的主要cli
模块 - 对于所有命令组(配置、创建),它们的业务逻辑将存储在自己的单独文件中,以获得更好的可读性。
在本模块中,我们将存储list
命令。深入研究该命令,我们知道我们想要实现以下选项:
config board
则需要该名称
从 board_name 必需选项开始,有几种方法可以实现此目的,其中之一是使用回调函数(有关更多信息,请参阅官方文档)或仅使用默认环境变量。然而,对于我们的用例,让我们通过在不满足条件时引发InvalidUserInputError
自定义异常来保持简单。
为了构建命令,我们首先定义选项。在 Typer 中,正如其官方文档中提到的,定义选项的关键要素是:
例如,要创建具有以下条件的detailed
选项:
我们的代码如下所示:
detailed: Annotated[bool, typer.Option(help=”Enable detailed view)] = None
总的来说,为了定义带有所需选项的list
命令,我们将list
视为 Python 函数,并将其选项视为必需参数。
# trellocli/cli/cli.py @app.command() def list( detailed: Annotated[bool, Option(help="Enable detailed view")] = None, board_name: Annotated[str, Option(help="Trello board to search")] = "" ) -> None: pass
请注意,我们将命令添加到在文件顶部初始化的app
实例中。请随意浏览官方 Typer 代码库,根据您的喜好修改选项。
至于命令的工作流程,我们要做的是这样的:
board_name
选项)detailed
选项)
有几点需要注意……
SUCCESS
以外的状态代码时,我们希望引发异常。通过使用try-catch
块,我们可以防止程序发生致命崩溃。board_id
设置 Trello 板以供使用。因此,我们希望涵盖以下用例get_all_boards
函数检查匹配来显式提供board_name
,则检索board_id
board_name
选项,则检索存储为环境变量的board_id
rich
包中的Table
功能进行格式化。有关rich
的更多信息,请参阅他们的官方文档
将所有内容放在一起,我们得到如下结果。免责声明: TrelloService
可能缺少一些我们尚未实现的功能。如果您需要帮助实现它们,请参阅此补丁: trello-cli-kit
# trellocli/cli/cli.py # module imports from trellocli.trelloservice import trellojob from trellocli.cli import cli_config, cli_create from trellocli.misc.custom_exceptions import * from trellocli import SUCCESS # dependencies imports from typer import Typer, Option from rich import print from rich.console import Console from rich.table import Table from dotenv import load_dotenv # misc imports from typing_extensions import Annotated import os # singleton instances app = Typer() console = Console() # init command groups app.add_typer(cli_config.app, name="config", help="COMMAND GROUP to initialize configurations") app.add_typer(cli_create.app, name="create", help="COMMAND GROUP to create new Trello elements") @app.command() def list( detailed: Annotated[ bool, Option(help="Enable detailed view") ] = None, board_name: Annotated[str, Option()] = "" ) -> None: """COMMAND to list board details in a simplified (default)/detailed view OPTIONS detailed (bool): request for detailed view board_name (str): board to use """ try: # check authorization res_is_authorized = trellojob.is_authorized() if not res_is_authorized: print("[bold red]Error![/] Authorization hasn't been granted. Try running `trellocli config access`") raise AuthorizationError # if board_name OPTION was given, attempt to retrieve board id using the name # else attempt to retrieve board id stored as an env var board_id = None if not board_name: load_dotenv() if not os.getenv("TRELLO_BOARD_ID"): print("[bold red]Error![/] A trello board hasn't been configured to use. Try running `trellocli config board`") raise InvalidUserInputError board_id = os.getenv("TRELLO_BOARD_ID") else: res_get_all_boards = trellojob.get_all_boards() if res_get_all_boards.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when retrieving boards from trello") raise TrelloReadError boards_list = {board.name: board.id for board in res_get_all_boards.res} # retrieve all board id(s) and find matching board name if board_name not in boards_list: print("[bold red]Error![/] An invalid trello board name was provided. Try running `trellocli config board`") raise InvalidUserInputError board_id = boards_list[board_name] # configure board to use res_get_board = trellojob.get_board(board_id=board_id) if res_get_board.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when configuring the trello board to use") raise TrelloReadError board = res_get_board.res # retrieve data (labels, trellolists) from board res_get_all_labels = trellojob.get_all_labels(board=board) if res_get_all_labels.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when retrieving data from board") raise TrelloReadError labels_list = res_get_all_labels.res res_get_all_lists = trellojob.get_all_lists(board=board) if res_get_all_lists.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when retrieving data from board") raise TrelloReadError trellolists_list = res_get_all_lists.res # store data on cards for each trellolist trellolists_dict = {trellolist: [] for trellolist in trellolists_list} for trellolist in trellolists_list: res_get_all_cards = trellojob.get_all_cards(trellolist=trellolist) if res_get_all_cards.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when retrieving cards from trellolist") raise TrelloReadError cards_list = res_get_all_cards.res trellolists_dict[trellolist] = cards_list # display data (lists count, cards count, labels) # if is_detailed OPTION is selected, display data (name, description, labels) for each card in each trellolist print() table = Table(title="Board: "+board.name, title_justify="left", show_header=False) table.add_row("[bold]Lists count[/]", str(len(trellolists_list))) table.add_row("[bold]Cards count[/]", str(sum([len(cards_list) for cards_list in trellolists_dict.values()]))) table.add_row("[bold]Labels[/]", ", ".join([label.name for label in labels_list if label.name])) console.print(table) if detailed: for trellolist, cards_list in trellolists_dict.items(): table = Table("Name", "Desc", "Labels", title="List: "+trellolist.name, title_justify="left") for card in cards_list: table.add_row(card.name, card.description, ", ".join([label.name for label in card.labels if label.name])) console.print(table) print() except (AuthorizationError, InvalidUserInputError, TrelloReadError): print("Program exited...")
要查看我们的软件的运行情况,只需在终端中运行python -m trellocli --help
即可。默认情况下,Typer 模块将自行填充--help
命令的输出。请注意我们如何将trellocli
称为包名称 - 还记得之前在我们的pyproject.toml
中是如何定义的吗?
让我们快进一点并初始化create
和config
命令组。为此,我们只需在app
对象上使用add_typer
函数即可。这个想法是命令组将有自己的app
对象,我们只需将其添加到cli.py
中的主app
中,以及命令组的名称和帮助程序文本。它应该看起来像这样
# trellocli/cli/cli.py app.add_typer(cli_config.app, name="config", help="COMMAND GROUP to initialize configurations")
挑战角💡你能自己导入create
命令组吗?请随意参考此补丁寻求帮助: trello-cli-kit
要为create
设置命令组,我们将其各自的命令存储在其自己的模块中。该设置与cli.py
类似,需要实例化 Typer 对象。至于命令,我们还希望遵守使用自定义异常的需要。我们想要讨论的另一个主题是当用户按下Ctrl + C
时,或者换句话说,中断进程。我们之所以没有在list
命令中介绍这一点,是因为这里的区别在于config
命令组由交互式命令组成。交互式命令之间的主要区别在于它们需要持续的用户交互。当然,假设我们直接的命令执行起来需要很长时间。处理潜在的键盘中断也是最佳实践。
从access
命令开始,我们最终将使用在TrelloService
中创建的authorize
函数。由于authorize
函数自行处理所有配置,因此我们只需验证该过程的执行情况。
# trellocli/cli/cli_config.py @app.command() def access() -> None: """COMMAND to configure authorization for program to access user's Trello account""" try: # check authorization res_authorize = trellojob.authorize() if res_authorize.status_code != SUCCESS: print("[bold red]Error![/] Authorization hasn't been granted. Try running `trellocli config access`") raise AuthorizationError except KeyboardInterrupt: print("[yellow]Keyboard Interrupt.[/] Program exited...") except AuthorizationError: print("Program exited...")
至于board
命令,我们将利用各种模块来提供良好的用户体验,包括简单的终端菜单来显示终端 GUI 以供用户交互。主要思想如下:
# trellocli/cli/cli_config.py @app.command() def board() -> None: """COMMAND to initialize Trello board""" try: # check authorization res_is_authorized = trellojob.is_authorized() if not res_is_authorized: print("[bold red]Error![/] Authorization hasn't been granted. Try running `trellocli config access`") raise AuthorizationError # retrieve all boards res_get_all_boards = trellojob.get_all_boards() if res_get_all_boards.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when retrieving trello boards") raise TrelloReadError boards_list = {board.name: board.id for board in res_get_all_boards.res} # for easy access to board id when given board name # display menu to select board boards_menu = TerminalMenu( boards_list.keys(), title="Select board:", raise_error_on_interrupt=True ) boards_menu.show() selected_board = boards_menu.chosen_menu_entry # set board ID as env var dotenv_path = find_dotenv() set_key( dotenv_path=dotenv_path, key_to_set="TRELLO_BOARD_ID", value_to_set=boards_list[selected_board] ) except KeyboardInterrupt: print("[yellow]Keyboard Interrupt.[/] Program exited...") except (AuthorizationError, TrelloReadError): print("Program exited...")
最后,我们将讨论软件的核心功能需求 - 将新卡添加到 Trello 看板上的列表中。我们将使用list
命令中的相同步骤,直到从板上检索数据。
除此之外,我们将交互地请求用户输入以正确配置新卡:
对于需要用户从列表中选择的所有提示,我们将像以前一样使用Simple Terminal Menu
包。至于其他提示和杂项,例如需要文本输入或用户确认,我们将简单地使用rich
包。同样重要的是要注意我们必须正确处理可选值:
# trellocli/cli/cli_create.py @app.command() def card( board_name: Annotated[str, Option()] = "" ) -> None: """COMMAND to add a new trello card OPTIONS board_name (str): board to use """ try: # check authorization res_is_authorized = trellojob.is_authorized() if not res_is_authorized: print("[bold red]Error![/] Authorization hasn't been granted. Try running `trellocli config access`") raise AuthorizationError # if board_name OPTION was given, attempt to retrieve board id using the name # else attempt to retrieve board id stored as an env var board_id = None if not board_name: load_dotenv() if not os.getenv("TRELLO_BOARD_ID"): print("[bold red]Error![/] A trello board hasn't been configured to use. Try running `trellocli config board`") raise InvalidUserInputError board_id = os.getenv("TRELLO_BOARD_ID") else: res_get_all_boards = trellojob.get_all_boards() if res_get_all_boards.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when retrieving boards from trello") raise TrelloReadError boards_list = {board.name: board.id for board in res_get_all_boards.res} # retrieve all board id(s) and find matching board name if board_name not in boards_list: print("[bold red]Error![/] An invalid trello board name was provided. Try running `trellocli config board`") raise InvalidUserInputError board_id = boards_list[board_name] # configure board to use res_get_board = trellojob.get_board(board_id=board_id) if res_get_board.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when configuring the trello board to use") raise TrelloReadError board = res_get_board.res # retrieve data (labels, trellolists) from board res_get_all_labels = trellojob.get_all_labels(board=board) if res_get_all_labels.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when retrieving the labels from the trello board") raise TrelloReadError labels_list = res_get_all_labels.res labels_dict = {label.name: label for label in labels_list if label.name} res_get_all_lists = trellojob.get_all_lists(board=board) if res_get_all_lists.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when retrieving the lists from the trello board") raise TrelloReadError trellolists_list = res_get_all_lists.res trellolists_dict = {trellolist.name: trellolist for trellolist in trellolists_list} # for easy access to trellolist when given name of trellolist # request for user input (trellolist, card name, description, labels to include) interactively to configure new card to be added trellolist_menu = TerminalMenu( trellolists_dict.keys(), title="Select list:", raise_error_on_interrupt=True ) # Prompt: trellolist trellolist_menu.show() print(trellolist_menu.chosen_menu_entry) selected_trellolist = trellolists_dict[trellolist_menu.chosen_menu_entry] selected_name = Prompt.ask("Card name") # Prompt: card name selected_desc = Prompt.ask("Description (Optional)", default=None) # Prompt (Optional) description labels_menu = TerminalMenu( labels_dict.keys(), title="Select labels (Optional):", multi_select=True, multi_select_empty_ok=True, multi_select_select_on_accept=False, show_multi_select_hint=True, raise_error_on_interrupt=True ) # Prompt (Optional): labels labels_menu.show() selected_labels = [labels_dict[label] for label in list(labels_menu.chosen_menu_entries)] if labels_menu.chosen_menu_entries else None # display user selection and request confirmation print() confirmation_table = Table(title="Card to be Added", show_header=False) confirmation_table.add_row("List", selected_trellolist.name) confirmation_table.add_row("Name", selected_name) confirmation_table.add_row("Description", selected_desc) confirmation_table.add_row("Labels", ", ".join([label.name for label in selected_labels]) if selected_labels else None) console.print(confirmation_table) confirm = Confirm.ask("Confirm") # if confirm, attempt to add card to trello # else, exit if confirm: res_add_card = trellojob.add_card( col=selected_trellolist, name=selected_name, desc=selected_desc, labels=selected_labels ) if res_add_card.status_code != SUCCESS: print("[bold red]Error![/] A problem occurred when adding a new card to trello") raise TrelloWriteError else: print("Process cancelled...") except KeyboardInterrupt: print("[yellow]Keyboard Interrupt.[/] Program exited...") except (AuthorizationError, InvalidUserInputError, TrelloReadError, TrelloWriteError): print("Program exited...")
挑战角💡可以显示add
过程的进度条吗?提示:看看如何使用rich
的状态功能
有趣的部分来了 - 在 PyPI 上正式分发我们的软件。我们将遵循此管道来执行此操作:
有关详细说明,请查看Ramit Mittal 撰写的有关 Python 打包的精彩教程。
pyproject.toml
需要的最后一个细节是指定哪个模块存储包本身。在我们的例子中,这将是trellocli
。以下是要添加的元数据:
# pyproject.toml [tool.setuptools] packages = ["trellocli"]
至于我们的README.md
,提供某种指南是很好的做法,无论是使用指南还是如何开始。如果您在README.md
中包含图像,则应使用其绝对 URL,通常采用以下格式
https://raw.githubusercontent.com/<user>/<repo>/<branch>/<path-to-image>
我们将使用build
和twine
工具来构建和发布我们的包。在终端中运行以下命令来为您的包创建源存档和轮子:
python -m build
确保您已经在 TestPyPI 上设置了帐户,然后运行以下命令
twine upload -r testpypi dist/*
系统会提示您输入用户名和密码。由于启用了两因素身份验证,您将需要使用 API 令牌(有关如何获取 TestPyPI API 令牌的更多信息:文档链接)。只需输入以下值:
完成后,您应该能够前往 TestPyPI 查看新分发的包!
目标是利用 GitHub 作为根据标签不断更新包的新版本的手段。
首先,转到 GitHub 工作流程上的Actions
选项卡并选择一个新工作流程。我们将使用由 GitHub Actions 创建的Publish Python Package
工作流程。请注意工作流程如何需要从存储库中读取机密?确保您已将 PyPI 令牌存储在指定名称下(获取 PyPI API 令牌与 TestPyPI 的获取类似)。
创建工作流程后,我们将把代码推送到 v1.0.0 标签。有关版本命名语法的更多信息,请参阅 Py-Pkgs 的精彩解释:链接到文档
只需运行常用的pull
、 add
和commit
命令即可。接下来,通过运行以下命令为最新提交创建标签(有关标签的更多信息:链接到文档)
git tag <tagname> HEAD
最后,将新标签推送到远程存储库
git push <remote name> <tag name>
如果您想了解更多信息,请阅读Karol Horosin 撰写的关于将 CI/CD 与 Python 包集成的精彩文章。但现在,坐下来享受你的最新成就吧🎉。当 GitHub Actions 工作流程将您的包分发到 PyPI 时,请随意观看神奇的揭秘。
这是一篇很长的文章😓。通过本教程,您学习了使用Typer
模块将软件转换为 CLI 程序并将包分发到 PyPI。为了更深入地了解,您学习了定义命令和命令组、开发交互式 CLI 会话,并涉足键盘中断等常见 CLI 场景。
你绝对是一个能坚持到底的奇才。您愿意加入我的第 3 部分吗?我们将在其中实现可选功能?