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