免责声明:本教程假设读者具备 Python、API、Git 和单元测试的基础知识。
我遇到过各种具有最酷动画的 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 可以是set
、 list
等等……(请注意,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 程序:
功能要求
非功能性需求
可选要求
基于以上内容,我们可以将 CLI 程序的命令和选项形式化为:
Ps 不要担心最后两列,我们稍后会了解......
至于我们的技术堆栈,我们将坚持这一点:
单元测试
特雷洛
命令行界面
实用程序(杂项)
我们将分部分处理这个项目,以下是您可以期待的片段:
第1部分
py-trello
业务逻辑的实现第2部分
第三部分
电脑选择了剪刀!让我们看看谁赢得了这场战斗……
目标是将 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 密钥和秘密后,将它们存储在.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-trello
的create_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
请注意,这一次当我们运行测试时,我们的测试将如何通过。事实上,这将有助于我们确保坚持正确的轨道。工作流程应该是扩展我们的功能、运行我们的测试、检查通过/失败并相应地进行重构。
让我们从__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-trello
的Board
类,或者换句话说,我们必须接受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_labels
和get_label
函数吗?修改py-trello
的Board
类。请随意参考这个补丁来看看我的实现是什么样的
最后但并非最不重要的一点是,我们终于达到了我们一直以来的目标——添加一张新卡。请记住,我们不会在这里使用之前声明的所有函数 - 辅助函数的目标是提高可扩展性。
# 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