paint-brush
如何为 Trello Board 管理创建 Python CLI 程序(第 1 部分)经过@elainechan01
2,360 讀數
2,360 讀數

如何为 Trello Board 管理创建 Python CLI 程序(第 1 部分)

经过 Elaine Yun Ru Chan19m2023/08/15
Read on Terminal Reader

太長; 讀書

正如维基百科所述,“命令行界面 (CLI) 是一种与设备或计算机程序进行交互的方式,其中包含来自用户或客户端的命令以及来自设备或程序的响应,以文本行的形式。” 换句话说,CLI 程序是用户使用命令行通过提供要执行的指令来与程序交互的程序。 许多日常软件都包装为 CLI 程序。以 vim 文本编辑器为例 - 这是任何 UNIX 系统附带的工具,只需在终端中运行 vim <FILE> 即可激活它。 关于 Google Cloud CLI,让我们深入剖析 CLI 程序。
featured image - 如何为 Trello Board 管理创建 Python CLI 程序(第 1 部分)
Elaine Yun Ru Chan HackerNoon profile picture
0-item

免责声明:本教程假设读者具备 Python、API、Git 和单元测试的基础知识。

我遇到过各种具有最酷动画的 CLI 软件,这让我想知道 - 我是否可以升级我的“简约”石头剪刀布学校项目?


嗨,我们来玩吧!选择你的战士(石头、剪刀、布):石头

什么是 CLI 程序?

正如维基百科所述,“命令行界面 (CLI) 是一种与设备或计算机程序进行交互的方式,其中包含来自用户或客户端的命令以及来自设备或程序的响应,以文本行的形式。”


换句话说,CLI 程序是用户使用命令行通过提供要执行的指令来与程序交互的程序。


许多日常软件都包装为 CLI 程序。以vim文本编辑器为例 - 这是任何 UNIX 系统附带的工具,只需在终端中运行vim <FILE>即可激活它。


关于Google Cloud CLI ,让我们深入剖析 CLI 程序。

论点

参数(参数)是提供给程序的信息项。它通常被称为位置参数,因为它们是通过其位置来标识的。


例如,当我们想要在核心部分设置project属性时,我们运行gcloud config set project <PROJECT_ID>


值得注意的是,我们可以将其转化为

争论

内容

精氨酸0

云云

精氨酸1

配置

……

……

命令

命令是向计算机提供指令的参数数组。


基于前面的示例,我们通过运行gcloud config set project <PROJECT_ID>在核心部分设置project属性


换句话说, set是一个命令。

可选命令

通常,命令是必需的,但我们可以例外。根据程序的用例,我们可以定义可选命令。


回顾一下gcloud config命令,正如其官方文档中所述, gcloud config是一个可让您修改属性的命令组。用法是这样的:

 gcloud config GROUP | COMMAND [GCLOUD_WIDE_FLAG … ]

其中 COMMAND 可以是setlist等等……(请注意,GROUP 是config

选项

选项是修改命令行为的参数的记录类型。它们是用“-”或“--”表示的键值对。


回到gcloud config命令组的使用,在本例中,选项是GCLOUD_WIDE_FLAG


例如,假设我们想显示命令的详细用法和说明,我们运行gcloud config set –help 。换句话说, --help是选项。


另一个例子是,当我们想要在特定项目的计算部分设置区域属性时,我们运行gcloud config set compute <ZONE_NAME> –project=<PROJECT_ID> 。换句话说, --project是一个保存值<PROJECT_ID>的选项。


同样重要的是要注意,他们的立场通常并不重要。

强制选项

选项,就像它的名字一样,通常是可选的,但也可以定制为强制的。


例如,当我们想要创建 dataproc 集群时,我们运行gcloud dataproc clusters create <CLUSTER_NAME> –region=<REGION> 。正如他们的使用文档中所述:

 gcloud dataproc clusters create (CLUSTER: –region=REGION)

如果之前未配置过--region标志,则该标志是必需的。

空头期权与多头期权

短选项以-开头,后跟单个字母数字字符,而长选项以--开头,后跟多个字符。当用户确定自己想要什么时,可以将短选项视为快捷方式,而长选项则更具可读性。


你选择了摇滚!计算机现在将做出选择。

通过本教程我们将实现什么目标?

所以我撒了谎……我们不会尝试升级主要的石头剪刀布 CLI 程序。

相反,让我们看一下现实世界的场景:

纲要和目标

您的团队使用 Trello 来跟踪项目的问题和进度。您的团队正在寻找一种更简化的与董事会交互的方式 - 类似于通过终端创建新的 GitHub 存储库。该团队请您创建一个 CLI 程序,其基本要求是能够将新卡添加到看板的“待办事项”列中。


根据上述要求,我们通过定义其要求来起草我们的 CLI 程序:


功能要求

  • 用户可以将新卡添加到板上的列中
    • 所需输入:列、卡名称
    • 可选输入:卡片描述、卡片标签(从现有中选择)

非功能性需求

  • 提示用户提供 Trello 帐户访问权限(授权)的程序
  • 提示用户设置要使用哪个 Trello 看板的程序(配置)

可选要求

  • 用户可以向板上添加新列
  • 用户可以向板上添加新标签
  • 用户可以看到所有列的简化/详细视图


基于以上内容,我们可以将 CLI 程序的命令和选项形式化为:

根据需求的 CLI 结构的详细表格视图


Ps 不要担心最后两列,我们稍后会了解......


至于我们的技术堆栈,我们将坚持这一点:


单元测试

  • py测试
  • pytest 模拟
  • cli 测试助手

特雷洛

  • py-trello(Trello SDK 的 Python 包装器)

命令行界面

  • 打字机
  • 富有的
  • 简单术语菜单

实用程序(杂项)

  • python-dotenv

时间线

我们将分部分处理这个项目,以下是您可以期待的片段:


第1部分

  • py-trello业务逻辑的实现

第2部分

  • CLI业务逻辑的实现
  • 将 CLI 程序作为包分发

第三部分

  • 可选功能要求的实现
  • 套餐更新


电脑选择了剪刀!让我们看看谁赢得了这场战斗……

让我们开始吧

文件夹结构

目标是将 CLI 程序作为包分发到PyPI上。因此,需要这样的设置:

 trellocli/ __init__.py __main__.py models.py cli.py trelloservice.py tests/ test_cli.py test_trelloservice.py README.md pyproject.toml .env .gitignore


以下是对每个文件和/或目录的深入研究:

  • trellocli :充当用户使用的包名称,例如pip install trellocli
    • __init__.py :代表包的根目录,使该文件夹符合Python包
    • __main__.py :定义入口点,并允许用户使用-m标志来运行模块而无需指定文件路径,例如, python -m <module_name>替换python -m <parent_folder>/<module_name>.py
    • models.py :存储全局使用的类,例如 API 响应预期符合的模型
    • cli.py :存储 CLI 命令和选项的业务逻辑
    • trelloservice.py :存储与py-trello交互的业务逻辑
  • tests :存储程序的单元测试
    • test_cli.py :存储 CLI 实现的单元测试
    • test_trelloservice.py :存储与py-trello交互的单元测试
  • README.md :存储程序的文档
  • pyproject.toml :存储包的配置和需求
  • .env :存储环境变量
  • .gitignore :指定版本控制期间要忽略(不跟踪)的文件


有关发布 Python 包的更详细说明,请查看以下一篇很棒的文章: Geir Arne Hjelle 的 How to Publish an Open-Source Python Package to PyPI

设置

在开始之前,我们先来了解一下包的设置。


从包中的__init__.py文件开始,该文件将存储包常量和变量,例如应用程序名称和版本。在我们的例子中,我们想要初始化以下内容:

  • 应用程序名称
  • 版本
  • 成功和错误常量
# trellocli/__init__.py __app_name__ = "trellocli" __version__ = "0.1.0" ( SUCCESS, TRELLO_WRITE_ERROR, TRELLO_READ_ERROR ) = range(3) ERRORS = { TRELLO_WRITE_ERROR: "Error when writing to Trello", TRELLO_READ_ERROR: "Error when reading from Trello" }


转到__main__.py文件,程序的主流程应该存储在此处。在我们的例子中,我们将存储 CLI 程序入口点,假设cli.py中有一个可调用函数。

 # trellocli/__main__.py from trellocli import cli def main(): # we'll modify this later - after the implementation of `cli.py` pass if __name__ == "__main__": main()


现在包已经设置完毕,让我们来看看更新我们的README.md文件(主要文档)。我们没有必须遵循的特定结构,但一个好的自述文件应包含以下内容:

  • 概述
  • 安装和要求
  • 入门和使用

如果您想更深入地了解,可以阅读另一篇很棒的文章: merlos 的《How to Write a Good README》


这是我想如何构建该项目的自述文件

<!--- README.md --> # Overview # Getting Started # Usage # Architecture ## Data Flow ## Tech Stack # Running Tests # Next Steps # References


让我们暂时保留骨架 - 我们稍后会回到这个问题。


继续,让我们根据官方文档配置包的元数据

# pyproject.toml [project] name = "trellocli_<YOUR_USERNAME>" version = "0.1.0" authors = [ { name = "<YOUR_NAME>", email = "<YOUR_EMAIL>" } ] description = "Program to modify your Trello boards from your computer's command line" readme = "README.md" requires-python = ">=3.7" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] dependencies = [] [project.urls] "Homepage" = ""


请注意您必须修改的占位符,例如您的用户名、您的姓名……


另一方面,我们暂时将主页 URL 留空。我们将在将其发布到 GitHub 后进行更改。我们还将暂时将依赖项部分留空,并随时添加。


列表中的下一个是.env文件,我们在其中存储 API 机密和密钥等环境变量。请务必注意,Git 不应跟踪此文件,因为它包含敏感信息。


在我们的例子中,我们将在此处存储 Trello 凭据。要在 Trello 中创建 Power-Up,请遵循本指南。更具体地说,根据py-trello的使用情况,由于我们打算在应用程序中使用 OAuth,因此我们需要以下内容来与 Trello 交互:

  • API 密钥(用于我们的应用程序)
  • API Secret(对于我们的应用程序)
  • 令牌(用户授予对其数据的访问权限的令牌)


检索到 API 密钥和秘密后,将它们存储在.env文件中

# .env TRELLO_API_KEY=<your_api_key> TRELLO_API_SECRET=<your_api_secret>


最后但并非最不重要的一点是,让我们使用可以在此处找到的模板 Python .gitignore 。请注意,这对于确保我们的.env文件永远不会被跟踪至关重要 - 如果在某个时刻,我们的.env文件被跟踪,即使我们在后续步骤中删除了该文件,损坏也已经完成,恶意行为者可以追踪到之前的文件敏感信息的补丁。


现在设置已完成,让我们将更改推送到 GitHub。根据pyproject.toml中指定的元数据,请记住相应地更新您的许可证和主页 URL。有关如何编写更好的提交的参考: Victoria Dye 的“编写更好的提交,构建更好的项目”


其他值得注意的步骤:

单元测试

在开始编写测试之前,请务必注意,因为我们正在使用 API,所以我们将实施模拟测试,以便能够测试我们的程序,而不会面临 API 停机的风险。这是 Real Python 的另一篇关于模拟测试的精彩文章: Mocking external APIs in Python


基于功能需求,我们主要关心的是允许用户添加新卡。引用py-trello中的方法: add_card 。为此,我们必须从List类调用add_card方法,该方法可以从Board类的get_list函数中检索,而 Board 类可以检索该方法......


您明白了要点 - 我们需要很多辅助方法才能到达最终目的地,让我们用语言来表达:

  • 测试检索客户端的令牌
  • 测试检索板
  • 测试检索板
  • 测试从板上检索列表
  • 测试检索列表
  • 测试从板上检索标签
  • 测试检索标签
  • 测试添加卡
  • 测试向卡片添加标签


同样重要的是要注意,在编写单元测试时,我们希望我们的测试尽可能广泛 - 它能很好地处理错误吗?它涵盖了我们计划的各个方面吗?


然而,出于本教程的目的,我们将通过仅检查成功案例来简化事情。


在深入研究代码之前,让我们修改pyproject.toml文件以包含编写/运行单元测试所需的依赖项。

 # pyproject.toml [project] dependencies = [ "pytest==7.4.0", "pytest-mock==3.11.1" ]


接下来,让我们激活 virtualenv 并运行pip install .安装依赖项。


完成后,我们最后编写一些测试。一般来说,我们的测试应该包括要返回的模拟响应、通过使用模拟响应修复返回值来尝试测试的函数的补丁,以及最后对函数的调用。检索用户访问令牌的示例测试如下:

 # tests/test_trelloservice.py # module imports from trellocli import SUCCESS from trellocli.trelloservice import TrelloService from trellocli.models import * # dependencies imports # misc imports def test_get_access_token(mocker): """Test to check success retrieval of user's access token""" mock_res = GetOAuthTokenResponse( token="test", token_secret="test", status_code=SUCCESS ) mocker.patch( "trellocli.trelloservice.TrelloService.get_user_oauth_token", return_value=mock_res ) trellojob = TrelloService() res = trellojob.get_user_oauth_token() assert res.status_code == SUCCESS


请注意,在我的示例代码中, GetOAuthTokenResponse是一个尚未在models.py中设置的模型。它提供了编写更清晰的代码的结构,我们稍后会看到它的实际效果。


要运行我们的测试,只需运行python -m pytest 。请注意我们的测试将如何失败,但这没关系 - 最终会成功。


挑战角💡你能尝试自己编写更多测试吗?请随意参考这个补丁来看看我的测试是什么样的


现在,让我们构建trelloservice 。首先添加一个新的依赖项,即py-trello包装器。

 # pyproject.toml dependencies = [ "pytest==7.4.0", "pytest-mock==3.11.1", "py-trello==0.19.0" ]


再次运行pip install .安装依赖项。

楷模

现在,让我们开始构建我们的模型 - 来调节我们在trelloservice中期望的响应。对于这部分,最好参考我们的单元测试和py-trello源代码来了解我们期望的返回值类型。


例如,假设我们要检索用户的访问令牌,参考py-trellocreate_oauth_token函数( 源代码),我们知道期望返回值是这样的

# trellocli/models.py # module imports # dependencies imports # misc imports from typing import NamedTuple class GetOAuthTokenResponse(NamedTuple): token: str token_secret: str status_code: int


另一方面,请注意命名约定的冲突。例如, py-trello模块有一个名为List的类。解决此问题的方法是在导入期间提供别名。

 # trellocli/models.py # dependencies imports from trello import List as Trellolist


您也可以随意利用这个机会根据您的程序的需求定制模型。例如,假设您只需要返回值中的一个属性,您可以重构模型以期望从返回值中提取所述值,而不是将其作为一个整体存储。

 # trellocli/models.py class GetBoardName(NamedTuple): """Model to store board id Attributes id (str): Extracted board id from Board value type """ id: str


挑战角💡你能尝试自己编写更多模型吗?请随意参考此补丁来看看我的模型是什么样子

商业逻辑

设置

模型下来了,让我们正式开始编写trelloservice代码。同样,我们应该参考我们创建的单元测试 - 假设当前的测试列表没有提供服务的完整覆盖,总是在需要时返回并添加更多测试。


像往常一样,将所有导入语句包含在顶部。然后按预期创建TrelloService类和占位符方法。我们的想法是,我们将在cli.py中初始化服务的共享实例并相应地调用其方法。此外,我们的目标是可扩展性,因此需要广泛的覆盖范围。

 # trellocli/trelloservice.py # module imports from trellocli import TRELLO_READ_ERROR, TRELLO_WRITE_ERROR, SUCCESS from trellocli.models import * # dependencies imports from trello import TrelloClient # misc imports class TrelloService: """Class to implement the business logic needed to interact with Trello""" def __init__(self) -> None: pass def get_user_oauth_token() -> GetOAuthTokenResponse: pass def get_all_boards() -> GetAllBoardsResponse: pass def get_board() -> GetBoardResponse: pass def get_all_lists() -> GetAllListsResponse: pass def get_list() -> GetListResponse: pass def get_all_labels() -> GetAllLabelsResponse: pass def get_label() -> GetLabelResponse: pass def add_card() -> AddCardResponse: pass


请注意,这一次当我们运行测试时,我们的测试将如何通过。事实上,这将有助于我们确保坚持正确的轨道。工作流程应该是扩展我们的功能、运行我们的测试、检查通过/失败并相应地进行重构。

授权和初始化 TrelloClient

让我们从__init__函数开始。这个想法是在此处调用get_user_oauth_token函数并初始化TrelloClient 。再次强调仅在.env文件中存储此类敏感信息的需要,我们将使用python-dotenv依赖项来检索敏感信息。相应地修改我们的pyproject.toml文件后,让我们开始实施授权步骤。

 # trellocli/trelloservice.py class TrelloService: """Class to implement the business logic needed to interact with Trello""" def __init__(self) -> None: self.__load_oauth_token_env_var() self.__client = TrelloClient( api_key=os.getenv("TRELLO_API_KEY"), api_secret=os.getenv("TRELLO_API_SECRET"), token=os.getenv("TRELLO_OAUTH_TOKEN") ) def __load_oauth_token_env_var(self) -> None: """Private method to store user's oauth token as an environment variable""" load_dotenv() if not os.getenv("TRELLO_OAUTH_TOKEN"): res = self.get_user_oauth_token() if res.status_code == SUCCESS: dotenv_path = find_dotenv() set_key( dotenv_path=dotenv_path, key_to_set="TRELLO_OAUTH_TOKEN", value_to_set=res.token ) else: print("User denied access.") self.__load_oauth_token_env_var() def get_user_oauth_token(self) -> GetOAuthTokenResponse: """Helper method to retrieve user's oauth token Returns GetOAuthTokenResponse: user's oauth token """ try: res = create_oauth_token() return GetOAuthTokenResponse( token=res["oauth_token"], token_secret=res["oauth_token_secret"], status_code=SUCCESS ) except: return GetOAuthTokenResponse( token="", token_secret="", status_code=TRELLO_AUTHORIZATION_ERROR )


在此实现中,我们创建了一个辅助方法来处理任何可预见的错误,例如,当用户在授权期间单击Deny时。此外,它被设置为递归地请求用户授权,直到返回有效响应为止,因为事实是,除非用户授权我们的应用程序访问其帐户数据,否则我们无法继续。


挑战角💡 注意TRELLO_AUTHORIZATION_ERROR吗?您可以将此错误声明为包常量吗?请参阅设置以获取更多信息

辅助函数

现在授权部分已完成,让我们继续讨论辅助函数,首先检索用户的 Trello 看板。

 # trellocli/trelloservice.py def get_all_boards(self) -> GetAllBoardsResponse: """Method to list all boards from user's account Returns GetAllBoardsResponse: array of user's trello boards """ try: res = self.__client.list_boards() return GetAllBoardsResponse( res=res, status_code=SUCCESS ) except: return GetAllBoardsResponse( res=[], status_code=TRELLO_READ_ERROR ) def get_board(self, board_id: str) -> GetBoardResponse: """Method to retrieve board Required Args board_id (str): board id Returns GetBoardResponse: trello board """ try: res = self.__client.get_board(board_id=board_id) return GetBoardResponse( res=res, status_code=SUCCESS ) except: return GetBoardResponse( res=None, status_code=TRELLO_READ_ERROR )


至于检索列表(列),我们必须检查py-trelloBoard类,或者换句话说,我们必须接受Board值类型的新参数。

 # trellocli/trelloservice.py def get_all_lists(self, board: Board) -> GetAllListsResponse: """Method to list all lists (columns) from the trello board Required Args board (Board): trello board Returns GetAllListsResponse: array of trello lists """ try: res = board.all_lists() return GetAllListsResponse( res=res, status_code=SUCCESS ) except: return GetAllListsResponse( res=[], status_code=TRELLO_READ_ERROR ) def get_list(self, board: Board, list_id: str) -> GetListResponse: """Method to retrieve list (column) from the trello board Required Args board (Board): trello board list_id (str): list id Returns GetListResponse: trello list """ try: res = board.get_list(list_id=list_id) return GetListResponse( res=res, status_code=SUCCESS ) except: return GetListResponse( res=None, status_code=TRELLO_READ_ERROR )


挑战角💡你能自己实现get_all_labelsget_label函数吗?修改py-trelloBoard类。请随意参考这个补丁来看看我的实现是什么样的

添加新卡功能

最后但并非最不重要的一点是,我们终于达到了我们一直以来的目标——添加一张新卡。请记住,我们不会在这里使用之前声明的所有函数 - 辅助函数的目标是提高可扩展性。

 # trellocli/trelloservice.py def add_card( self, col: Trellolist, name: str, desc: str = "", labels: List[Label] = [] ) -> AddCardResponse: """Method to add a new card to a list (column) on the trello board Required Args col (Trellolist): trello list name (str): card name Optional Args desc (str): card description labels (List[Label]): list of labels to be added to the card Returns AddCardResponse: newly-added card """ try: # create new card new_card = col.add_card(name=name) # add optional description if desc: new_card.set_description(description=desc) # add optional labels if labels: for label in labels: new_card.add_label(label=label) return AddCardResponse( res=new_card, status_code=SUCCESS ) except: return AddCardResponse( res=new_card, status_code=TRELLO_WRITE_ERROR )


🎉 现在一切都已完成并尘埃落定,请记住相应地更新您的 README 并将您的代码推送到 GitHub。


恭喜!你赢了。再次玩(是/否)?

包起来

感谢您耐心等待:)通过本教程,您成功学会了在编写单元测试时实现模拟、构建内聚性模型、通读源代码以查找关键功能以及使用第三方包装器实现业务逻辑。


请密切关注第 2 部分,我们将深入探讨 CLI 程序本身的实现。


在此期间,让我们保持联系👀


GitHub 源代码:https: //github.com/elainechan01/trellocli