Compare commits

...

3 Commits

Author SHA1 Message Date
917232558@qq.com 559aa75048 chore: release v0.4.3 2 weeks ago
917232558@qq.com 9359a96266 fix: 兼容 FastAPI 嵌套路由响应包裹 2 weeks ago
917232558@qq.com 193ab7cf16 fix: iticli 优雅处理 Ctrl+C 退出 2 weeks ago

@ -55,7 +55,7 @@ iticli install
```toml ```toml
dependencies = [ dependencies = [
"iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v0.4.1", "iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v0.4.3",
] ]
``` ```
@ -127,14 +127,14 @@ iticli help
```bash ```bash
iticli release iticli release
iticli release v0.4.1 iticli release v0.4.3
``` ```
Windows: Windows:
```bat ```bat
iticli release iticli release
iticli release v0.4.1 iticli release v0.4.3
``` ```
## 文档 ## 文档

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "{{ project_slug | replace('_', '-') }}" name = "{{ project_slug | replace('_', '-') }}"
version = "0.4.1" version = "0.4.3"
description = "{{ project_name }}" description = "{{ project_name }}"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

@ -26,7 +26,7 @@ framework_git:
framework_tag: framework_tag:
type: str type: str
help: iTi-Flask Git tag help: iTi-Flask Git tag
default: v0.4.1 default: v0.4.3
include_system: include_system:
type: bool type: bool
@ -49,4 +49,4 @@ system_git:
system_tag: system_tag:
type: str type: str
help: iTi-System Git tag help: iTi-System Git tag
default: v0.4.1 default: v0.4.3

@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com> # SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
__version__ = "0.4.1" __version__ = "0.4.3"

@ -471,12 +471,15 @@ def install_auto_envelope(app: FastAPI) -> None:
if not config.response_envelope_enabled: if not config.response_envelope_enabled:
return return
raw_paths = tuple(config.raw_response_paths) raw_paths = tuple(config.raw_response_paths)
for route in app.routes: targets: dict[int, tuple[APIRoute, list[str]]] = {}
if not isinstance(route, APIRoute): for route, paths in _iter_envelope_route_targets(app.routes):
continue targets.setdefault(id(route), (route, []))[1].extend(paths)
wrapped_any = False
for route, paths in targets.values():
if getattr(route, "__iti_envelope_installed__", False): if getattr(route, "__iti_envelope_installed__", False):
continue continue
if _is_route_raw(route, raw_paths): if _is_route_raw(route, raw_paths, paths):
continue continue
original_call = route.dependant.call original_call = route.dependant.call
if original_call is None: if original_call is None:
@ -484,13 +487,53 @@ def install_auto_envelope(app: FastAPI) -> None:
route.endpoint = _wrap_endpoint_with_envelope(original_call) route.endpoint = _wrap_endpoint_with_envelope(original_call)
_rebuild_route_dependant(route) _rebuild_route_dependant(route)
setattr(route, "__iti_envelope_installed__", True) setattr(route, "__iti_envelope_installed__", True)
wrapped_any = True
if wrapped_any:
_invalidate_included_router_caches(app.routes)
def _iter_envelope_route_targets(routes: Iterable[Any]):
for route in routes:
effective_contexts = getattr(route, "effective_route_contexts", None)
if callable(effective_contexts):
for context in effective_contexts():
original_route = getattr(context, "original_route", None)
if isinstance(original_route, APIRoute):
yield original_route, (
getattr(context, "path_format", ""),
getattr(context, "path", ""),
)
continue
if isinstance(route, APIRoute):
yield route, (route.path_format, route.path)
def _invalidate_included_router_caches(routes: Iterable[Any]) -> None:
for route in routes:
if hasattr(route, "_effective_candidates_version"):
route._effective_candidates_version = None
route._effective_low_priority_routes_version = None
route._effective_candidates = []
route._effective_low_priority_routes = []
original_router = getattr(route, "original_router", None)
if original_router is not None:
_invalidate_included_router_caches(getattr(original_router, "routes", ()))
def _is_route_raw(route: APIRoute, raw_paths: Iterable[str]) -> bool:
def _is_route_raw(route: APIRoute, raw_paths: Iterable[str], paths: Iterable[str] = ()) -> bool:
endpoint = route.endpoint endpoint = route.endpoint
if getattr(endpoint, "__iti_raw_response__", False): if getattr(endpoint, "__iti_raw_response__", False):
return True return True
for path in route.path_format, route.path: dependant_call = getattr(getattr(route, "dependant", None), "call", None)
if getattr(dependant_call, "__iti_raw_response__", False):
return True
seen_paths: set[str] = set()
for path in (*paths, route.path_format, route.path):
if not path or path in seen_paths:
continue
seen_paths.add(path)
request = _PathOnlyRequest(path) request = _PathOnlyRequest(path)
if is_raw_response_request(request, raw_paths): if is_raw_response_request(request, raw_paths):
return True return True

@ -560,8 +560,31 @@ def update_self() -> int:
def run(cmd: Sequence[str], cwd: Path, extra_env: dict[str, str] | None = None) -> int: def run(cmd: Sequence[str], cwd: Path, extra_env: dict[str, str] | None = None) -> int:
env = project_env(cwd, extra_env) env = project_env(cwd, extra_env)
completed = subprocess.run(list(cmd), cwd=str(cwd), env=env, check=False) process = subprocess.Popen(list(cmd), cwd=str(cwd), env=env)
return int(completed.returncode) try:
return int(process.wait())
except KeyboardInterrupt:
_wait_after_keyboard_interrupt(process)
return 130
def _wait_after_keyboard_interrupt(process: subprocess.Popen) -> None:
try:
process.wait(timeout=30)
return
except KeyboardInterrupt:
process.terminate()
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=5)
except KeyboardInterrupt:
process.kill()
process.wait()
except subprocess.TimeoutExpired:
process.kill()
process.wait()
def project_env(root: Path, extra_env: dict[str, str] | None = None) -> dict[str, str]: def project_env(root: Path, extra_env: dict[str, str] | None = None) -> dict[str, str]:

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import subprocess
import pytest import pytest
@ -22,6 +23,7 @@ from iticli.cli import (
parse_run_args, parse_run_args,
resolve_worker_module, resolve_worker_module,
resolve_update_packages, resolve_update_packages,
run,
update_sync_cmd, update_sync_cmd,
update_pyproject_git_tags, update_pyproject_git_tags,
version_is_newer, version_is_newer,
@ -33,6 +35,71 @@ def write(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8") path.write_text(text, encoding="utf-8")
def test_run_returns_130_after_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
class DummyProcess:
def __init__(self) -> None:
self.wait_calls: list[int | None] = []
def wait(self, timeout: int | None = None) -> int:
self.wait_calls.append(timeout)
if timeout is None:
raise KeyboardInterrupt
return 0
def terminate(self) -> None:
raise AssertionError("terminate should not be called after graceful child exit")
def kill(self) -> None:
raise AssertionError("kill should not be called after graceful child exit")
process = DummyProcess()
def fake_popen(cmd: list[str], cwd: str, env: dict[str, str]) -> DummyProcess:
assert cmd == ["demo"]
assert cwd == str(tmp_path)
assert env["PYTHONPATH"]
return process
monkeypatch.setattr(subprocess, "Popen", fake_popen)
assert run(["demo"], tmp_path, extra_env={"PYTHONPATH": "."}) == 130
assert process.wait_calls == [None, 30]
def test_run_terminates_child_when_interrupt_shutdown_times_out(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
class DummyProcess:
def __init__(self) -> None:
self.terminated = False
self.killed = False
def wait(self, timeout: int | None = None) -> int:
if timeout is None:
raise KeyboardInterrupt
if timeout == 30:
raise subprocess.TimeoutExpired(["demo"], timeout)
return 0
def terminate(self) -> None:
self.terminated = True
def kill(self) -> None:
self.killed = True
process = DummyProcess()
def fake_popen(cmd: list[str], cwd: str, env: dict[str, str]) -> DummyProcess:
return process
monkeypatch.setattr(subprocess, "Popen", fake_popen)
assert run(["demo"], tmp_path) == 130
assert process.terminated is True
assert process.killed is False
def test_detect_framework_project(tmp_path: Path) -> None: def test_detect_framework_project(tmp_path: Path) -> None:
write( write(
tmp_path / "pyproject.toml", tmp_path / "pyproject.toml",
@ -195,7 +262,7 @@ def test_parser_accepts_worker_command() -> None:
assert args.command == "worker" assert args.command == "worker"
assert args.name == "mq" assert args.name == "mq"
assert args.args == ["--", "--config", "dev"] assert args.args == ["--config", "dev"]
def test_normalize_worker_name_accepts_dash_alias() -> None: def test_normalize_worker_name_accepts_dash_alias() -> None:

Loading…
Cancel
Save