devtool.py -- 一个FastAPI的开发环境便捷脚本

 

用惯了Django的manage.py所以在FastAPI的项目里,也写了一个类似的脚本:

  1 #!/usr/bin/env python
  2 import subprocess
  3 import sys
  4 from pathlib import Path
  5 
  6 import typer
  7 
  8 cli = typer.Typer()
  9 
 10 
 11 def get_part(s: str) -> str:
 12     """根据输入的选项,返回对应的命令参数
 13 
 14     Usage::
 15         >>> get_part('1')
 16         'patch'
 17         >>> get_part('patch')
 18         'patch'
 19     """
 20     choices = {"1": "patch", "2": "minor", "3": "major"}
 21     choices.update({v: v for v in choices.values()})
 22     try:
 23         return choices[s]
 24     except KeyError:
 25         typer.echo(f"Invalid part: {s!r}")
 26         sys.exit(1)
 27 
 28 
 29 def run_and_echo(cmd: str) -> int:
 30     """先打印出SHELL命令,然后执行它"""
 31     typer.echo(f"--> {cmd}")
 32     return subprocess.run(cmd, shell=True).returncode
 33 
 34 
 35 def get_current_version() -> str:
 36     """获取pyproject.toml文件里的版本号"""
 37     r = subprocess.run(["poetry", "version", "-s"], capture_output=True)
 38     return r.stdout.decode().strip()
 39 
 40 
 41 def exit_if_run_failed(cmd: str) -> None:
 42     """打印和执行SHELL命令,并在执行失败时退出脚本"""
 43     if rc := run_and_echo(cmd):
 44         sys.exit(rc)
 45 
 46 
 47 @cli.command()
 48 def bump():
 49     """根据输入的选项,执行bumpversion命令去升级版本和打标签"""
 50     version = get_current_version()
 51     typer.echo(f"Current version: {version}")
 52     if sys.argv[1:]:
 53         part = get_part(sys.argv[1])
 54     else:
 55         tip = (
 56             "Choices:\n1. patch\n2. minor\n3. major\n\n"
 57             "Which one to bump?(Leave blank to use `patch`) "
 58         )
 59         if a := input(tip).strip():
 60             part = get_part(a)
 61         else:
 62             part = "patch"
 63     parse = '"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"'  # NOQA
 64     cmd = (
 65         f"bumpversion --commit --parse {parse}"
 66         f" --current-version {version} {part} pyproject.toml"
 67     )
 68     if part != "patch":
 69         cmd + " --tag"
 70     exit_if_run_failed(cmd)
 71     exit_if_run_failed("git push && git push --tags && git log -1")
 72 
 73 
 74 class UpgradeDependencies:
 75     """升级依赖到最新版本
 76     (直接用poetry update的话,只会升级小版本,不会升级大版本)
 77     """
 78 
 79     @staticmethod
 80     def parse_value(version_info: str, key: str) -> str:
 81         """解析extras/platform等额外参数
 82 
 83         Usage::
 84             >>> parse_value('{extras = ["asyncpg"], version = "^0.19.2"}', 'extras')
 85             'asyncpg'
 86         """
 87         sep = key + " = "
 88         rest = version_info.split(sep, 1)[-1].split(",")[0]
 89         return rest.split("}")[0].strip().strip("[]")
 90 
 91     @classmethod
 92     def _build_args(
 93         cls, package_lines: list[str]
 94     ) -> tuple[list[str], dict[str, list[str]]]:
 95         args: list[str] = []  # ['uvicorn[standard]', 'fastapi']
 96         specials: dict[str, list[str]] = {}  # {'--platform linux': ['orjson']}
 97         for line in package_lines:
 98             if not (m := line.strip()) or m.startswith("#"):
 99                 continue
100             elif m.startswith("[tool."):
101                 break
102             package, version_info = m.split("=", 1)
103             if (package := package.strip()).lower() == "python":
104                 continue
105             elif "markers =" in version_info:
106                 # markers无法从命令行里定义,所以跳过
107                 continue
108             if (extras_tip := "extras") in version_info:
109                 package += "[" + cls.parse_value(version_info, extras_tip) + "]"
110             item = f'"{package}@latest"'
111             if (pf := "platform") in version_info:
112                 platform = cls.parse_value(version_info, pf)
113                 key = f"--{pf}={platform}"
114                 specials[key] = specials.get(key, []) + [item]
115             elif (sc := "source") in version_info:
116                 source = cls.parse_value(version_info, sc)
117                 key = f"--{sc}={source}"
118                 specials[key] = specials.get(key, []) + [item]
119             else:
120                 args.append(item)
121         return args, specials
122 
123     @classmethod
124     def get_args(cls) -> tuple[list[str], list[str], list[list[str]], str]:
125         others: list[list[str]] = []
126         main_title = "[tool.poetry.dependencies]"
127         toml_file = Path(__file__).parent.resolve().parent / "pyproject.toml"
128         text = toml_file.read_text("utf8").split(main_title)[-1]
129         dev_flag = "--dev"
130         if (dev_title := "[tool.poetry.dev-dependencies]") not in text:
131             dev_flag = "--group dev"
132             dev_title = "[tool.poetry.group.dev.dependencies]"
133         main, dev = text.split(dev_title)
134         devs = dev.split("[tool.")[0].strip().splitlines()
135         mains = main.strip().splitlines()
136         prod_packs, specials = cls._build_args(mains)
137         if specials:
138             others.extend([[k] + v for k, v in specials.items()])
139         dev_packs, specials = cls._build_args(devs)
140         if specials:
141             others.extend([[k, dev_flag] + v for k, v in specials.items()])
142         return prod_packs, dev_packs, others, dev_flag
143 
144     @classmethod
145     def gen_cmd(cls) -> str:
146         main_args, dev_args, others, dev_flag = cls.get_args()
147         command = "poetry add"
148         upgrade = "{0} {1} && {0} {3} {2}".format(
149             command, " ".join(main_args), " ".join(dev_args), dev_flag
150         )
151         for packages in others:
152             upgrade += f" && {command} {' '.join(packages)}"
153         return upgrade
154 
155 
156 @cli.command()
157 def update():
158     """升级所有依赖包到最新版"""
159     exit_if_run_failed(UpgradeDependencies.gen_cmd())
160 
161 
162 def lint(remove=None):
163     """格式化加静态检查"""
164     remove_imports = "autoflake --in-place --remove-all-unused-imports"
165     cmd = ""
166     paths = "."
167     if args := sys.argv[1:]:
168         if (flag := "-r") in args:
169             if remove is None:
170                 remove = True
171             args = [i for i in args if i != flag]
172         paths = " ".join(args)
173         if remove and all(Path(i).is_file() for i in args):
174             cmd = f"{remove_imports} {paths} && " + cmd
175     lint_them = "{0} {2} {1} && {0} {3} {1} && {0} {4} {1} && {0} {5} {1}"
176     tools = ("isort", "black", "ruff", "mypy")
177     cmd += lint_them.format("poetry run", paths, *tools)
178     exit_if_run_failed(cmd)
179 
180 
181 @cli.command()
182 def dev():
183     """启动服务:相当于django的runserver"""
184     cmd = "poetry run python main.py"
185     if args := sys.argv[1:]:
186         cmd += " " + " ".join(args)
187     exit_if_run_failed(cmd)
188 
189 
190 @cli.command()
191 def makemigrations():
192     """生成数据库迁移文件,类似Django的./manage.py makemigrations"""
193     exit_if_run_failed("aerich migrate")
194 
195 
196 @cli.command()
197 def migrate():
198     """更新数据库表结构:相当于django的./manage.py migrate"""
199     exit_if_run_failed("aerich upgrade")
200 
201 
202 if __name__ == "__main__":
203     cli()

 

pyproject.toml的内容如下:

[tool.poetry]
name = "huiyuan"
version = "0.1.1"
description = ""
authors = ["Waket Zheng <waketzheng@gmail.com>"]

[[tool.poetry.source]]
name = "tx"
url = "https://mirrors.cloud.tencent.com/pypi/simple/"
default = false
secondary = false

[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.88.0"
uvicorn = "^0.20.0"
tortoise-orm = {extras = ["asyncpg"], version = "^0.19.2"}
redis = "^4.4.0"
httpx = "^0.23.1"
python-dotenv = "^0.21.0"
loguru = "^0.6.0"
aerich = "^0.7.1"
orjson = {version = "^3.8.3", platform = "linux"}
gunicorn = {version = "^20.1.0", platform = "linux"}

[tool.poetry.group.dev.dependencies]
isort = "^5.11.4"
black = "^22.12.0"
types-redis = "^4.3.21.6"
typer = {extras = ["all"], version = "^0.7.0"}
ipython = {version = "^8.7.0", platform = "drawin"}
# ruff有点大(3.7M),采用全局安装的方式
#ruff = "*"

[tool.poetry.group.dev.dependencies.mypy]
version = "^0.991"
allow-prereleases = true

[tool.isort]
profile = "black"
known_third_party = "fastapi,tortoise"
skip = "build,.tox,.venv,migrations,.git,.pytest_cache,.mypy_cache,__init__.py"

[tool.black]
line-length = 88
target-version = ['py310']
extend-exclude = '''
# A regex preceded with ^/ will apply only to files and directories
# in the root of the project.
(
  ^/foo.py    # exclude a file named foo.py in the root of the project
  | .venv  # exclude autogenerated Protocol Buffer files anywhere in the project
)
'''

[tool.ruff]
ignore = ["D203"]
exclude = [".git", "__pycache__", ".venv", "dist"]

[tool.ruff.per-file-ignores]
"models/__init__.py" = ["F401","F403"]

[tool.mypy]
ignore_missing_imports = true
warn_no_return = false
exclude = [
    "^fabfile\\.py$",  # TOML's double-quoted strings require escaping backslashes
    'two\.pyi$',  # but TOML's single-quoted strings do not
    '^\.venv',
]
[[tool.mypy.overrides]]
module = "*.migrations.*"
ignore_errors = true


[tool.aerich]
tortoise_orm = "settings.TORTOISE_ORM"
location = "./migrations"
src_folder = "./."

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

[tool.poetry.scripts]
dev="huiyuan.devtool:dev"
bump="huiyuan.devtool:bump"
lint="huiyuan.devtool:lint"
update="huiyuan.devtool:update"
makemigrations="huiyuan.devtool:makemigrations"
migrate="huiyuan.devtool:migrate"

 

使用示例:poetry run update

posted @ 2022-11-22 23:00  waketzheng  阅读(93)  评论(0编辑  收藏  举报