main
NoahLan 1 week ago
commit b8551a2d12

12
.gitignore vendored

@ -0,0 +1,12 @@
.venv/
.pytest_cache/
*.egg-info/
__pycache__/
*.py[cod]
dist/
build/
.coverage
htmlcov/
.env
.env.*
!.env.example

@ -0,0 +1,120 @@
# iti-flask-cli
`iticli` 是 iTi-Flask 框架项目和模板生成业务项目共用的命令行工具。
它是独立工具。
框架项目和新生成的业务项目都不需要内置 `.sh` / `.cmd` 脚本。
## 安装
私有 Git 安装:
```bash
uv tool install git+https://git.noahlan.cn/iti-framework/iti-flask-cli.git
```
或:
```bash
pipx install git+https://git.noahlan.cn/iti-framework/iti-flask-cli.git
```
本地开发安装:
```bash
cd iti-flask-cli
uv tool install -e .
```
## 项目识别
`iticli` 会从当前目录向上查找 `pyproject.toml`,并识别两类项目:
- 框架项目:`project.name = "iti-flask"`,且包含 `iti/app.py`、`copier.yml`。
- 业务项目:包含 `main.py`、`app/`、`migrations/alembic.ini`,且依赖 `iti-flask`
`create` 不要求当前目录是项目目录。
默认从私有 Git 里的 iTi-Flask Copier 模板生成业务项目。
## 常用命令
```bash
iticli help
iticli install
iticli install dev
iticli install odbc
iticli test
iticli run dev 8000
iticli serve dev 8000
iticli run prod 8000
iticli migrate
iticli migrate heads
iticli migrate current
iticli migrate revision "alice add order table"
```
业务项目:
```bash
iticli sync flask
iticli sync system
iticli sync all
iticli template check
iticli template update
iticli init
iticli init system
iticli docker build
iticli docker up
iticli docker up --db
iticli docker logs
iticli docker down
```
框架项目:
```bash
iticli release
iticli release v0.2.5
```
任意目录生成业务项目:
```bash
iticli create my-business-app
iticli create --with-system my-system-app
```
使用当前框架工作区作为模板源:
```bash
cd iTi-Flask
iticli create --local ../my-business-app
```
## 旧项目迁移
旧仓库或旧业务项目里如果还保留脚本,可以按下面迁移:
| 旧命令 | 新命令 |
| --- | --- |
| `./iti.sh install` / `./app.sh install` | `iticli install` |
| `./iti.sh test` / `./app.sh test` | `iticli test` |
| `./iti.sh serve 8000` / `./app.sh serve dev 8000` | `iticli run dev 8000``iticli serve dev 8000` |
| `./iti.sh migrate` / `./app.sh migrate` | `iticli migrate` |
| `./iti.sh heads` / `./app.sh heads` | `iticli migrate heads` |
| `./iti.sh current` / `./app.sh current` | `iticli migrate current` |
| `./iti.sh migration "msg"` / `./app.sh migration "msg"` | `iticli migrate revision "msg"` |
| `./iti.sh make-app ../app app` | `iticli create app` |
| `./iti.sh make-system-app ../app app` | `iticli create --with-system app` |
| `./iti.sh release` | `iticli release` |
| `./app.sh framework-sync` | `iticli sync flask` |
| `./app.sh system-sync` | `iticli sync system` |
| `./app.sh template-check` | `iticli template check` |
| `./app.sh template-update` | `iticli template update` |
| `./app.sh init` | `iticli init` |
| `./app.sh init-system` | `iticli init system` |
| `./app.sh docker-up` | `iticli docker up` |
| `./app.sh docker-up-db` | `iticli docker up --db` |
`install odbc` 会同步开发依赖,并把 `pyodbc` 安装进当前项目虚拟环境。
ODBC 系统驱动仍由操作系统或镜像负责安装。

@ -0,0 +1,5 @@
"""iTi-Flask command line tools."""
__all__ = ["__version__"]
__version__ = "0.1.0"

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

@ -0,0 +1,630 @@
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any, 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)
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("--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)
sync_parser = subparsers.add_parser("sync", help="sync framework or system package")
sync_parser.add_argument("target", choices=["flask", "system", "all"], nargs="?", default="all")
sync_parser.set_defaults(handler=handle_sync)
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_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}
if ctx.kind == "framework":
cmd = ["uv", "run", "uvicorn", "iti.app:create_app", "--factory", "--port", port]
else:
cmd = ["uv", "run", "uvicorn", "main:app", "--port", 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 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.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"})
return release_framework(ctx.root, args.version)
def handle_sync(args: argparse.Namespace) -> int:
ctx = require_project({"business"})
if args.target in {"flask", "all"}:
cmd = ["uv", "sync", "--extra", "dev", "--upgrade-package", "iti-flask"]
if args.target == "all" and ctx.has_system:
cmd.extend(["--upgrade-package", "iti-system"])
code = run(cmd, ctx.root)
if code:
return code
if args.target in {"system", "all"}:
if not ctx.has_system:
raise CliError("current business project does not depend on iti-system")
code = run(["uv", "run", "iti-system", "migrations", "sync", "--target", "migrations/versions"], ctx.root)
if code:
return code
return 0
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()
)
if is_framework:
kind = "framework"
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", "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 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)
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 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 read_current_version(root: Path) -> str:
about_file = root / "iti" / "__about__.py"
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 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())

@ -0,0 +1,28 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "iti-flask-cli"
version = "0.1.0"
description = "Unified CLI for iTi-Flask framework and generated business projects."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [{ name = "NoahLan", email = "6995syu@163.com" }]
keywords = ["iti-flask", "fastapi", "cli"]
dependencies = []
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
]
[project.scripts]
iticli = "iticli.cli:main"
[tool.setuptools.packages.find]
include = ["iticli*"]
[tool.pytest.ini_options]
testpaths = ["tests"]

@ -0,0 +1,125 @@
from __future__ import annotations
from pathlib import Path
from iticli.cli import (
ProjectContext,
alembic_base_cmd,
detect_project,
extract_message,
find_optional_extra,
has_package,
normalize_version,
parse_run_args,
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_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", "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", "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_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_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

@ -0,0 +1,78 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "iti-flask-cli"
version = "0.1.0"
source = { editable = "." }
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }]
provides-extras = ["dev"]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
Loading…
Cancel
Save