feat: add iticli worker command

main
917232558@qq.com 1 week ago
parent a93961290e
commit 321db4dfd6

@ -28,6 +28,7 @@ iTi-Flask 是 FastAPI 后端框架基座。
- `iti/service_client/*`:同步 HTTP JSON 客户端和注册表。 - `iti/service_client/*`:同步 HTTP JSON 客户端和注册表。
- `iti/mq/*`:通用 MQ 入口v1 Kafka backend生产者、消费者、offset store 和 runner。 - `iti/mq/*`:通用 MQ 入口v1 Kafka backend生产者、消费者、offset store 和 runner。
- `iti/tasks/*`:单进程任务注册和 runner。 - `iti/tasks/*`:单进程任务注册和 runner。
- `iticli/*`:框架随包提供的项目 CLI包含安装、测试、运行、迁移、模板、Docker 和 worker 命令。
- `iti/audit.py`:审计事件发送器,不拥有系统日志表。 - `iti/audit.py`:审计事件发送器,不拥有系统日志表。
- `iti/storage/*`:存储后端接口和实现。 - `iti/storage/*`:存储后端接口和实现。
- `copier-template/`:业务项目模板。 - `copier-template/`:业务项目模板。
@ -63,20 +64,22 @@ iTi-Flask 是 FastAPI 后端框架基座。
- `copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja` - `copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja`
- 需要保持一致的已生成项目 skill 副本 - 需要保持一致的已生成项目 skill 副本
- 生成项目必须保留 `.copier-answers.yml`,否则不能用 `copier update` 同步模板。 - 生成项目必须保留 `.copier-answers.yml`,否则不能用 `copier update` 同步模板。
- `iticli` 是独立工具,安装命令用 `uv tool install git+https://git.noahlan.cn/iti-framework/iti-flask-cli.git`。 - `iticli` 随 iTi-Flask 包提供,安装命令用 `uv tool install git+https://git.noahlan.cn/iti-framework/iTi-Flask.git`。
- Windows 安装 `iticli` 后需要运行 `uv tool update-shell` 并重新打开终端。 - Windows 安装 `iticli` 后需要运行 `uv tool update-shell` 并重新打开终端。
- `iticli init``iticli docker ...` 会在 `.env` 不存在时从 `.env.example` 创建。 - `iticli init``iticli docker ...` 会在 `.env` 不存在时从 `.env.example` 创建。
- 已生成项目同步框架依赖用 `iticli update framework` - 已生成项目同步框架依赖用 `iticli update framework`
- 已生成项目检查和同步模板用 `iticli template check`、`iticli template update`。 - 已生成项目检查和同步模板用 `iticli template check`、`iticli template update`。
- 业务项目本地 worker 用 `iticli worker <name>` 启动MQ worker 固定解析到 `app.modules.mq.worker` 并默认补 `MQ_ENABLED=true`
- 模板项目的 Alembic 命令必须显式使用 `-c migrations/alembic.ini` - 模板项目的 Alembic 命令必须显式使用 `-c migrations/alembic.ini`
- 模板项目的测试命令使用 `uv run --extra dev pytest -q`,避免未安装 dev extra 时找不到 pytest。 - 模板项目的测试命令使用 `uv run --extra dev pytest -q`,避免未安装 dev extra 时找不到 pytest。
## 命令 ## 命令
- 安装 CLI`uv tool install git+https://git.noahlan.cn/iti-framework/iti-flask-cli.git` - 安装 CLI`uv tool install git+https://git.noahlan.cn/iti-framework/iTi-Flask.git`
- 安装框架开发依赖:`iticli install` - 安装框架开发依赖:`iticli install`
- 运行框架测试:`iticli test` - 运行框架测试:`iticli test`
- 启动最小应用:`iticli run dev 8000` - 启动最小应用:`iticli run dev 8000`
- 启动业务项目 worker`iticli worker mq`
- 生成业务项目:`iticli create ../my-business-app` - 生成业务项目:`iticli create ../my-business-app`
- 生成带系统包的业务项目:`iticli create --with-system ../my-system-app` - 生成带系统包的业务项目:`iticli create --with-system ../my-system-app`
- 发布框架:`iticli release` - 发布框架:`iticli release`

@ -33,7 +33,7 @@ AI 修改框架代码或文档时优先读:
安装 `iticli` 安装 `iticli`
```bash ```bash
uv tool install git+https://git.noahlan.cn/iti-framework/iti-flask-cli.git uv tool install git+https://git.noahlan.cn/iti-framework/iTi-Flask.git
uv tool update-shell uv tool update-shell
``` ```
@ -91,6 +91,15 @@ iticli init
iticli run dev 8000 iticli run dev 8000
``` ```
业务项目启动本地 worker
```bash
iticli worker mq
iticli worker mq -- --config dev
```
`iticli worker mq` 会从业务项目根目录启动 `app.modules.mq.worker`,并在未显式设置 `MQ_ENABLED` 时默认设置为 `true`
同步框架依赖和模板骨架: 同步框架依赖和模板骨架:
```bash ```bash

@ -0,0 +1,2 @@
"""iTi-Flask project CLI."""

@ -0,0 +1,7 @@
from __future__ import annotations
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

@ -0,0 +1,959 @@
from __future__ import annotations
import argparse
import os
import re
import shutil
import subprocess
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence
DEFAULT_FRAMEWORK_GIT = os.environ.get(
"ITICLI_FRAMEWORK_GIT",
"https://git.noahlan.cn/iti-framework/iTi-Flask.git",
)
DEFAULT_SYSTEM_GIT = os.environ.get(
"ITICLI_SYSTEM_GIT",
"https://git.noahlan.cn/iti-framework/iTi-System.git",
)
DEFAULT_COPIER_REF = os.environ.get("ITICLI_COPIER_REF", "HEAD")
ENV_NAMES = {"dev", "test", "prod", "default"}
class CliError(Exception):
def __init__(self, message: str, code: int = 2) -> None:
super().__init__(message)
self.code = code
@dataclass(frozen=True)
class ProjectContext:
root: Path
kind: str
pyproject: dict[str, Any]
has_system: bool
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return int(args.handler(args))
except CliError as exc:
print(str(exc), file=sys.stderr)
return exc.code
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="iticli",
description="iTi-Flask framework and business project CLI.",
)
parser.set_defaults(handler=lambda _args: parser.print_help() or 0)
subparsers = parser.add_subparsers(dest="command")
help_parser = subparsers.add_parser("help", help="show help")
help_parser.set_defaults(handler=lambda _args: parser.print_help() or 0)
install_parser = subparsers.add_parser("install", help="install project dependencies")
install_parser.add_argument(
"profile",
nargs="?",
choices=["dev", "odbc"],
help="dev installs project dev dependencies; odbc also installs pyodbc",
)
install_parser.set_defaults(handler=handle_install)
test_parser = subparsers.add_parser("test", help="run tests")
test_parser.set_defaults(handler=handle_test)
worker_parser = subparsers.add_parser("worker", help="run a project worker")
worker_parser.add_argument("name", help="worker name, for example: mq")
worker_parser.add_argument("args", nargs=argparse.REMAINDER, help="arguments passed to the worker")
worker_parser.set_defaults(handler=handle_worker)
add_run_parser(subparsers, "run")
add_run_parser(subparsers, "serve")
migrate_parser = subparsers.add_parser("migrate", help="run Alembic commands")
migrate_parser.add_argument("action", nargs="?", help="upgrade target, heads, current, revision, ...")
migrate_parser.add_argument("args", nargs=argparse.REMAINDER)
migrate_parser.add_argument("-m", "--message", help="migration message for revision")
migrate_parser.set_defaults(handler=handle_migrate)
create_parser = subparsers.add_parser("create", help="create a business project")
create_parser.add_argument("project_name", help="target directory or project name")
create_parser.add_argument("--with-system", action="store_true", help="include iti-system")
create_parser.add_argument("--target", help="target directory, defaults to project_name")
create_parser.add_argument("--slug", help="distribution/project slug, defaults to target basename")
create_parser.add_argument("--display-name", help="business project display name")
create_parser.add_argument(
"--database",
choices=["mysql", "postgresql"],
default=None,
help="database dialect rendered into the project, defaults to template value",
)
create_parser.add_argument("--source", help="Copier template source, defaults to iTi-Flask private Git")
create_parser.add_argument("--ref", default=DEFAULT_COPIER_REF, help="Copier source ref, defaults to HEAD")
create_parser.add_argument("--local", action="store_true", help="use current iTi-Flask framework checkout as template source")
create_parser.add_argument("--framework-git", help="framework dependency Git URL rendered into the project")
create_parser.add_argument("--framework-tag", help="framework dependency tag rendered into the project")
create_parser.add_argument("--system-git", help="system dependency Git URL rendered into the project")
create_parser.add_argument("--system-tag", help="system dependency tag rendered into the project")
create_parser.set_defaults(handler=handle_create)
release_parser = subparsers.add_parser("release", help="release iTi-Flask framework")
release_parser.add_argument("version", nargs="?", help="target version, defaults to next patch")
release_parser.set_defaults(handler=handle_release)
update_parser = subparsers.add_parser("update", help="update iticli or framework packages")
update_parser.add_argument("target", choices=["self", "framework", "system", "all"])
update_parser.add_argument("--tag", help="target Git tag, defaults to latest semver tag")
update_parser.set_defaults(handler=handle_update)
template_parser = subparsers.add_parser("template", help="check or update Copier template")
template_subparsers = template_parser.add_subparsers(dest="template_action", required=True)
template_check = template_subparsers.add_parser("check", help="check template updates")
template_check.set_defaults(handler=handle_template)
template_update = template_subparsers.add_parser("update", help="apply template updates")
template_update.set_defaults(handler=handle_template)
init_parser = subparsers.add_parser("init", help="initialize a business project")
init_parser.add_argument("scope", nargs="?", choices=["system"], help="only run system migration and seed")
init_parser.set_defaults(handler=handle_init)
docker_parser = subparsers.add_parser("docker", help="run Docker Compose commands")
docker_parser.add_argument("action", choices=["build", "up", "down", "logs"])
docker_parser.add_argument("--db", action="store_true", help="include docker-compose.with-db.yml")
docker_parser.add_argument("--service", default="app", help="service name for logs")
docker_parser.set_defaults(handler=handle_docker)
return parser
def add_run_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser], name: str) -> None:
run_parser = subparsers.add_parser(name, help="run the app")
run_parser.add_argument("env_or_port", nargs="?", help="dev, test, prod, or port")
run_parser.add_argument("port", nargs="?", help="port when env is provided")
run_parser.set_defaults(handler=handle_run)
def handle_install(args: argparse.Namespace) -> int:
ctx = require_project()
if args.profile == "odbc":
extra_name = find_optional_extra(ctx.pyproject, ["odbc", "erp"])
if extra_name:
return run(["uv", "sync", "--extra", "dev", "--extra", extra_name], ctx.root)
code = run(["uv", "sync", "--extra", "dev"], ctx.root)
if code:
return code
return run(["uv", "pip", "install", "pyodbc>=5.3.0"], ctx.root)
return run(["uv", "sync", "--extra", "dev"], ctx.root)
def handle_test(_args: argparse.Namespace) -> int:
ctx = require_project()
if ctx.kind == "business":
return run(["uv", "run", "--extra", "dev", "pytest", "-q"], ctx.root)
return run(["uv", "run", "pytest", "-q"], ctx.root)
def handle_worker(args: argparse.Namespace) -> int:
ctx = require_project({"business"})
worker_name = normalize_worker_name(args.name)
module = resolve_worker_module(ctx, worker_name)
extra_args = list(args.args or [])
if extra_args[:1] == ["--"]:
extra_args = extra_args[1:]
env = {"APP_ENV": os.environ.get("APP_ENV", "dev")}
if worker_name == "mq" and "MQ_ENABLED" not in os.environ:
env["MQ_ENABLED"] = "true"
return run(["uv", "run", "python", "-m", module, *extra_args], ctx.root, extra_env=env)
def normalize_worker_name(name: str) -> str:
normalized = name.strip().replace("-", "_")
if not normalized or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", normalized):
raise CliError(f"invalid worker name: {name}")
return normalized
def resolve_worker_module(ctx: ProjectContext, name: str) -> str:
candidates = [
(ctx.root / "app" / "modules" / name / "worker.py", f"app.modules.{name}.worker"),
(ctx.root / "app" / "workers" / f"{name}.py", f"app.workers.{name}"),
]
for path, module in candidates:
if path.is_file():
return module
raise CliError(f"worker not found: {name}")
def handle_run(args: argparse.Namespace) -> int:
ctx = require_project()
env_name, port = parse_run_args(args.env_or_port, args.port)
env = {"APP_ENV": env_name}
cmd = build_run_cmd(ctx, port)
if env_name == "prod":
cmd.extend(["--host", "0.0.0.0"])
else:
cmd.append("--reload")
return run(cmd, ctx.root, extra_env=env)
def build_run_cmd(ctx: ProjectContext, port: str) -> list[str]:
if ctx.kind == "framework":
cmd = ["uv", "run", "python", "-m", "uvicorn", "iti.app:create_app", "--factory", "--port", port]
else:
cmd = ["uv", "run", "python", "-m", "uvicorn", "main:app", "--port", port]
return cmd
def handle_migrate(args: argparse.Namespace) -> int:
ctx = require_project()
action = args.action
rest = list(args.args or [])
base_cmd = alembic_base_cmd(ctx)
if not action:
return run([*base_cmd, "upgrade", "head"], ctx.root)
if action in {"revision", "migration"}:
message = args.message or extract_message(rest) or " ".join(rest).strip()
if not message:
raise CliError('missing migration message, example: iticli migrate revision "alice add order table"')
return run([*base_cmd, "revision", "--autogenerate", "-m", message], ctx.root)
if action == "upgrade":
target = rest[0] if rest else "head"
return run([*base_cmd, "upgrade", target, *rest[1:]], ctx.root)
if action in {"heads", "current", "history", "branches", "downgrade", "stamp", "show"}:
return run([*base_cmd, action, *rest], ctx.root)
return run([*base_cmd, "upgrade", action, *rest], ctx.root)
def handle_create(args: argparse.Namespace) -> int:
cwd = Path.cwd()
source = args.source or DEFAULT_FRAMEWORK_GIT
if args.local:
ctx = require_project({"framework"})
source = str(ctx.root)
target = Path(args.target or args.project_name).expanduser()
if not target.is_absolute():
target = cwd / target
slug = args.slug or target.name
display_name = args.display_name or target.name
cmd = [
"uvx",
"copier",
"copy",
"--defaults",
"--vcs-ref",
args.ref,
source,
str(target),
"-d",
f"project_name={display_name}",
"-d",
f"project_slug={slug}",
"-d",
f"include_system={str(args.with_system).lower()}",
]
if args.database is not None:
cmd.extend(["-d", f"database_dialect={args.database}"])
if args.framework_git:
cmd.extend(["-d", f"framework_git={args.framework_git}"])
if args.framework_tag is not None:
cmd.extend(["-d", f"framework_tag={args.framework_tag}"])
if args.system_git:
cmd.extend(["-d", f"system_git={args.system_git}"])
if args.system_tag is not None:
cmd.extend(["-d", f"system_tag={args.system_tag}"])
return run(cmd, cwd)
def handle_release(args: argparse.Namespace) -> int:
ctx = require_project({"framework", "system"})
if ctx.kind == "system":
return release_system(ctx.root, args.version)
return release_framework(ctx.root, args.version)
def handle_update(args: argparse.Namespace) -> int:
if args.target == "self":
return update_self()
ctx = require_project({"business", "system"})
updates = resolve_update_packages(ctx, args.target, args.tag)
update_pyproject_git_tags(ctx.root, updates)
if ctx.kind == "business" and ctx.has_system:
ensure_uv_no_sources_package(ctx.root, "iti-flask")
code = run(update_sync_cmd(ctx, updates), ctx.root)
if code:
return code
if "iti-system" in updates:
return sync_system_migrations(ctx)
return 0
def resolve_update_packages(ctx: ProjectContext, target: str, tag_arg: str | None) -> dict[str, str]:
if ctx.kind == "system":
if target not in {"framework", "all"}:
raise CliError("system package can only be updated from a business project")
return {"iti-flask": normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_FRAMEWORK_GIT)}
if target == "system" and not ctx.has_system:
raise CliError("current business project does not depend on iti-system")
if target == "framework":
return {"iti-flask": normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_FRAMEWORK_GIT)}
if target == "system":
return {"iti-system": normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_SYSTEM_GIT)}
if target == "all":
updates = {"iti-flask": normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_FRAMEWORK_GIT)}
if ctx.has_system:
updates["iti-system"] = normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_SYSTEM_GIT)
return updates
raise CliError(f"unknown update target: {target}")
def update_sync_cmd(ctx: ProjectContext, updates: Mapping[str, str]) -> list[str]:
cmd = ["uv", "sync", "--extra", "dev"]
if ctx.kind == "business" and ctx.has_system:
cmd.extend(["--no-sources-package", "iti-flask"])
for package in updates:
cmd.extend(["--upgrade-package", package])
return cmd
def handle_template(args: argparse.Namespace) -> int:
ctx = require_project({"business"})
cmd = ["uvx", "copier", "update", "--defaults", "--vcs-ref", "HEAD", str(ctx.root)]
if args.template_action == "check":
cmd.insert(4, "--pretend")
return run(cmd, ctx.root)
def handle_init(args: argparse.Namespace) -> int:
ctx = require_project({"business"})
if args.scope == "system":
if not ctx.has_system:
raise CliError("current business project does not depend on iti-system")
code = sync_system_migrations(ctx)
if code:
return code
code = run([*alembic_base_cmd(ctx), "upgrade", "head"], ctx.root)
if code:
return code
return seed_system(ctx)
code = run(["uv", "sync", "--extra", "dev"], ctx.root)
if code:
return code
if ctx.has_system:
code = sync_system_migrations(ctx)
if code:
return code
code = run([*alembic_base_cmd(ctx), "upgrade", "head"], ctx.root)
if code:
return code
if ctx.has_system:
return seed_system(ctx)
return 0
def handle_docker(args: argparse.Namespace) -> int:
ctx = require_project({"business"})
compose = ["docker", "compose"]
if args.db:
compose.extend(["-f", "docker-compose.yml", "-f", "docker-compose.with-db.yml"])
if args.action == "build":
return run([*compose, "build"], ctx.root)
if args.action == "up":
return run([*compose, "up", "-d", "--build"], ctx.root)
if args.action == "down":
return run([*compose, "down"], ctx.root)
return run([*compose, "logs", "-f", args.service], ctx.root)
def require_project(allowed: set[str] | None = None) -> ProjectContext:
ctx = detect_project(Path.cwd())
if ctx.kind == "unknown":
raise CliError("current directory is not an iTi-Flask framework or business project")
if allowed and ctx.kind not in allowed:
expected = " or ".join(sorted(allowed))
raise CliError(f"command requires {expected} project, current project is {ctx.kind}")
return ctx
def detect_project(cwd: Path) -> ProjectContext:
root = find_project_root(cwd)
if root is None:
return ProjectContext(cwd, "unknown", {}, False)
pyproject = read_pyproject(root / "pyproject.toml")
project_name = str(pyproject.get("project", {}).get("name", ""))
is_framework = (
project_name == "iti-flask"
and (root / "iti" / "app.py").is_file()
and (root / "copier.yml").is_file()
)
has_flask = has_package(pyproject, "iti-flask")
has_system = has_package(pyproject, "iti-system")
is_business = (
has_flask
and (root / "main.py").is_file()
and (root / "app").is_dir()
and (root / "migrations" / "alembic.ini").is_file()
)
is_system = (
project_name == "iti-system"
and (root / "iti_system" / "module.py").is_file()
and (root / "iti_system" / "cli.py").is_file()
)
if is_framework:
kind = "framework"
elif is_system:
kind = "system"
elif is_business:
kind = "business"
else:
kind = "unknown"
return ProjectContext(root, kind, pyproject, has_system)
def find_project_root(cwd: Path) -> Path | None:
current = cwd.resolve()
for path in (current, *current.parents):
if (path / "pyproject.toml").is_file():
return path
return None
def read_pyproject(path: Path) -> dict[str, Any]:
try:
with path.open("rb") as file:
return tomllib.load(file)
except FileNotFoundError:
return {}
except tomllib.TOMLDecodeError as exc:
raise CliError(f"invalid pyproject.toml: {exc}") from exc
def has_package(pyproject: dict[str, Any], package_name: str) -> bool:
project = pyproject.get("project", {})
dependencies = list(project.get("dependencies", []) or [])
optional = project.get("optional-dependencies", {}) or {}
for values in optional.values():
dependencies.extend(values or [])
normalized = package_name.lower().replace("_", "-")
for dependency in dependencies:
name = re.split(r"\s|@|>=|<=|==|~=|!=|>|<|\[", str(dependency), maxsplit=1)[0]
if name.lower().replace("_", "-") == normalized:
return True
uv_sources = pyproject.get("tool", {}).get("uv", {}).get("sources", {}) or {}
return normalized in {str(key).lower().replace("_", "-") for key in uv_sources}
def find_optional_extra(pyproject: dict[str, Any], names: Sequence[str]) -> str | None:
optional = pyproject.get("project", {}).get("optional-dependencies", {}) or {}
normalized = {str(name).lower().replace("_", "-"): str(name) for name in optional}
for name in names:
found = normalized.get(name.lower().replace("_", "-"))
if found:
return found
return None
def parse_run_args(env_or_port: str | None, port_arg: str | None) -> tuple[str, str]:
env_name = os.environ.get("APP_ENV", "dev")
port = "8000"
if env_or_port:
if env_or_port in ENV_NAMES:
env_name = "dev" if env_or_port == "default" else env_or_port
port = port_arg or port
elif env_or_port.isdigit():
port = env_or_port
else:
env_name = env_or_port
port = port_arg or port
if port_arg and not env_or_port:
port = port_arg
if not str(port).isdigit():
raise CliError(f"invalid port: {port}")
return env_name, str(port)
def extract_message(rest: list[str]) -> str | None:
for flag in ("-m", "--message"):
if flag in rest:
index = rest.index(flag)
if index + 1 >= len(rest):
raise CliError(f"{flag} requires a message")
message = rest[index + 1]
del rest[index : index + 2]
return message
for item in list(rest):
if item.startswith("--message="):
rest.remove(item)
return item.partition("=")[2]
return None
def alembic_base_cmd(ctx: ProjectContext) -> list[str]:
cmd = ["uv", "run", "python", "-m", "alembic"]
if ctx.kind == "business":
cmd.extend(["-c", "migrations/alembic.ini"])
return cmd
def sync_system_migrations(ctx: ProjectContext) -> int:
return run(["uv", "run", "iti-system", "migrations", "sync", "--target", "migrations/versions"], ctx.root)
def seed_system(ctx: ProjectContext) -> int:
return run(
["uv", "run", "iti-system", "seed", "system", "app:create_app"],
ctx.root,
extra_env={"PYTHONPATH": "."},
)
def update_self() -> int:
commands: list[list[str]] = []
if shutil.which("uv"):
commands.append(["uv", "tool", "upgrade", "iti-flask"])
if shutil.which("pipx"):
commands.append(["pipx", "upgrade", "iti-flask"])
if not commands:
raise CliError("uv or pipx is required to update iticli")
for cmd in commands:
code = run(cmd, Path.cwd())
if code == 0:
return 0
raise CliError("failed to update iticli with uv tool or pipx")
def run(cmd: Sequence[str], cwd: Path, extra_env: dict[str, str] | None = None) -> int:
env = project_env(cwd, extra_env)
completed = subprocess.run(list(cmd), cwd=str(cwd), env=env, check=False)
return int(completed.returncode)
def project_env(root: Path, extra_env: dict[str, str] | None = None) -> dict[str, str]:
env = os.environ.copy()
virtual_env = env.get("VIRTUAL_ENV")
project_venv = root / ".venv"
if virtual_env:
try:
same_venv = Path(virtual_env).resolve() == project_venv.resolve()
except OSError:
same_venv = False
if not same_venv:
env.pop("VIRTUAL_ENV", None)
if extra_env:
env.update(extra_env)
return env
def release_framework(root: Path, version_arg: str | None) -> int:
current_version = read_current_version(root / "iti" / "__about__.py")
target_version = normalize_version(version_arg) if version_arg else bump_patch(current_version)
target_tag = f"v{target_version}"
if target_version == current_version:
raise CliError(f"version already set to {target_version}")
if not version_is_newer(current_version, target_version):
raise CliError(f"target version must be newer than {current_version}")
if git_output(root, ["status", "--porcelain"]).strip():
raise CliError("working tree is not clean")
branch = git_output(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]).strip()
if not branch:
raise CliError("release requires a branch checkout")
if subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{target_tag}"], cwd=root).returncode == 0:
raise CliError(f"tag already exists: {target_tag}")
code = run(["uv", "run", "pytest", "-q"], root)
if code:
return code
write_framework_release_files(root, target_version)
code = run(
[
"git",
"add",
"iti/__about__.py",
"copier.yml",
"copier-template/pyproject.toml.jinja",
"README.md",
],
root,
)
if code:
return code
code = run(["git", "commit", "-m", f"chore: release {target_tag}"], root)
if code:
return code
code = run(["git", "tag", "-a", target_tag, "-m", f"release {target_tag}"], root)
if code:
return code
code = run(["git", "push", "origin", branch, target_tag], root)
if code:
return code
print(f"released {target_tag}")
return 0
def release_system(root: Path, version_arg: str | None) -> int:
current_version = read_current_version(root / "iti_system" / "__about__.py")
target_version = normalize_version(version_arg) if version_arg else bump_patch(current_version)
target_tag = f"v{target_version}"
if target_version == current_version:
raise CliError(f"version already set to {target_version}")
if not version_is_newer(current_version, target_version):
raise CliError(f"target version must be newer than {current_version}")
if git_output(root, ["status", "--porcelain"]).strip():
raise CliError("working tree is not clean")
branch = git_output(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]).strip()
if not branch:
raise CliError("release requires a branch checkout")
if subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{target_tag}"], cwd=root).returncode == 0:
raise CliError(f"tag already exists: {target_tag}")
code = run(["uv", "run", "pytest", "-q"], root)
if code:
return code
write_system_release_files(root, target_version)
code = run(["git", "add", "iti_system/__about__.py", "pyproject.toml", "README.md"], root)
if code:
return code
code = run(["git", "commit", "-m", f"chore: release {target_tag}"], root)
if code:
return code
code = run(["git", "tag", "-a", target_tag, "-m", f"release {target_tag}"], root)
if code:
return code
code = run(["git", "push", "origin", branch, target_tag], root)
if code:
return code
print(f"released {target_tag}")
return 0
def git_output(root: Path, args: Sequence[str]) -> str:
completed = subprocess.run(
["git", *args],
cwd=root,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if completed.returncode:
raise CliError(completed.stderr.strip() or f"git {' '.join(args)} failed")
return completed.stdout
def latest_git_tag(git_url: str) -> str:
completed = subprocess.run(
["git", "ls-remote", "--tags", git_url],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if completed.returncode:
raise CliError(completed.stderr.strip() or f"git ls-remote failed: {git_url}")
return latest_git_tag_from_refs(completed.stdout, git_url)
def latest_git_tag_from_refs(refs: str, git_url: str = "remote") -> str:
tags = git_tags_from_refs(refs)
if not tags:
raise CliError(f"no semver tags found in {git_url}")
return max(tags, key=parse_version)
def git_tags_from_refs(refs: str) -> list[str]:
tags: list[str] = []
seen: set[str] = set()
for line in refs.splitlines():
parts = line.split()
if len(parts) != 2 or parts[1].endswith("^{}"):
continue
tag = parts[1].rsplit("/", 1)[-1]
try:
parse_version(tag)
except CliError:
continue
if tag not in seen:
tags.append(tag)
seen.add(tag)
return tags
def normalize_git_tag(raw: str) -> str:
return f"v{normalize_version(raw)}"
def update_pyproject_git_tags(root: Path, updates: Mapping[str, str]) -> None:
path = root / "pyproject.toml"
lines = path.read_text(encoding="utf-8").splitlines()
updated = {package: False for package in updates}
for index, line in enumerate(lines):
for package, tag in updates.items():
dependency_line = rewrite_dependency_git_line(line, package, tag)
if dependency_line is not None:
lines[index] = dependency_line
updated[package] = True
break
else:
for package, tag in updates.items():
source_line = rewrite_uv_source_git_line(line, package, tag)
if source_line is not None:
lines[index] = source_line
updated[package] = True
break
missing = [package for package, is_updated in updated.items() if not is_updated]
if missing:
raise CliError(f"{', '.join(missing)} git dependency not found in {path}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def ensure_uv_no_sources_package(root: Path, package: str) -> None:
path = root / "pyproject.toml"
lines = path.read_text(encoding="utf-8").splitlines()
section_start = find_toml_section(lines, "[tool.uv]")
if section_start is None:
source_start = find_toml_section(lines, "[tool.uv.sources]")
insert_at = source_start if source_start is not None else len(lines)
block = ["[tool.uv]", f'no-sources-package = ["{package}"]', ""]
lines[insert_at:insert_at] = block
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
section_end = next_toml_section(lines, section_start + 1)
for index in range(section_start + 1, section_end):
line = lines[index].strip()
if not line.startswith("no-sources-package"):
continue
if re.search(rf'"{re.escape(package)}"|\'{re.escape(package)}\'', line):
return
if line.endswith("]"):
lines[index] = f'{lines[index][:-1]}, "{package}"]'
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
raise CliError("cannot update multi-line tool.uv.no-sources-package")
lines.insert(section_end, f'no-sources-package = ["{package}"]')
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def find_toml_section(lines: Sequence[str], section: str) -> int | None:
for index, line in enumerate(lines):
if line.strip() == section:
return index
return None
def next_toml_section(lines: Sequence[str], start: int) -> int:
for index in range(start, len(lines)):
if re.match(r"\s*\[.+]\s*$", lines[index]):
return index
return len(lines)
def rewrite_dependency_git_line(line: str, package: str, tag: str) -> str | None:
stripped = line.lstrip()
indent = line[: len(line) - len(stripped)]
if not stripped.startswith(('"', "'")):
return None
quote = stripped[0]
end = stripped.find(quote, 1)
if end < 0:
return None
dependency = stripped[1:end]
name_part, separator, git_ref = dependency.partition("@ git+")
if not separator or dependency_package_name(name_part) != package:
return None
git_url, _old_ref = split_git_url_ref(git_ref.strip())
new_dependency = f"{name_part.rstrip()} @ git+{git_url}@{tag}"
return f"{indent}{quote}{new_dependency}{quote}{stripped[end + 1:]}"
def rewrite_uv_source_git_line(line: str, package: str, tag: str) -> str | None:
if not re.match(rf"^\s*{re.escape(package)}\s*=", line):
return None
match = re.search(r'git\s*=\s*"([^"]+)"', line)
if not match:
raise CliError(f"{package} source is not a git source")
indent = line[: len(line) - len(line.lstrip())]
return f'{indent}{package} = {{ git = "{match.group(1)}", tag = "{tag}" }}'
def dependency_package_name(name_part: str) -> str:
return re.split(r"\[|\s", name_part.strip(), maxsplit=1)[0].lower().replace("_", "-")
def split_git_url_ref(value: str) -> tuple[str, str | None]:
url, separator, ref = value.rpartition("@")
if separator and ref and "/" not in ref and ":" not in ref:
return url, ref
return value, None
def read_current_version(about_file: Path) -> str:
match = re.search(r'^__version__\s*=\s*"([^"]+)"', about_file.read_text(encoding="utf-8"), re.MULTILINE)
if not match:
raise CliError(f"version not found in {about_file}")
return match.group(1)
def write_framework_release_files(root: Path, target_version: str) -> None:
replace_first_line(
root / "iti" / "__about__.py",
lambda line: line.startswith("__version__ = "),
f'__version__ = "{target_version}"',
)
replace_section_default(root / "copier.yml", "framework_tag:", f" default: v{target_version}")
replace_section_default(root / "copier.yml", "system_tag:", f" default: v{target_version}")
replace_first_line(
root / "copier-template" / "pyproject.toml.jinja",
lambda line: line.startswith("version = "),
f'version = "{target_version}"',
)
replace_first_line(
root / "README.md",
lambda line: line.startswith(' "iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v'),
f' "iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v{target_version}",',
)
replace_first_line(
root / "README.md",
lambda line: line.startswith("iticli release v"),
f"iticli release v{target_version}",
count=2,
)
def write_system_release_files(root: Path, target_version: str) -> None:
replace_first_line(
root / "iti_system" / "__about__.py",
lambda line: line.startswith("__version__ = "),
f'__version__ = "{target_version}"',
)
replace_first_line(
root / "pyproject.toml",
lambda line: line.startswith('iti-flask = { git = "https://git.noahlan.cn/iti-framework/iTi-Flask.git", tag = "'),
f'iti-flask = {{ git = "https://git.noahlan.cn/iti-framework/iTi-Flask.git", tag = "v{target_version}" }}',
)
replace_first_line(
root / "README.md",
lambda line: line.startswith(' "iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v'),
f' "iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v{target_version}",',
)
replace_first_line(
root / "README.md",
lambda line: line.startswith(' "iti-system @ git+https://git.noahlan.cn/iti-framework/iTi-System.git@v'),
f' "iti-system @ git+https://git.noahlan.cn/iti-framework/iTi-System.git@v{target_version}",',
)
replace_first_line(
root / "README.md",
lambda line: line.startswith("iticli release v"),
f"iticli release v{target_version}",
count=2,
)
def replace_first_line(path: Path, predicate: Any, new_line: str, count: int = 1) -> None:
lines = path.read_text(encoding="utf-8").splitlines()
updated = 0
for index, line in enumerate(lines):
if predicate(line):
lines[index] = new_line
updated += 1
if updated == count:
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
raise CliError(f"line not updated in {path}")
def replace_section_default(path: Path, section_header: str, new_line: str) -> None:
lines = path.read_text(encoding="utf-8").splitlines()
in_section = False
for index, line in enumerate(lines):
if line == section_header:
in_section = True
continue
if in_section and line and not line.startswith((" ", "\t")):
break
if in_section and line.startswith(" default: v"):
lines[index] = new_line
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
raise CliError(f"line not updated in {path}")
def normalize_version(raw: str) -> str:
major, minor, patch = parse_version(raw)
return f"{major}.{minor}.{patch}"
def bump_patch(raw: str) -> str:
major, minor, patch = parse_version(raw)
return f"{major}.{minor}.{patch + 1}"
def version_is_newer(current: str, target: str) -> bool:
return parse_version(target) > parse_version(current)
def parse_version(raw: str) -> tuple[int, int, int]:
version = raw.removeprefix("v")
parts = version.split(".")
if len(parts) != 3:
raise CliError(f"invalid version: {raw}")
try:
return tuple(int(part) for part in parts) # type: ignore[return-value]
except ValueError as exc:
raise CliError(f"invalid version: {raw}") from exc
if __name__ == "__main__":
raise SystemExit(main())

@ -68,7 +68,7 @@ Source = "https://github.com/NoahLan/iti-flask"
version = { attr = "iti.__about__.__version__" } version = { attr = "iti.__about__.__version__" }
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["iti*"] include = ["iti*", "iticli*"]
exclude = ["iti.runtime*"] exclude = ["iti.runtime*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
@ -78,6 +78,7 @@ iti = [
[project.scripts] [project.scripts]
iti = "iti.cli:iti_cli" iti = "iti.cli:iti_cli"
iticli = "iticli.cli:main"
[tool.setuptools.exclude-package-data] [tool.setuptools.exclude-package-data]
iti = [ iti = [
@ -88,13 +89,14 @@ iti = [
] ]
[tool.coverage.run] [tool.coverage.run]
source_pkgs = ["iti", "tests"] source_pkgs = ["iti", "iticli", "tests"]
branch = true branch = true
parallel = true parallel = true
omit = ["iti/__about__.py"] omit = ["iti/__about__.py"]
[tool.coverage.paths] [tool.coverage.paths]
iti = ["iti", "*/iti"] iti = ["iti", "*/iti"]
iticli = ["iticli", "*/iticli"]
tests = ["tests", "*/tests"] tests = ["tests", "*/tests"]
[tool.coverage.report] [tool.coverage.report]
@ -105,6 +107,7 @@ python_version = "3.11"
ignore_missing_imports = true ignore_missing_imports = true
warn_unused_configs = true warn_unused_configs = true
files = [ files = [
"iticli",
"iti/config.py", "iti/config.py",
"iti/cli.py", "iti/cli.py",
"iti/app.py", "iti/app.py",

@ -0,0 +1,310 @@
from __future__ import annotations
from pathlib import Path
import pytest
from iticli.cli import (
CliError,
ProjectContext,
alembic_base_cmd,
build_run_cmd,
build_parser,
detect_project,
ensure_uv_no_sources_package,
extract_message,
find_optional_extra,
git_tags_from_refs,
has_package,
latest_git_tag_from_refs,
normalize_version,
normalize_worker_name,
parse_run_args,
resolve_worker_module,
resolve_update_packages,
update_sync_cmd,
update_pyproject_git_tags,
version_is_newer,
)
def write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def test_detect_framework_project(tmp_path: Path) -> None:
write(
tmp_path / "pyproject.toml",
"""
[project]
name = "iti-flask"
dependencies = []
""",
)
write(tmp_path / "iti" / "app.py", "")
write(tmp_path / "copier.yml", "")
ctx = detect_project(tmp_path)
assert ctx.kind == "framework"
def test_detect_business_project(tmp_path: Path) -> None:
write(
tmp_path / "pyproject.toml",
"""
[project]
name = "demo"
dependencies = [
"iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v0.2.4",
"iti-system @ git+https://git.noahlan.cn/iti-framework/iTi-System.git@v0.2.4",
]
""",
)
write(tmp_path / "main.py", "")
(tmp_path / "app").mkdir()
write(tmp_path / "migrations" / "alembic.ini", "")
ctx = detect_project(tmp_path)
assert ctx.kind == "business"
assert ctx.has_system is True
def test_detect_system_project(tmp_path: Path) -> None:
write(
tmp_path / "pyproject.toml",
"""
[project]
name = "iti-system"
dependencies = ["iti-flask"]
""",
)
write(tmp_path / "iti_system" / "module.py", "")
write(tmp_path / "iti_system" / "cli.py", "")
ctx = detect_project(tmp_path)
assert ctx.kind == "system"
def test_detect_from_child_directory(tmp_path: Path) -> None:
write(
tmp_path / "pyproject.toml",
"""
[project]
name = "demo"
dependencies = ["iti-flask @ git+https://example.test/iTi-Flask.git"]
""",
)
write(tmp_path / "main.py", "")
child = tmp_path / "app" / "modules"
child.mkdir(parents=True)
write(tmp_path / "migrations" / "alembic.ini", "")
ctx = detect_project(child)
assert ctx.root == tmp_path
assert ctx.kind == "business"
def test_has_package_reads_uv_sources() -> None:
pyproject = {
"project": {"dependencies": []},
"tool": {"uv": {"sources": {"iti-flask": {"git": "https://example.test/repo.git"}}}},
}
assert has_package(pyproject, "iti-flask") is True
def test_find_optional_extra_prefers_project_extra_name() -> None:
pyproject = {"project": {"optional-dependencies": {"odbc": [], "dev": []}}}
assert find_optional_extra(pyproject, ["odbc", "erp"]) == "odbc"
def test_business_alembic_uses_template_config(tmp_path: Path) -> None:
ctx = ProjectContext(tmp_path, "business", {}, False)
assert alembic_base_cmd(ctx) == [
"uv",
"run",
"python",
"-m",
"alembic",
"-c",
"migrations/alembic.ini",
]
def test_framework_alembic_uses_default_config(tmp_path: Path) -> None:
ctx = ProjectContext(tmp_path, "framework", {}, False)
assert alembic_base_cmd(ctx) == ["uv", "run", "python", "-m", "alembic"]
def test_parse_run_args_accepts_env_and_port() -> None:
assert parse_run_args("test", "9000") == ("test", "9000")
assert parse_run_args("9000", None) == ("dev", "9000")
assert parse_run_args("default", None) == ("dev", "8000")
def test_build_run_cmd_uses_python_module() -> None:
framework = ProjectContext(Path("."), "framework", {}, False)
business = ProjectContext(Path("."), "business", {}, False)
assert build_run_cmd(framework, "8000")[:5] == ["uv", "run", "python", "-m", "uvicorn"]
assert "iti.app:create_app" in build_run_cmd(framework, "8000")
assert "main:app" in build_run_cmd(business, "8000")
def test_extract_message_accepts_remainder_flags() -> None:
rest = ["-m", "alice add order table", "--head", "head"]
assert extract_message(rest) == "alice add order table"
assert rest == ["--head", "head"]
def test_parser_exposes_update_instead_of_sync() -> None:
parser = build_parser()
args = parser.parse_args(["update", "framework", "--tag", "v0.2.5"])
assert args.command == "update"
assert args.target == "framework"
with pytest.raises(SystemExit):
parser.parse_args(["sync", "flask"])
with pytest.raises(SystemExit):
parser.parse_args(["update", "flask"])
def test_parser_accepts_create_database_dialect() -> None:
parser = build_parser()
args = parser.parse_args(["create", "--database", "postgresql", "demo"])
assert args.command == "create"
assert args.database == "postgresql"
def test_parser_accepts_worker_command() -> None:
parser = build_parser()
args = parser.parse_args(["worker", "mq", "--", "--config", "dev"])
assert args.command == "worker"
assert args.name == "mq"
assert args.args == ["--", "--config", "dev"]
def test_normalize_worker_name_accepts_dash_alias() -> None:
assert normalize_worker_name("sync-order") == "sync_order"
def test_normalize_worker_name_rejects_invalid_name() -> None:
with pytest.raises(CliError):
normalize_worker_name("../mq")
def test_resolve_worker_module_prefers_module_worker(tmp_path: Path) -> None:
write(tmp_path / "app" / "modules" / "mq" / "worker.py", "")
ctx = ProjectContext(tmp_path, "business", {}, False)
assert resolve_worker_module(ctx, "mq") == "app.modules.mq.worker"
def test_resolve_worker_module_accepts_app_workers(tmp_path: Path) -> None:
write(tmp_path / "app" / "workers" / "sync_order.py", "")
ctx = ProjectContext(tmp_path, "business", {}, False)
assert resolve_worker_module(ctx, "sync_order") == "app.workers.sync_order"
def test_version_helpers() -> None:
assert normalize_version("v1.2.3") == "1.2.3"
assert version_is_newer("1.2.3", "1.2.4") is True
assert version_is_newer("1.2.3", "1.2.3") is False
def test_latest_git_tag_from_refs_uses_highest_semver() -> None:
refs = """
aaaa refs/tags/v0.2.9
bbbb refs/tags/v0.2.10
cccc refs/tags/v0.2.10^{}
dddd refs/tags/not-a-version
"""
assert latest_git_tag_from_refs(refs) == "v0.2.10"
assert git_tags_from_refs(refs) == ["v0.2.9", "v0.2.10"]
def test_resolve_update_packages_keeps_framework_and_system_independent() -> None:
ctx = ProjectContext(Path("."), "business", {}, True)
assert resolve_update_packages(ctx, "framework", "v0.2.5") == {"iti-flask": "v0.2.5"}
assert resolve_update_packages(ctx, "system", "0.3.2") == {"iti-system": "v0.3.2"}
def test_update_sync_cmd_ignores_transitive_framework_source_for_system_projects() -> None:
ctx = ProjectContext(Path("."), "business", {}, True)
assert update_sync_cmd(ctx, {"iti-flask": "v0.2.5"}) == [
"uv",
"sync",
"--extra",
"dev",
"--no-sources-package",
"iti-flask",
"--upgrade-package",
"iti-flask",
]
def test_update_pyproject_git_tag_rewrites_dependency_and_uv_source(tmp_path: Path) -> None:
write(
tmp_path / "pyproject.toml",
"""
[project]
dependencies = [
"iti-flask[excel] @ git+https://example.test/iTi-Flask.git@241f1d9",
"iti-system @ git+https://example.test/iTi-System.git@v0.2.4",
]
[tool.uv.sources]
iti-flask = { git = "https://example.test/iTi-Flask.git", rev = "241f1d9" }
iti-system = { git = "https://example.test/iTi-System.git", tag = "v0.2.4" }
""",
)
update_pyproject_git_tags(
tmp_path,
{
"iti-flask": "v0.2.5",
"iti-system": "v0.2.6",
},
)
text = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
assert '"iti-flask[excel] @ git+https://example.test/iTi-Flask.git@v0.2.5",' in text
assert '"iti-system @ git+https://example.test/iTi-System.git@v0.2.6",' in text
assert 'iti-flask = { git = "https://example.test/iTi-Flask.git", tag = "v0.2.5" }' in text
assert 'iti-system = { git = "https://example.test/iTi-System.git", tag = "v0.2.6" }' in text
def test_ensure_uv_no_sources_package_inserts_tool_uv_before_sources(tmp_path: Path) -> None:
write(
tmp_path / "pyproject.toml",
"""
[project]
name = "demo"
[tool.uv.sources]
iti-flask = { git = "https://example.test/iTi-Flask.git", tag = "v0.2.5" }
""",
)
ensure_uv_no_sources_package(tmp_path, "iti-flask")
text = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
assert "[tool.uv]\nno-sources-package = [\"iti-flask\"]\n\n[tool.uv.sources]" in text
Loading…
Cancel
Save