isanic-e 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- isanic/__init__.py +19 -0
- isanic/cli.py +103 -0
- isanic/controller.py +13 -0
- isanic/exceptions.py +31 -0
- isanic/handlers.py +39 -0
- isanic/response.py +67 -0
- isanic/routing.py +141 -0
- isanic/templates/full/README.md +32 -0
- isanic/templates/full/_gitignore +9 -0
- isanic/templates/full/app/__init__.py +1 -0
- isanic/templates/full/app/api/__init__.py +1 -0
- isanic/templates/full/app/api/controllers/__init__.py +1 -0
- isanic/templates/full/app/api/controllers/hello.py +9 -0
- isanic/templates/full/app/api/controllers/login.py +28 -0
- isanic/templates/full/app/api/schemas/__init__.py +1 -0
- isanic/templates/full/app/api/schemas/login.py +15 -0
- isanic/templates/full/app/common/__init__.py +1 -0
- isanic/templates/full/app/core/__init__.py +1 -0
- isanic/templates/full/app/core/app.py +17 -0
- isanic/templates/full/app/core/config.py +36 -0
- isanic/templates/full/app/core/logging.py +11 -0
- isanic/templates/full/app/main.py +14 -0
- isanic/templates/full/pyproject.toml +22 -0
- isanic/templates/full/tests/test_framework.py +98 -0
- isanic/validation.py +109 -0
- isanic_e-0.1.0.dist-info/METADATA +83 -0
- isanic_e-0.1.0.dist-info/RECORD +30 -0
- isanic_e-0.1.0.dist-info/WHEEL +5 -0
- isanic_e-0.1.0.dist-info/entry_points.txt +2 -0
- isanic_e-0.1.0.dist-info/top_level.txt +1 -0
isanic/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""iSanic-e 框架公共接口。"""
|
|
2
|
+
|
|
3
|
+
from isanic.controller import ApiController
|
|
4
|
+
from isanic.exceptions import AppError, ValidationAppError
|
|
5
|
+
from isanic.handlers import setup_exception_handlers
|
|
6
|
+
from isanic.response import ApiResponse, fail, success, wrap_response
|
|
7
|
+
from isanic.routing import register_controllers
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ApiController",
|
|
11
|
+
"ApiResponse",
|
|
12
|
+
"AppError",
|
|
13
|
+
"ValidationAppError",
|
|
14
|
+
"fail",
|
|
15
|
+
"register_controllers",
|
|
16
|
+
"setup_exception_handlers",
|
|
17
|
+
"success",
|
|
18
|
+
"wrap_response",
|
|
19
|
+
]
|
isanic/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
TEMPLATE_ROOT = Path(__file__).parent / "templates" / "full"
|
|
8
|
+
PROJECT_NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
"""命令行入口。"""
|
|
13
|
+
parser = _build_parser()
|
|
14
|
+
args = parser.parse_args(argv)
|
|
15
|
+
|
|
16
|
+
if args.command == "init":
|
|
17
|
+
return init_project(args.project_name)
|
|
18
|
+
|
|
19
|
+
parser.print_help()
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def init_project(project_name: str, base_dir: Path | None = None) -> int:
|
|
24
|
+
"""初始化完整示例项目。"""
|
|
25
|
+
if not PROJECT_NAME_PATTERN.fullmatch(project_name):
|
|
26
|
+
print(
|
|
27
|
+
"项目名只能包含字母、数字、短横线和下划线,并且必须以字母开头。",
|
|
28
|
+
file=sys.stderr,
|
|
29
|
+
)
|
|
30
|
+
return 2
|
|
31
|
+
|
|
32
|
+
root_dir = base_dir or Path.cwd()
|
|
33
|
+
target_dir = root_dir / project_name
|
|
34
|
+
if target_dir.exists() and any(target_dir.iterdir()):
|
|
35
|
+
print(f"目标目录已存在且非空:{target_dir}", file=sys.stderr)
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
_copy_template(TEMPLATE_ROOT, target_dir, project_name)
|
|
40
|
+
_print_success(project_name)
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
"""构建命令行参数解析器。"""
|
|
46
|
+
parser = argparse.ArgumentParser(
|
|
47
|
+
prog="isanic",
|
|
48
|
+
description="iSanic-e 项目脚手架",
|
|
49
|
+
)
|
|
50
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
51
|
+
|
|
52
|
+
init_parser = subparsers.add_parser("init", help="初始化一个 Sanic 项目")
|
|
53
|
+
init_parser.add_argument("project_name", help="要创建的项目目录名")
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _copy_template(source_dir: Path, target_dir: Path, project_name: str) -> None:
|
|
58
|
+
"""递归复制模板文件到目标目录。"""
|
|
59
|
+
for source_path in source_dir.rglob("*"):
|
|
60
|
+
if _should_skip_template_path(source_path):
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
relative_path = source_path.relative_to(source_dir)
|
|
64
|
+
target_path = target_dir / _render_text(str(relative_path), project_name)
|
|
65
|
+
|
|
66
|
+
if source_path.is_dir():
|
|
67
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
text = source_path.read_text(encoding="utf-8")
|
|
72
|
+
target_path.write_text(_render_text(text, project_name), encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
gitignore_template = target_dir / "_gitignore"
|
|
75
|
+
if gitignore_template.exists():
|
|
76
|
+
shutil.move(str(gitignore_template), str(target_dir / ".gitignore"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _should_skip_template_path(path: Path) -> bool:
|
|
80
|
+
"""跳过安装后自动生成的缓存文件。"""
|
|
81
|
+
if "__pycache__" in path.parts:
|
|
82
|
+
return True
|
|
83
|
+
return path.suffix in {".pyc", ".pyo"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _render_text(value: str, project_name: str) -> str:
|
|
87
|
+
"""替换模板中的占位符。"""
|
|
88
|
+
return value.replace("{{ project_name }}", project_name)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _print_success(project_name: str) -> None:
|
|
92
|
+
"""输出初始化成功提示。"""
|
|
93
|
+
print(f"项目已创建:{project_name}")
|
|
94
|
+
print("")
|
|
95
|
+
print("下一步:")
|
|
96
|
+
print(f" cd {project_name}")
|
|
97
|
+
print(' python -m pip install -e ".[dev]"')
|
|
98
|
+
print(" python -m pytest")
|
|
99
|
+
print(" python -m app.main")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
raise SystemExit(main())
|
isanic/controller.py
ADDED
isanic/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AppError(Exception):
|
|
5
|
+
"""业务异常基类。"""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
code: int | str = "APP_ERROR",
|
|
12
|
+
status_code: int = 400,
|
|
13
|
+
data: Any = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
self.code = code
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.data = data
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ValidationAppError(AppError):
|
|
23
|
+
"""请求参数校验失败。"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, errors: list[dict[str, Any]]) -> None:
|
|
26
|
+
super().__init__(
|
|
27
|
+
"请求参数校验失败",
|
|
28
|
+
code="VALIDATION_ERROR",
|
|
29
|
+
status_code=422,
|
|
30
|
+
data=errors,
|
|
31
|
+
)
|
isanic/handlers.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from sanic import Sanic
|
|
2
|
+
from sanic.exceptions import MethodNotAllowed, NotFound, SanicException
|
|
3
|
+
|
|
4
|
+
from isanic.exceptions import AppError
|
|
5
|
+
from isanic.response import fail
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def setup_exception_handlers(app: Sanic) -> None:
|
|
9
|
+
"""注册全局异常处理器。"""
|
|
10
|
+
|
|
11
|
+
@app.exception(AppError)
|
|
12
|
+
async def handle_app_error(request, exception: AppError):
|
|
13
|
+
return fail(
|
|
14
|
+
exception.code,
|
|
15
|
+
exception.message,
|
|
16
|
+
exception.data,
|
|
17
|
+
status=exception.status_code,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
@app.exception(NotFound)
|
|
21
|
+
async def handle_not_found(request, exception: NotFound):
|
|
22
|
+
return fail("NOT_FOUND", "接口不存在", status=404)
|
|
23
|
+
|
|
24
|
+
@app.exception(MethodNotAllowed)
|
|
25
|
+
async def handle_method_not_allowed(request, exception: MethodNotAllowed):
|
|
26
|
+
return fail("METHOD_NOT_ALLOWED", "请求方法不允许", status=405)
|
|
27
|
+
|
|
28
|
+
@app.exception(Exception)
|
|
29
|
+
async def handle_unknown_error(request, exception: Exception):
|
|
30
|
+
if isinstance(exception, SanicException):
|
|
31
|
+
status_code = exception.status_code or 500
|
|
32
|
+
return fail(
|
|
33
|
+
"HTTP_ERROR",
|
|
34
|
+
str(exception) or "请求处理失败",
|
|
35
|
+
status=status_code,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
request.app.ctx.logger.exception("未捕获异常", exc_info=exception)
|
|
39
|
+
return fail("INTERNAL_SERVER_ERROR", "服务器内部错误", status=500)
|
isanic/response.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from dataclasses import asdict, is_dataclass
|
|
2
|
+
from typing import Any, Generic, TypeAlias, TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from sanic.response import HTTPResponse, json
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
JsonValue: TypeAlias = (
|
|
10
|
+
dict[str, Any] | list[Any] | str | int | float | bool | None
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiResponse(Generic[T]):
|
|
15
|
+
"""统一响应结构的类型标记。"""
|
|
16
|
+
|
|
17
|
+
code: int | str
|
|
18
|
+
message: str
|
|
19
|
+
data: T | None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def success(data: Any = None, message: str = "success", code: int = 0) -> HTTPResponse:
|
|
23
|
+
"""返回成功响应。"""
|
|
24
|
+
return json(
|
|
25
|
+
{
|
|
26
|
+
"code": code,
|
|
27
|
+
"message": message,
|
|
28
|
+
"data": _to_jsonable(data),
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fail(
|
|
34
|
+
code: int | str,
|
|
35
|
+
message: str,
|
|
36
|
+
data: Any = None,
|
|
37
|
+
status: int = 400,
|
|
38
|
+
) -> HTTPResponse:
|
|
39
|
+
"""返回失败响应。"""
|
|
40
|
+
return json(
|
|
41
|
+
{
|
|
42
|
+
"code": code,
|
|
43
|
+
"message": message,
|
|
44
|
+
"data": _to_jsonable(data),
|
|
45
|
+
},
|
|
46
|
+
status=status,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def wrap_response(value: Any) -> HTTPResponse:
|
|
51
|
+
"""把业务返回值包装为统一响应。"""
|
|
52
|
+
if isinstance(value, HTTPResponse):
|
|
53
|
+
return value
|
|
54
|
+
return success(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _to_jsonable(value: Any) -> Any:
|
|
58
|
+
"""转换常见 Python 对象为可 JSON 序列化的数据。"""
|
|
59
|
+
if isinstance(value, BaseModel):
|
|
60
|
+
return value.model_dump(mode="json")
|
|
61
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
62
|
+
return asdict(value)
|
|
63
|
+
if isinstance(value, dict):
|
|
64
|
+
return {key: _to_jsonable(item) for key, item in value.items()}
|
|
65
|
+
if isinstance(value, list | tuple | set):
|
|
66
|
+
return [_to_jsonable(item) for item in value]
|
|
67
|
+
return value
|
isanic/routing.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import pkgutil
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from types import ModuleType
|
|
7
|
+
from typing import Any, TypeAlias
|
|
8
|
+
|
|
9
|
+
from sanic import Request, Sanic
|
|
10
|
+
from sanic.response import HTTPResponse
|
|
11
|
+
|
|
12
|
+
from isanic.controller import ApiController
|
|
13
|
+
from isanic.response import wrap_response
|
|
14
|
+
from isanic.validation import build_form_model, resolve_form_binding
|
|
15
|
+
|
|
16
|
+
ControllerClass: TypeAlias = type[ApiController]
|
|
17
|
+
HandlerResult: TypeAlias = HTTPResponse | dict[str, Any] | list[Any] | str | int | float | bool | None
|
|
18
|
+
ControllerMethod: TypeAlias = Callable[..., Awaitable[HandlerResult]]
|
|
19
|
+
|
|
20
|
+
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
|
|
21
|
+
CONTROLLER_SUFFIX = "Controller"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register_controllers(app: Sanic, package: str) -> None:
|
|
25
|
+
"""扫描指定包并注册所有控制器路由。"""
|
|
26
|
+
root_module = import_module(package)
|
|
27
|
+
for module in _walk_modules(root_module):
|
|
28
|
+
for controller_class in _iter_controller_classes(module):
|
|
29
|
+
_register_controller(app, controller_class)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _walk_modules(root_module: ModuleType) -> list[ModuleType]:
|
|
33
|
+
"""递归加载包下所有模块。"""
|
|
34
|
+
modules = [root_module]
|
|
35
|
+
if not hasattr(root_module, "__path__"):
|
|
36
|
+
return modules
|
|
37
|
+
|
|
38
|
+
for module_info in pkgutil.walk_packages(
|
|
39
|
+
root_module.__path__,
|
|
40
|
+
prefix=f"{root_module.__name__}.",
|
|
41
|
+
):
|
|
42
|
+
modules.append(import_module(module_info.name))
|
|
43
|
+
return modules
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _iter_controller_classes(module: ModuleType) -> list[ControllerClass]:
|
|
47
|
+
"""查找模块中定义的控制器类。"""
|
|
48
|
+
controllers: list[ControllerClass] = []
|
|
49
|
+
for _, item in inspect.getmembers(module, inspect.isclass):
|
|
50
|
+
if item is ApiController:
|
|
51
|
+
continue
|
|
52
|
+
if item.__module__ != module.__name__:
|
|
53
|
+
continue
|
|
54
|
+
if issubclass(item, ApiController):
|
|
55
|
+
controllers.append(item)
|
|
56
|
+
return controllers
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _register_controller(app: Sanic, controller_class: ControllerClass) -> None:
|
|
60
|
+
"""注册单个控制器上的所有 HTTP 方法。"""
|
|
61
|
+
controller_prefix = _controller_prefix(controller_class)
|
|
62
|
+
for route in _iter_routes(controller_class):
|
|
63
|
+
uri = f"/{controller_prefix}/{route.path}".rstrip("/")
|
|
64
|
+
app.add_route(
|
|
65
|
+
_build_route_handler(controller_class, route.method_name),
|
|
66
|
+
uri,
|
|
67
|
+
methods=[route.http_method],
|
|
68
|
+
name=f"{controller_class.__name__}.{route.method_name}",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RouteInfo:
|
|
73
|
+
"""控制器方法对应的路由信息。"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, http_method: str, method_name: str, path: str) -> None:
|
|
76
|
+
self.http_method = http_method
|
|
77
|
+
self.method_name = method_name
|
|
78
|
+
self.path = path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _iter_routes(controller_class: ControllerClass) -> list[RouteInfo]:
|
|
82
|
+
"""解析控制器类中的路由方法。"""
|
|
83
|
+
routes: list[RouteInfo] = []
|
|
84
|
+
for name, member in inspect.getmembers(controller_class, inspect.isfunction):
|
|
85
|
+
http_method, path = _parse_route_method(name)
|
|
86
|
+
if http_method is None or path is None:
|
|
87
|
+
continue
|
|
88
|
+
routes.append(RouteInfo(http_method, name, path))
|
|
89
|
+
return routes
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_route_method(name: str) -> tuple[str | None, str | None]:
|
|
93
|
+
"""把方法名解析为 HTTP 方法和路径。"""
|
|
94
|
+
parts = name.split("_", 1)
|
|
95
|
+
method = parts[0].lower()
|
|
96
|
+
if method not in HTTP_METHODS:
|
|
97
|
+
return None, None
|
|
98
|
+
|
|
99
|
+
action = parts[1] if len(parts) > 1 else "index"
|
|
100
|
+
return method.upper(), _to_kebab_case(action)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_route_handler(
|
|
104
|
+
controller_class: ControllerClass,
|
|
105
|
+
method_name: str,
|
|
106
|
+
) -> Callable[[Request], Awaitable[HTTPResponse]]:
|
|
107
|
+
"""创建 Sanic 可识别的路由处理函数。"""
|
|
108
|
+
controller_method = getattr(controller_class, method_name)
|
|
109
|
+
form_binding = resolve_form_binding(controller_method)
|
|
110
|
+
|
|
111
|
+
async def route_handler(request: Request) -> HTTPResponse:
|
|
112
|
+
controller = controller_class(request)
|
|
113
|
+
method = getattr(controller, method_name)
|
|
114
|
+
if form_binding is None:
|
|
115
|
+
result = await method()
|
|
116
|
+
else:
|
|
117
|
+
form = build_form_model(request, form_binding)
|
|
118
|
+
result = await method(form)
|
|
119
|
+
return wrap_response(result)
|
|
120
|
+
|
|
121
|
+
route_handler.__name__ = f"{controller_class.__name__}_{method_name}"
|
|
122
|
+
return route_handler
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _controller_prefix(controller_class: ControllerClass) -> str:
|
|
126
|
+
"""生成控制器路径前缀。"""
|
|
127
|
+
if controller_class.prefix:
|
|
128
|
+
return controller_class.prefix.strip("/")
|
|
129
|
+
|
|
130
|
+
name = controller_class.__name__
|
|
131
|
+
if name.endswith(CONTROLLER_SUFFIX):
|
|
132
|
+
name = name[: -len(CONTROLLER_SUFFIX)]
|
|
133
|
+
return _to_kebab_case(name)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _to_kebab_case(value: str) -> str:
|
|
137
|
+
"""把类名或方法名片段转换为短横线命名。"""
|
|
138
|
+
value = value.replace("_", "-")
|
|
139
|
+
value = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value)
|
|
140
|
+
value = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "-", value)
|
|
141
|
+
return value.lower()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# {{ project_name }}
|
|
2
|
+
|
|
3
|
+
这是通过 `isanic init` 生成的 Sanic 后端项目。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
安装依赖:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python -m pip install -e ".[dev]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
运行测试:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python -m pytest
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
启动服务:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m app.main
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
示例接口:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
GET /hello/test
|
|
29
|
+
POST /login/submit
|
|
30
|
+
GET /users/list?page=1&size=10
|
|
31
|
+
GET /users/raw
|
|
32
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""应用包。"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""业务接口包。"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""控制器包。"""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from sanic.response import text
|
|
2
|
+
|
|
3
|
+
from app.api.schemas.login import LoginForm, UserQueryForm
|
|
4
|
+
from isanic import ApiController, AppError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Login(ApiController):
|
|
8
|
+
"""登录控制器。"""
|
|
9
|
+
|
|
10
|
+
async def post_submit(self, form: LoginForm) -> dict[str, str]:
|
|
11
|
+
"""提交登录表单。"""
|
|
12
|
+
if form.username == "disabled":
|
|
13
|
+
raise AppError("用户已被禁用", code="USER_DISABLED", status_code=403)
|
|
14
|
+
return {"token": f"token-for-{form.username}"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UserProfile(ApiController):
|
|
18
|
+
"""用户资料控制器。"""
|
|
19
|
+
|
|
20
|
+
prefix = "users"
|
|
21
|
+
|
|
22
|
+
async def get_list(self, form: UserQueryForm) -> dict[str, int]:
|
|
23
|
+
"""查询用户列表。"""
|
|
24
|
+
return {"page": form.page, "size": form.size}
|
|
25
|
+
|
|
26
|
+
async def get_raw(self):
|
|
27
|
+
"""直接返回 Sanic 原生响应。"""
|
|
28
|
+
return text("raw response")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""请求和响应模型包。"""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LoginForm(BaseModel):
|
|
5
|
+
"""登录表单。"""
|
|
6
|
+
|
|
7
|
+
username: str = Field(min_length=3, max_length=32)
|
|
8
|
+
password: str = Field(min_length=6, max_length=128)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UserQueryForm(BaseModel):
|
|
12
|
+
"""用户列表查询表单。"""
|
|
13
|
+
|
|
14
|
+
page: int = Field(default=1, ge=1)
|
|
15
|
+
size: int = Field(default=10, ge=1, le=100)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""通用工具包。"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""核心配置和应用工厂包。"""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from sanic import Sanic
|
|
2
|
+
|
|
3
|
+
from app.core.config import Settings, load_settings
|
|
4
|
+
from app.core.logging import configure_logging
|
|
5
|
+
from isanic import register_controllers, setup_exception_handlers
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_app(settings: Settings | None = None) -> Sanic:
|
|
9
|
+
"""创建 Sanic 应用实例。"""
|
|
10
|
+
resolved_settings = settings or load_settings()
|
|
11
|
+
app = Sanic(resolved_settings.app_name)
|
|
12
|
+
app.ctx.settings = resolved_settings
|
|
13
|
+
app.ctx.logger = configure_logging(resolved_settings.debug)
|
|
14
|
+
|
|
15
|
+
setup_exception_handlers(app)
|
|
16
|
+
register_controllers(app, resolved_settings.controllers_package)
|
|
17
|
+
return app
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True, slots=True)
|
|
6
|
+
class Settings:
|
|
7
|
+
"""应用运行配置。"""
|
|
8
|
+
|
|
9
|
+
app_name: str = "{{ project_name }}"
|
|
10
|
+
host: str = "127.0.0.1"
|
|
11
|
+
port: int = 8000
|
|
12
|
+
debug: bool = False
|
|
13
|
+
auto_reload: bool = False
|
|
14
|
+
controllers_package: str = "app.api.controllers"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_settings() -> Settings:
|
|
18
|
+
"""从环境变量加载配置。"""
|
|
19
|
+
return Settings(
|
|
20
|
+
app_name=os.getenv("APP_NAME", "{{ project_name }}"),
|
|
21
|
+
host=os.getenv("APP_HOST", "127.0.0.1"),
|
|
22
|
+
port=int(os.getenv("APP_PORT", "8000")),
|
|
23
|
+
debug=_to_bool(os.getenv("APP_DEBUG"), default=False),
|
|
24
|
+
auto_reload=_to_bool(os.getenv("APP_AUTO_RELOAD"), default=False),
|
|
25
|
+
controllers_package=os.getenv(
|
|
26
|
+
"APP_CONTROLLERS_PACKAGE",
|
|
27
|
+
"app.api.controllers",
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _to_bool(value: str | None, *, default: bool) -> bool:
|
|
33
|
+
"""把环境变量字符串转换为布尔值。"""
|
|
34
|
+
if value is None:
|
|
35
|
+
return default
|
|
36
|
+
return value.lower() in {"1", "true", "yes", "on"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def configure_logging(debug: bool) -> logging.Logger:
|
|
5
|
+
"""配置应用日志。"""
|
|
6
|
+
level = logging.DEBUG if debug else logging.INFO
|
|
7
|
+
logging.basicConfig(
|
|
8
|
+
level=level,
|
|
9
|
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
10
|
+
)
|
|
11
|
+
return logging.getLogger("{{ project_name }}")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from app.core.app import create_app
|
|
2
|
+
from app.core.config import load_settings
|
|
3
|
+
|
|
4
|
+
settings = load_settings()
|
|
5
|
+
app = create_app(settings)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
if __name__ == "__main__":
|
|
9
|
+
app.run(
|
|
10
|
+
host=settings.host,
|
|
11
|
+
port=settings.port,
|
|
12
|
+
debug=settings.debug,
|
|
13
|
+
auto_reload=settings.auto_reload,
|
|
14
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "{{ project_name }}"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "基于 iSanic-e 生成的 Sanic 后端项目"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14,<3.15"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"isanic-e>=0.1.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
dev = [
|
|
13
|
+
"pytest>=8,<10",
|
|
14
|
+
"sanic-testing>=24,<26",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[tool.pytest.ini_options]
|
|
18
|
+
testpaths = ["tests"]
|
|
19
|
+
pythonpath = ["."]
|
|
20
|
+
filterwarnings = [
|
|
21
|
+
"ignore::DeprecationWarning",
|
|
22
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from sanic import Sanic
|
|
2
|
+
|
|
3
|
+
from app.core.app import create_app
|
|
4
|
+
from app.core.config import Settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_app() -> Sanic:
|
|
8
|
+
"""创建测试应用。"""
|
|
9
|
+
return create_app(Settings(app_name="{{ project_name }}Test"))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_hello_route_is_registered() -> None:
|
|
13
|
+
"""测试控制器方法自动注册为路由。"""
|
|
14
|
+
_, response = make_app().test_client.get("/hello/test")
|
|
15
|
+
|
|
16
|
+
assert response.status == 200
|
|
17
|
+
assert response.json == {
|
|
18
|
+
"code": 0,
|
|
19
|
+
"message": "success",
|
|
20
|
+
"data": {"message": "hello"},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_json_body_is_validated_and_injected() -> None:
|
|
25
|
+
"""测试 JSON 请求体会被校验并注入表单对象。"""
|
|
26
|
+
_, response = make_app().test_client.post(
|
|
27
|
+
"/login/submit",
|
|
28
|
+
json={"username": "alice", "password": "secret1"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
assert response.status == 200
|
|
32
|
+
assert response.json["data"] == {"token": "token-for-alice"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_form_data_is_validated_and_injected() -> None:
|
|
36
|
+
"""测试 form-data 会被校验并注入表单对象。"""
|
|
37
|
+
_, response = make_app().test_client.post(
|
|
38
|
+
"/login/submit",
|
|
39
|
+
data={"username": "bob", "password": "secret2"},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
assert response.status == 200
|
|
43
|
+
assert response.json["data"] == {"token": "token-for-bob"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_query_string_is_validated_and_injected() -> None:
|
|
47
|
+
"""测试查询字符串会被校验并注入表单对象。"""
|
|
48
|
+
_, response = make_app().test_client.get("/users/list?page=2&size=20")
|
|
49
|
+
|
|
50
|
+
assert response.status == 200
|
|
51
|
+
assert response.json["data"] == {"page": 2, "size": 20}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_validation_error_returns_422() -> None:
|
|
55
|
+
"""测试表单校验失败会返回统一 422 响应。"""
|
|
56
|
+
_, response = make_app().test_client.post(
|
|
57
|
+
"/login/submit",
|
|
58
|
+
json={"username": "a", "password": "bad"},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
assert response.status == 422
|
|
62
|
+
assert response.json["code"] == "VALIDATION_ERROR"
|
|
63
|
+
assert response.json["message"] == "请求参数校验失败"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_app_error_returns_unified_response() -> None:
|
|
67
|
+
"""测试业务异常会返回统一错误响应。"""
|
|
68
|
+
_, response = make_app().test_client.post(
|
|
69
|
+
"/login/submit",
|
|
70
|
+
json={"username": "disabled", "password": "secret1"},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert response.status == 403
|
|
74
|
+
assert response.json == {
|
|
75
|
+
"code": "USER_DISABLED",
|
|
76
|
+
"message": "用户已被禁用",
|
|
77
|
+
"data": None,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_http_response_is_not_wrapped_twice() -> None:
|
|
82
|
+
"""测试 Sanic 原生响应不会被二次包装。"""
|
|
83
|
+
_, response = make_app().test_client.get("/users/raw")
|
|
84
|
+
|
|
85
|
+
assert response.status == 200
|
|
86
|
+
assert response.text == "raw response"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_not_found_returns_unified_response() -> None:
|
|
90
|
+
"""测试 404 会返回统一响应结构。"""
|
|
91
|
+
_, response = make_app().test_client.get("/missing")
|
|
92
|
+
|
|
93
|
+
assert response.status == 404
|
|
94
|
+
assert response.json == {
|
|
95
|
+
"code": "NOT_FOUND",
|
|
96
|
+
"message": "接口不存在",
|
|
97
|
+
"data": None,
|
|
98
|
+
}
|
isanic/validation.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import Any, get_type_hints
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ValidationError
|
|
6
|
+
from sanic import Request
|
|
7
|
+
|
|
8
|
+
from isanic.exceptions import ValidationAppError
|
|
9
|
+
|
|
10
|
+
BODY_METHODS = {"POST", "PUT", "PATCH"}
|
|
11
|
+
QUERY_METHODS = {"GET", "DELETE"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationErrorResponse(BaseModel):
|
|
15
|
+
"""参数校验错误响应数据。"""
|
|
16
|
+
|
|
17
|
+
field: str
|
|
18
|
+
message: str
|
|
19
|
+
type: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FormBinding(BaseModel):
|
|
23
|
+
"""控制器方法的表单绑定信息。"""
|
|
24
|
+
|
|
25
|
+
parameter_name: str
|
|
26
|
+
model_class: type[BaseModel]
|
|
27
|
+
|
|
28
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_form_binding(handler: Callable[..., Any]) -> FormBinding | None:
|
|
32
|
+
"""从控制器方法签名中解析唯一表单参数。"""
|
|
33
|
+
signature = inspect.signature(handler)
|
|
34
|
+
type_hints = get_type_hints(handler)
|
|
35
|
+
bindings: list[FormBinding] = []
|
|
36
|
+
|
|
37
|
+
for name, parameter in signature.parameters.items():
|
|
38
|
+
if name == "self":
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
annotation = type_hints.get(name, parameter.annotation)
|
|
42
|
+
if _is_pydantic_model(annotation):
|
|
43
|
+
bindings.append(
|
|
44
|
+
FormBinding(parameter_name=name, model_class=annotation)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if len(bindings) > 1:
|
|
48
|
+
raise TypeError(f"{handler.__qualname__} 只允许声明一个表单模型参数")
|
|
49
|
+
|
|
50
|
+
return bindings[0] if bindings else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_form_model(request: Request, binding: FormBinding) -> BaseModel:
|
|
54
|
+
"""按 HTTP 方法提取请求数据并构建表单模型。"""
|
|
55
|
+
payload = _extract_payload(request)
|
|
56
|
+
try:
|
|
57
|
+
return binding.model_class.model_validate(payload)
|
|
58
|
+
except ValidationError as exception:
|
|
59
|
+
raise ValidationAppError(_format_validation_errors(exception)) from exception
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_payload(request: Request) -> dict[str, Any]:
|
|
63
|
+
"""从请求中提取待校验数据。"""
|
|
64
|
+
method = request.method.upper()
|
|
65
|
+
if method in QUERY_METHODS:
|
|
66
|
+
return _mapping_to_dict(request.args)
|
|
67
|
+
if method in BODY_METHODS:
|
|
68
|
+
if _is_json_request(request):
|
|
69
|
+
json_payload = request.json
|
|
70
|
+
if isinstance(json_payload, dict):
|
|
71
|
+
return json_payload
|
|
72
|
+
return _mapping_to_dict(request.form)
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _mapping_to_dict(mapping: Any) -> dict[str, Any]:
|
|
77
|
+
"""把 Sanic 的参数容器转换为普通字典。"""
|
|
78
|
+
data: dict[str, Any] = {}
|
|
79
|
+
for key, value in mapping.items():
|
|
80
|
+
if isinstance(value, list) and len(value) == 1:
|
|
81
|
+
data[key] = value[0]
|
|
82
|
+
else:
|
|
83
|
+
data[key] = value
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _format_validation_errors(error: ValidationError) -> list[dict[str, Any]]:
|
|
88
|
+
"""格式化 Pydantic 校验错误,避免泄露内部对象。"""
|
|
89
|
+
errors: list[dict[str, Any]] = []
|
|
90
|
+
for item in error.errors():
|
|
91
|
+
errors.append(
|
|
92
|
+
ValidationErrorResponse(
|
|
93
|
+
field=".".join(str(part) for part in item["loc"]),
|
|
94
|
+
message=item["msg"],
|
|
95
|
+
type=item["type"],
|
|
96
|
+
).model_dump(mode="json")
|
|
97
|
+
)
|
|
98
|
+
return errors
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_json_request(request: Request) -> bool:
|
|
102
|
+
"""判断请求体是否声明为 JSON。"""
|
|
103
|
+
content_type = request.headers.get("content-type", "")
|
|
104
|
+
return "application/json" in content_type or content_type.endswith("+json")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_pydantic_model(annotation: Any) -> bool:
|
|
108
|
+
"""判断注解是否为 Pydantic 模型类型。"""
|
|
109
|
+
return inspect.isclass(annotation) and issubclass(annotation, BaseModel)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: isanic-e
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 基于 Sanic 的类型化表单验证后端脚手架
|
|
5
|
+
Author: iSanic-e contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: sanic,scaffold,backend,pydantic,api
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: <3.15,>=3.14
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: sanic<26,>=25.12
|
|
15
|
+
Requires-Dist: pydantic<3,>=2.12
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: build<2,>=1.3; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest<10,>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: sanic-testing<26,>=24; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# iSanic-e
|
|
22
|
+
|
|
23
|
+
基于 Sanic 的类型化表单验证后端脚手架。
|
|
24
|
+
|
|
25
|
+
安装后可以通过 `isanic init` 快速生成一个完整可运行的 Sanic 项目,内置:
|
|
26
|
+
|
|
27
|
+
- 控制器自动路由注册
|
|
28
|
+
- Pydantic v2 表单验证
|
|
29
|
+
- 统一成功和错误响应
|
|
30
|
+
- 全局异常处理
|
|
31
|
+
- 示例控制器、表单模型和测试
|
|
32
|
+
|
|
33
|
+
## 安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -m pip install isanic-e
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 初始化项目
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
isanic init demo
|
|
43
|
+
cd demo
|
|
44
|
+
python -m pip install -e ".[dev]"
|
|
45
|
+
python -m pytest
|
|
46
|
+
python -m app.main
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 控制器示例
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from app.api.schemas.login import LoginForm
|
|
53
|
+
from isanic import ApiController
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Login(ApiController):
|
|
57
|
+
async def post_submit(self, form: LoginForm) -> dict[str, str]:
|
|
58
|
+
return {"token": f"token-for-{form.username}"}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
上面的代码会自动注册为:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
POST /login/submit
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 业务异常
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from isanic import AppError
|
|
71
|
+
|
|
72
|
+
raise AppError("用户已被禁用", code="USER_DISABLED", status_code=403)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
返回:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"code": "USER_DISABLED",
|
|
80
|
+
"message": "用户已被禁用",
|
|
81
|
+
"data": null
|
|
82
|
+
}
|
|
83
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
isanic/__init__.py,sha256=_KKUYCSyWSjvQg6pyQNQczqpY34L6TdG1ZRwj54ve4I,517
|
|
2
|
+
isanic/cli.py,sha256=2VTLBZf_r6Z0hMF8nIUMhRMFbOgsxhpRqTFtVlnt6qo,3290
|
|
3
|
+
isanic/controller.py,sha256=ZwsJAzi7F5Xl5vaYtGuEQ1_pVurZJIdXEMY22FBi3SE,251
|
|
4
|
+
isanic/exceptions.py,sha256=zNIel7zo4jdkNFmumnZek_Zh24cVKMWfimqNbCrrO2U,722
|
|
5
|
+
isanic/handlers.py,sha256=wtZn7HIJzjUnO-3nbG2UfWBj0Zm6wnewynzOSTpSo9A,1393
|
|
6
|
+
isanic/response.py,sha256=WdwEsIa7wL-13H_aQb9SEpTJ2lXMJ4xk_1miGjJbLP4,1688
|
|
7
|
+
isanic/routing.py,sha256=8mPtcpcezdNbrTDXKXR9IuQDXUXCnBF6vGbDzh6HRPY,4953
|
|
8
|
+
isanic/validation.py,sha256=lW7UXMEeJakQt4Np5szJpuYSylGNlnpMRJkIx34r6jc,3477
|
|
9
|
+
isanic/templates/full/README.md,sha256=PlPtrqRQPqCY4aSOFs54FSxEaSisH5nxml_Q4yer0T8,372
|
|
10
|
+
isanic/templates/full/_gitignore,sha256=VEpZj8AVS1Jj7L4t5fMvDoCcRty0xtaLwRw8z_BLR50,96
|
|
11
|
+
isanic/templates/full/pyproject.toml,sha256=xqguWRTtp9BT9TXxf5K_k2B7EbD_o_6XfghIqTXRP7w,429
|
|
12
|
+
isanic/templates/full/app/__init__.py,sha256=Yaz3I3KhEaNU8pe3CaxbGGE8muahrBdEyjM_-f3IAMU,19
|
|
13
|
+
isanic/templates/full/app/main.py,sha256=oQt1YZBANzDIPFBMJZigzkQo2kk3DLFqC_8Vvg_C-8g,309
|
|
14
|
+
isanic/templates/full/app/api/__init__.py,sha256=GCsz5xYzX_C-LGmtCb7rDyf9mZ2bYs7GOFz_i2Q72OQ,25
|
|
15
|
+
isanic/templates/full/app/api/controllers/__init__.py,sha256=jjmBoZ_iVbx0qvzTXAUmsTJkOSrlgsPgTihUZNc_8Vs,22
|
|
16
|
+
isanic/templates/full/app/api/controllers/hello.py,sha256=Tcu7ASWnOcnRMxoQcKp7npjy6VNMryPiXLTxoBjFWy0,207
|
|
17
|
+
isanic/templates/full/app/api/controllers/login.py,sha256=ogDqRDoMZbukzslnQtLuhtUmRx4U9W84BpVjUksfpBQ,850
|
|
18
|
+
isanic/templates/full/app/api/schemas/__init__.py,sha256=dZZXYoGlODR6RPfUIKBvM62I_09fMQV2PYU8-WAffrs,34
|
|
19
|
+
isanic/templates/full/app/api/schemas/login.py,sha256=KboH3nkTGGvK5IP8OIO200zM8p6ScYXfcLJqemTLQJk,366
|
|
20
|
+
isanic/templates/full/app/common/__init__.py,sha256=3fOMnVI76p00PrhurOlJMhHjYm_ACnAPf6Uqmcf0X18,25
|
|
21
|
+
isanic/templates/full/app/core/__init__.py,sha256=MXnmEcYKBE7S2YhZNRfssuhKx3eoXjhrAst-P5c2syA,40
|
|
22
|
+
isanic/templates/full/app/core/app.py,sha256=1u9ofJsUSosHB_rrLYVQFF1iV7XIWr6IE1wOZk9pOOk,610
|
|
23
|
+
isanic/templates/full/app/core/config.py,sha256=q3_9ylkCoCrvpI05KpVWDrfR1qwBcc3C2VcPoi8noao,1070
|
|
24
|
+
isanic/templates/full/app/core/logging.py,sha256=SOmJffHfdUYawXStdQxVl55W1BFV0aTNA1HqT2PCNv8,326
|
|
25
|
+
isanic/templates/full/tests/test_framework.py,sha256=EiHxEN5nMcKAFC7BByTepdIze-C2HQMDo1Fnn7pnH9o,2966
|
|
26
|
+
isanic_e-0.1.0.dist-info/METADATA,sha256=k0DQ7UauMvS9e3LUQdo7E5lvO-HvjqeBKU9HV_tS1ls,1841
|
|
27
|
+
isanic_e-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
28
|
+
isanic_e-0.1.0.dist-info/entry_points.txt,sha256=B9pd2m2FJRvpHj07RKmH3trQ4y4bEyDNw7Je8BwjP_0,43
|
|
29
|
+
isanic_e-0.1.0.dist-info/top_level.txt,sha256=GIj82zBo7gefv-Q1mwCGtEGOoO7QXnNVAWiM7G-8SNs,7
|
|
30
|
+
isanic_e-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
isanic
|