├── .dockerignore
├── .gitignore
├── Dockerfile
├── README.md
├── app
├── __init__.py
└── ws
│ ├── __init__.py
│ ├── apis.py
│ ├── models.py
│ ├── schema.py
│ ├── views.py
│ └── ws.py
├── core
├── config
│ ├── __init__.py
│ ├── base_config.py
│ ├── common.py
│ ├── dev_config.py
│ └── pro_config.py
├── dependencies
│ ├── __init__.py
│ ├── auth.py
│ └── base.py
├── i18n
│ ├── README.md
│ ├── __init__.py
│ ├── babel.cfg
│ ├── en_US
│ │ └── LC_MESSAGES
│ │ │ └── messages.po
│ └── zh_CN
│ │ └── LC_MESSAGES
│ │ └── messages.po
├── manager
│ ├── __init__.py
│ ├── babel_cmd.py
│ ├── base.py
│ └── gen_code.py
├── middleware
│ ├── __init__.py
│ ├── fastapi_globals.py
│ └── session.py
├── schema
│ ├── __init__.py
│ ├── base.py
│ └── paginate.py
├── storage
│ ├── __init__.py
│ ├── db.py
│ └── model.py
└── utils
│ ├── api_exception.py
│ └── enums.py
├── main.py
├── manage.py
└── requirements.txt
/.dockerignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .venv
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 |
32 | # Installer logs
33 | pip-log.txt
34 | pip-delete-this-directory.txt
35 |
36 | # Unit test / coverage reports
37 | htmlcov/
38 | .tox/
39 | .coverage
40 | .coverage.*
41 | .cache
42 | nosetests.xml
43 | coverage.xml
44 | *,cover
45 | .hypothesis/
46 |
47 | # Translations
48 | *.mo
49 | *.pot
50 |
51 | # Django stuff:
52 | *.log
53 | local_settings.py
54 |
55 | # Flask stuff:
56 | #instance/
57 | .webassets-cache
58 |
59 | # Scrapy stuff:
60 | .scrapy
61 |
62 | # Sphinx documentation
63 | docs/_build/
64 |
65 | # PyBuilder
66 | target/
67 |
68 | # IPython Notebook
69 | .ipynb_checkpoints
70 |
71 | # pyenv
72 | .python-version
73 |
74 | # celery beat schedule file
75 | celerybeat-schedule
76 |
77 | # dotenv
78 | .env
79 |
80 | # virtualenv
81 | venv/
82 | ENV/
83 |
84 | # Spyder project settings
85 | .spyderproject
86 |
87 | # Rope project settings
88 | .ropeproject
89 |
90 | .idea
91 | .venv
92 | .vscode
93 | __pycache__
94 | .pytest_cache/v/cache
95 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.8.13
2 |
3 | RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list
4 |
5 | RUN apt-get update && apt-get -y install openssh-server vim apt-utils && rm -rf /var/lib/apt/lists/*
6 |
7 |
8 | WORKDIR /usr/src/app
9 | ENV PYTHONPATH /usr/src/app
10 |
11 |
12 | COPY requirements.txt /usr/src/app/requirements.txt
13 |
14 | RUN pip3 install --upgrade pip -i https://pypi.douban.com/simple
15 | RUN pip3 install -i https://pypi.douban.com/simple -r requirements.txt
16 |
17 | COPY . /usr/src/app
18 |
19 | EXPOSE 8001
20 | ENV TZ=Asia/Shanghai
21 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo '$TZ' > /etc/timezone && rm -rf /root/.cache/pip
22 |
23 | CMD ["python3", "/usr/src/main.py"]
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 简介
2 |
3 | 这是一个`fastapi`的脚手架项目,解释器版本至少是python3.7 +,项目集成了`sqlalchemy2(1.4+)`、`JWT Auth`、`websocket`、`i18n`等常用功能,项目的目录结构也比较简单,也封装了一系列的web开发过程中会用到的工具,欢迎大家给项目提提建议。
4 |
5 | ## 目录结构
6 |
7 | ```shell
8 | .
9 | ├── app # 业务应用目录,下属多个应用模块
10 | │ ├── __init__.py
11 | │ ├── demo # 应用模块demo
12 | │ │ ├── apis.py # 路由定义
13 | │ │ ├── demo.py # 模块工具方法定义
14 | │ │ ├── models.py # ORM model类定义
15 | │ │ ├── schema.py # 序列化类定义
16 | │ │ └── views.py # 视图函数定义
17 | │ └── ws # websocket模块
18 | ├── core # 项目核心
19 | │ ├── config # 项目配置文件
20 | │ ├── dependencies # fastapi依赖定义
21 | │ ├── i18n # 国际化翻译
22 | │ ├── manager # manager工具
23 | │ ├── middleware # ASGI中间件定义
24 | │ ├── schema # 基础schema
25 | │ ├── storage # SQLA相关
26 | │ └── utils # 工具目录
27 | ├── docs # 项目文档
28 | ├── main.py # 入口文件
29 | ├── manage.py # manager工具
30 | ├── Dockerfile # 就是一个DOckerfile
31 | ├── README.md
32 | └── requirements.txt # 依赖文件
33 | ```
34 |
35 | ## 主要功能模块
36 |
37 | ### 1. SQLAlchemy
38 |
39 | `1.4+`的版本,这个版本支持异步IO,但是相应的得使用异步的数据库适配器,这里使用的是`aiomysql`, 同时1.4版本已经支持SQLA的2.0写法,当然也是兼容旧版写法的。[两者区别具体查看文档](https://docs.sqlalchemy.org/en/14/changelog/migration_20.html)
40 |
41 | 与SQLA相关的代码在`/core/storage`下:
42 |
43 | - `model.py`: 定义了ORM表结构映射基础对象`Base`, 集成它来关联db中的表。
44 | - `db.py`: 初始化SQLA,添加一些监听事件。
45 |
46 | 应用代码中引入`session`可以直接从全局对象`g`中获取:
47 |
48 | ```shell
49 | from core.middleware import g
50 | g.db.execute(...)
51 | ```
52 |
53 | 获取`engine`对象
54 |
55 | ```python
56 | from core.storage import db
57 | db.engine.execute(...)
58 | ```
59 |
60 | 逻辑删除,orm对象
61 | ```python
62 | obj.delete()
63 | ```
64 |
65 | ### 2.dependencies
66 |
67 | 项目下所有的fastapi依赖注入函数都在`/core/dependencies`:
68 |
69 | - base_dependen: 基础依赖注入,所有的AP都需要引入这个依赖,用于提取请求头中的`Accept-Language`来设置全局变量`local`, 用于`i18n`的翻译。
70 | - auth_dependen: 基于`Authorization`请求头的JWT认证。
71 |
72 | ### 3. config
73 |
74 | 项目的配置文件目录,分为两种环境配置文件`dev`和`prod`。启动项目时引入哪个配置文件取决于环境变量配置`CONFIG_NAME`,默认是`dev`。
75 |
76 | 已有配置:
77 |
78 | - CONFIG_NAME: 引入配置文件类型
79 | - SERVICE_NAME:项目名称
80 | - TZ:时区
81 | - TOKEN_SECRET_KEY:JWT密钥
82 | - DATABASE_MYSQL_URL:数据库DSN
83 | - SHOW_SQL:是否打开SQLA日志
84 | - RETURN_SQL:是否在接口返回SQL记录
85 | - DB_POOL_RECYCLE:数据库连接释放事件
86 | - DB_MAX_OVERFLOW:连接池缓冲个数
87 | - DB_POOL_SIZE: 连接池大小
88 |
89 | ### 4. schema
90 |
91 | 项目的序列化和反序列化是基于pydantic实现的,业务应用需要继承`BaseSchema`来扩展各自需要的序列化器。提供两种序列化器:
92 |
93 | - DataSchema: 普通序列化器
94 | - PageSchema: 分页序列化器
95 |
96 | 还提供了数据分页器:`paginate_handler`,可以接收`select`对象或迭代对象,返回分页后的数据字典。
97 |
98 | ### 5. i18n
99 |
100 | 创建翻译文件在后面的`manager.py`里会有介绍,如果是已有的翻译文件直接放入`/core/i18n/lang`里。同时,项目里需要使用翻译的地方可以如下使用,具体翻译成何种语言取决于全局变量`g.locale`
101 |
102 | ```python
103 | from core.i18n import gettext
104 | t = gettext("test") # t为翻译后的内容
105 | ```
106 |
107 | ### 6. manager.py
108 |
109 | 项目下有一个类似`django`的项目管理工具,现在提供了两个命令:
110 |
111 | 1. startapp [appname]
112 |
113 | 在`/app`下创建一个标准的应用目录,且实现了一套增删改查的代码
114 |
115 | 2. babel [add|run]
116 |
117 | **add**: 检测代码中的引入了`gettext`的地方,并更新翻译文件`message.po`, 之后需要手动填入翻译内容。
118 |
119 | **run**: 编译message.po。
120 |
121 | ### 7. middleware
122 |
123 | 项目下的`/core/middleware`是所有的ASGI的中间件,目前共有两个中间件
124 |
125 | - `fastapi-globals.py`: `g`对象的实现,基于contextvar
126 | - `session.py`: 注册全局`session`
127 |
128 | #### 8.websocket
129 |
130 | 项目自带了websocket应用,实现了ws连接管理类`ConnectionManager`。目前是将连接信息存储在内存中,第一次连接时需要进行token验证,之后服务端会定时进行心跳检测,所以需要客户端隔一段时间就发送心跳包,否则服务端会主动断开连接。
131 |
132 | token验证格式:
133 |
134 | ```json
135 | {
136 | "Op": "bind",
137 | "SessionID": "token"
138 | }
139 | ```
140 |
141 | 心跳检测包:
142 |
143 | ```json
144 | {
145 | "Op": "heartbeat",
146 | "SessionID": "token"
147 | }
148 | ```
149 |
150 | #### 9.部署
151 |
152 | 下载依赖:`pip3 install -r quirements.txt`
153 | 启动项目:`python main.py`
154 |
--------------------------------------------------------------------------------
/app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/goofy-z/fastapi-web-template/9b842ae0563f9f5d9b8f72849ed3a7fe2b88a4f6/app/__init__.py
--------------------------------------------------------------------------------
/app/ws/__init__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from app.ws.ws import ConnectionManager
4 |
5 | manager = ConnectionManager()
6 |
7 |
8 | async def create_ws_manager():
9 | manager.start()
10 |
--------------------------------------------------------------------------------
/app/ws/apis.py:
--------------------------------------------------------------------------------
1 | from app.ws.schema import SockJSInfoRes
2 | from fastapi.applications import FastAPI
3 | from app.ws.views import broadcast_msg_view, ws_view, test_html_view
4 | from fastapi import APIRouter
5 |
6 |
7 | router = APIRouter(tags=["websocket"])
8 |
9 | router.add_api_websocket_route(
10 | name="websocket连接",
11 | path="/{client_id}/{session}/{transport}",
12 | endpoint=ws_view,
13 | )
14 |
15 |
16 | router.add_api_route(
17 | name="websocket测试",
18 | path="/test.html",
19 | endpoint=test_html_view,
20 | )
21 |
22 |
23 | router.add_api_route(name="群推消息", path="/wss/broadcast_msg", methods=["POST"], endpoint=broadcast_msg_view)
24 |
--------------------------------------------------------------------------------
/app/ws/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 |
3 | from app.models.base import Base
4 | from sqlalchemy import Column, String
5 |
6 |
7 | class Demo(Base):
8 | __tablename__ = "demos"
9 | id = Column(String(36), primary_key=True, default=str(uuid.uuid4))
10 | name = Column(String(128), nullable=False, primary_key=True, unique=True)
11 | description = Column(String(512), nullable=False)
12 |
--------------------------------------------------------------------------------
/app/ws/schema.py:
--------------------------------------------------------------------------------
1 | from typing import List, Optional
2 |
3 | from pydantic.fields import Field
4 | from core.schema import BaseSchema
5 |
6 |
7 | class SockJSInfoRes(BaseSchema):
8 | cookie_needed: bool = False
9 | entropy: int
10 | origins: List[str] = ["*:*"]
11 | websocket: bool = True
12 |
13 |
14 | class BroadcastMsgReq(BaseSchema):
15 | msg: str = Field(description="推送消息内容")
16 |
--------------------------------------------------------------------------------
/app/ws/views.py:
--------------------------------------------------------------------------------
1 | import random
2 |
3 | from typing import Optional
4 | from fastapi import WebSocket
5 | from starlette.responses import HTMLResponse
6 | from starlette.websockets import WebSocketDisconnect
7 | from app.ws.schema import BroadcastMsgReq
8 |
9 | from app.ws import manager
10 | from app.ws.ws import html
11 |
12 |
13 | async def ws_view(
14 | websocket: WebSocket, client_id: int, session: Optional[str] = "qeqe", transport: Optional[str] = "websocket"
15 | ):
16 | """
17 | websocket服务
18 | """
19 | res = await manager.connect_with_token(websocket)
20 | if not res:
21 | return
22 | try:
23 | while True:
24 | await manager.receive_json(websocket)
25 | except WebSocketDisconnect:
26 | manager.remove_session(websocket)
27 |
28 |
29 | async def test_html_view():
30 | """
31 | 测试html
32 | """
33 | return HTMLResponse(html)
34 |
35 |
36 | async def sockjs_info_view(t: Optional[int]):
37 | """
38 | 伪造sockjs的info接口
39 | """
40 | data = {
41 | "entropy": random.randint(1, 2147483647),
42 | }
43 | return data
44 |
45 |
46 | async def broadcast_msg_view(req: BroadcastMsgReq):
47 | """
48 | 广播消息
49 | """
50 | msg = req.msg
51 | await manager.broadcast(req.msg)
52 | return "success"
53 |
--------------------------------------------------------------------------------
/app/ws/ws.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 |
4 | import jwt
5 | import asyncio
6 | from typing import Dict, List
7 | from datetime import datetime, timedelta
8 |
9 | from fastapi import FastAPI, WebSocket, WebSocketDisconnect
10 |
11 | app = FastAPI(debug=True)
12 |
13 | LOG = logging.getLogger(__name__)
14 |
15 |
16 | class ConnectionManager:
17 | _hb_handle = None # heartbeat event loop timer
18 | _hb_task = None # gc task
19 |
20 | def __init__(self):
21 | self.active_connections: Dict[List[WebSocket]] = []
22 | self.heartbeat = 10
23 |
24 | async def connect(self, session: WebSocket):
25 | await session.accept()
26 | self.active_connections.append(session)
27 |
28 | async def connect_with_token(self, session: WebSocket):
29 | """
30 | 需要token创建ws连接
31 | """
32 | await session.accept()
33 | # 恢复一条消息代表接收到请求
34 | await self.send_personal_message("o", session)
35 |
36 | # 初始化连接时加上心跳检查时间
37 | self.flush_session_expire_time(session)
38 |
39 | # if self._hb_task:
40 | # print(self._hb_task.result())
41 | self.active_connections.append(session)
42 |
43 | # 第一次消息必须发送token过来
44 | try:
45 | token = await session.receive_json()
46 | if isinstance(token, list):
47 | token = json.loads(token[0])
48 | elif not isinstance(token, dict):
49 | token = json.loads(token)
50 | except WebSocketDisconnect:
51 | return
52 | except Exception as e:
53 | await self.send_personal_message(f"token error", session)
54 | await self.disconnect(session)
55 | return False
56 |
57 | if token.get("Op") == "bind":
58 | try:
59 | sessionID = token.get("SessionID")
60 | payload = jwt.decode(
61 | sessionID.encode("utf-8"), "31e37738-559a-47f3-8b16-5fa27a2ed410", algorithms="HS256"
62 | )
63 | user_id = payload.get("user_id")
64 | setattr(session, "user_id", user_id)
65 | # 再次刷新失效时间
66 | self.flush_session_expire_time(session)
67 | # 发送一条消息回应token验证成功
68 | await self.send_personal_message('a[{"result":"success"}]', session)
69 | return True
70 | except:
71 | await self.send_personal_message(f"You token is inviled", session)
72 | await self.disconnect(session)
73 | else:
74 | await self.send_personal_message(f"need token", session)
75 | await self.disconnect(session)
76 |
77 | async def receive_json(self, session: WebSocket):
78 | """
79 | 获取json格式的websocket数据
80 | """
81 | res = await session.receive_json()
82 | self.flush_session_expire_time(session)
83 | return res
84 |
85 | async def send_personal_message(self, message: str, session: WebSocket):
86 | await session.send_text(message)
87 |
88 | async def broadcast(self, message: str):
89 | for connection in self.active_connections:
90 | msg = "a" + json.dumps([message])
91 | await connection.send_text(msg)
92 |
93 | def start(self):
94 | # logging.info("start manager")
95 | if not self._hb_handle:
96 | loop = asyncio.get_running_loop()
97 | self._hb_handle = loop.call_later(self.heartbeat, self._heartbeat)
98 |
99 | def stop(self):
100 | if self._hb_handle is not None:
101 | self._hb_handle.cancel()
102 | self._hb_handle = None
103 | if self._hb_task is not None:
104 | self._hb_task.cancel()
105 | self._hb_task = None
106 |
107 | async def disconnect(self, session: WebSocket):
108 | """
109 | 关闭连接
110 | """
111 | await session.close()
112 | self.remove_session(session)
113 |
114 | def remove_session(self, session):
115 | """
116 | 移除缓存的session
117 | """
118 | if session in self.active_connections:
119 | LOG.info(f"session close {self.get_session_id(session)}")
120 | self.active_connections.remove(session)
121 |
122 | def _heartbeat(self):
123 | """
124 | 启动定时task
125 | """
126 | if self._hb_task is None:
127 | loop = asyncio.get_running_loop()
128 | self._hb_task = loop.create_task(self._heartbeat_task())
129 |
130 | async def _heartbeat_task(self):
131 | """
132 | 心跳检测
133 | """
134 | sessions = self.active_connections
135 | if sessions:
136 | now = datetime.now()
137 |
138 | idx = 0
139 | while idx < len(sessions):
140 | session = sessions[idx]
141 | if session.expire_time < now:
142 | session_id = self.get_session_id(session)
143 | # 删除超时session
144 | try:
145 | await self.send_personal_message("h time out", session)
146 | logging.warn(f"heart beat check timeout {session_id}")
147 | await self.disconnect(session)
148 | except Exception as e:
149 | LOG.info(f"session {session_id} check failed {str(e)}")
150 | continue
151 | # 没有超时的设置下次超时时间, 但是索引不变
152 | await self.send_personal_message("h[]", session)
153 | idx += 1
154 |
155 | self._hb_task = None
156 | loop = asyncio.get_running_loop()
157 | self._hb_handle = loop.call_later(self.heartbeat, self._heartbeat)
158 |
159 | def flush_session_expire_time(self, session: WebSocket):
160 | """
161 | 刷新session的过期时间
162 | """
163 | setattr(session, "expire_time", datetime.now() + timedelta(seconds=self.heartbeat))
164 |
165 | def get_session_id(self, session):
166 | """
167 | 提取session的id
168 | """
169 | if hasattr(session, "user_id"):
170 | session_id = session.user_id
171 | else:
172 | session_id = session.url
173 | return session_id
174 |
175 |
176 | html = """
177 |
178 |
179 |
180 | Chat
181 |
182 |
183 | WebSocket Chat
184 | Your ID:
185 |
189 |
191 |
209 |
210 |
211 | """
212 |
--------------------------------------------------------------------------------
/core/config/__init__.py:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/core/config/base_config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | def str2bool(v):
5 | if v is None or isinstance(v, bool):
6 | return v
7 | return v.lower() in ("yes", "true", "t", "1")
8 |
9 |
10 | def str2int(v):
11 | if v is None:
12 | return v
13 | if v == "":
14 | return None
15 | return int(v)
16 |
17 |
18 | def str2float(v):
19 | if v is None:
20 | return v
21 | return int(v)
22 |
23 |
24 | class Base:
25 | # ------------------- need config ---------------------
26 | DATABASE_MYSQL_URL = os.getenv("DATABASE_MYSQL_URL", "root:dangerous@127.0.0.1:3306/test")
27 |
28 | # ------------------- option ---------------------
29 | CONFIG_NAME = "BASE"
30 | SERVICE_NAME = os.getenv("SERVICE_NAME", "fastapi-web-template")
31 |
32 | TZ = os.getenv("TZ", "Asia/Shanghai")
33 |
34 | TOKEN_SECRET_KEY = os.getenv("TOKEN_SECRET_KEY", "token_secret_key")
35 |
36 | # db
37 | DATABASE_URL = os.getenv("DATABASE_URL", f"mysql+aiomysql://{DATABASE_MYSQL_URL}?charset=utf8mb4")
38 | SHOW_SQL = str2bool(os.getenv("SHOW_SQL", "False"))
39 | RETURN_SQL = str2bool(os.getenv("RETURN_SQL", "True"))
40 | DATABASE_URL_ENCODING = os.getenv("DATABASE_URL_ENCODING", "utf8mb4")
41 |
42 | DB_POOL_RECYCLE = str2int(os.getenv("DB_POOL_RECYCLE", 3600))
43 | DB_MAX_OVERFLOW = str2int(os.getenv("DB_MAX_OVERFLOW", 20))
44 | DB_POOL_SIZE = str2int(os.getenv("DB_POOL_SIZE", 5))
45 |
--------------------------------------------------------------------------------
/core/config/common.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import os
4 |
5 | LOG = logging.getLogger(__name__)
6 | # noinspection PyUnresolvedReferences
7 | from .base_config import str2bool
8 |
9 |
10 | class Config:
11 | def __init__(self):
12 | self.config = None
13 | self.get_config()
14 |
15 | def get_config(self, config_name=os.getenv("CONFIG_NAME", "DEFAULT")):
16 | if config_name == "PROD":
17 | from .pro_config import ProdConfig
18 |
19 | self.config = ProdConfig()
20 | else:
21 | from .dev_config import DevConfig
22 |
23 | self.config = DevConfig()
24 |
25 | LOG.info("config use: %s", config_name)
26 | return self
27 |
28 | def __getattr__(self, item):
29 | return getattr(self.config, item)
30 |
31 |
32 | config = Config()
33 |
--------------------------------------------------------------------------------
/core/config/dev_config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from .base_config import Base, str2bool
4 |
5 |
6 | class DevConfig(Base):
7 | CONFIG_NAME = "DEV"
8 |
9 | DATABASE_MYSQL_URL = os.getenv("DATABASE_MYSQL_URL", "root:dangerous@127.0.0.1:3306/test")
10 |
11 | PROD = str2bool(os.getenv("PROD", "False"))
12 |
13 | # db
14 | DATABASE_URL = os.getenv("DATABASE_URL", f"mysql+aiomysql://{DATABASE_MYSQL_URL}?charset=utf8mb4")
15 | SHOW_SQL = str2bool(os.getenv("SHOW_SQL", "True"))
16 |
--------------------------------------------------------------------------------
/core/config/pro_config.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from .base_config import Base, str2bool
4 |
5 |
6 | class ProdConfig(Base):
7 | CONFIG_NAME = "PROD"
8 |
9 | PROD = str2bool(os.getenv("PROD", "False"))
10 |
--------------------------------------------------------------------------------
/core/dependencies/__init__.py:
--------------------------------------------------------------------------------
1 | from fastapi import Depends
2 | from .auth import auth
3 | from .base import base
4 |
5 | auth_dependen = Depends(auth)
6 | base_dependen = Depends(base)
7 |
--------------------------------------------------------------------------------
/core/dependencies/auth.py:
--------------------------------------------------------------------------------
1 | import jwt
2 |
3 | from fastapi import Header
4 | from starlette.responses import JSONResponse
5 |
6 | from core.config.common import config
7 | from core.middleware import g
8 |
9 |
10 | async def auth(Authorization: str = Header(...)):
11 | """
12 | 接口认证依赖,这个是和base依赖分隔的,你得注意先后顺序
13 | """
14 | header_type = "Bearer"
15 | header_name = "Authorization"
16 | algorithms = "HS256"
17 | # 只支持从header中获取jwt token, 存在token,将解析并设置user_id
18 | auth = Authorization
19 | if not auth:
20 | msg = "Need Header Authorization".format(header_name, header_type)
21 | JSONResponse({"error_info": msg, "error_type": "auth_error"}, status_code=401)
22 | parts = auth.split()
23 | if len(parts) != 2 or parts[0] != header_type:
24 | msg = "Bad {} header. Expected value '{} '".format(header_name, header_type)
25 | return JSONResponse({"error_info": msg, "error_type": "auth_error"}, status_code=401)
26 | try:
27 | payload = jwt.decode(parts[1], config.TOKEN_SECRET_KEY, algorithms=algorithms)
28 | except Exception as e:
29 | msg = f"Bad token {str(e)}"
30 | return JSONResponse({"error_info": msg, "error_type": "auth_error"}, status_code=401)
31 | # TODO check user_id
32 | g.user_id = payload.get("user_id")
33 |
--------------------------------------------------------------------------------
/core/dependencies/base.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import asyncio
3 |
4 | from fastapi import Request
5 |
6 |
7 | async def base(request: Request):
8 | """
9 | 基础依赖,你可以当作钩子函数来处理
10 | 如果需要扩展其他的dependen,需要注意调用顺序
11 | 另外你需要知道的一点是,fastapi中 dependencies如果是非异步函数则会用线程池进行处理
12 | - 1. 在线程池里contextvar的修改将不起作用
13 | """
14 | from core.middleware.fastapi_globals import g
15 |
16 | lang = request.headers.get("Accept-Language")
17 | if lang:
18 | g.locale = lang.split(",")[0]
19 | else:
20 | g.locale = "zh-CN"
21 |
--------------------------------------------------------------------------------
/core/i18n/README.md:
--------------------------------------------------------------------------------
1 | ## 手动执行翻译步骤
2 |
3 | ### 1. extract file
4 | `pybabel extract -F core/i18n/babel.cfg -k lazy_gettext -o core/i18n/messages.pot app`
5 | ### 2. 初始化翻译目录(如果不存在翻译目录需要处理一下)
6 | `pybabel init -i core/i18n/messages.pot -d core/i18n/ -l zh_CN`
7 | ### 3. 同步翻译
8 | `pybabel update -i core/i18n/messages.pot -d core/i18n/`
9 | ### 4. 编译翻译文件
10 | - 修改翻译前置文件,比如中文就是:`core/i18n/zh_CN/LC_MESSAGES/messages.po`,完成缺少的翻译内容
11 |
12 | - 编译:`pybabel compile -d core/i18n/`
--------------------------------------------------------------------------------
/core/i18n/__init__.py:
--------------------------------------------------------------------------------
1 | import gettext
2 | import logging
3 | import os
4 |
5 | from babel.support import Translations
6 | from starlette.datastructures import Headers
7 |
8 | TRANSLATIONS = {
9 | # 获取当前文件所在目录,翻译文件就在当前目录下
10 | "zh-CN": Translations.load(os.path.split(os.path.realpath(__file__))[0], locales=["zh_CN"]),
11 | "en-US": Translations.load(os.path.split(os.path.realpath(__file__))[0], locales=["en_US"]),
12 | }
13 |
14 |
15 | translations = TRANSLATIONS.get("en-US")
16 |
17 |
18 | def gettext(msg: str):
19 | from core.middleware.fastapi_globals import g
20 |
21 | t = TRANSLATIONS.get(g.locale) if g.locale else TRANSLATIONS.get("en-US")
22 | return t.ugettext(msg)
23 |
--------------------------------------------------------------------------------
/core/i18n/babel.cfg:
--------------------------------------------------------------------------------
1 | [python: **.py]
2 |
3 | extensions = jinja2.ext.autoescape,jinja2.ext.with_,webassets.ext.jinja2.AssetsExtension
--------------------------------------------------------------------------------
/core/i18n/en_US/LC_MESSAGES/messages.po:
--------------------------------------------------------------------------------
1 | # English (United States) translations for PROJECT.
2 | # Copyright (C) 2021 ORGANIZATION
3 | # This file is distributed under the same license as the PROJECT project.
4 | # FIRST AUTHOR , 2021.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: PROJECT VERSION\n"
9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
10 | "POT-Creation-Date: 2021-08-23 11:16+0800\n"
11 | "PO-Revision-Date: 2021-08-18 16:37+0800\n"
12 | "Last-Translator: FULL NAME \n"
13 | "Language: en_US\n"
14 | "Language-Team: en_US \n"
15 | "Plural-Forms: nplurals=2; plural=(n != 1)\n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=utf-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Generated-By: Babel 2.9.1\n"
20 |
21 | #: venv/lib/python3.8/site-packages/click/_termui_impl.py:495
22 | msgid "{editor}: Editing failed"
23 | msgstr ""
24 |
25 | #: venv/lib/python3.8/site-packages/click/_termui_impl.py:499
26 | msgid "{editor}: Editing failed: {e}"
27 | msgstr ""
28 |
29 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:19
30 | msgid ""
31 | "Click will abort further execution because Python was configured to use "
32 | "ASCII as encoding for the environment. Consult "
33 | "https://click.palletsprojects.com/unicode-support/ for mitigation steps."
34 | msgstr ""
35 |
36 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:55
37 | msgid ""
38 | "Additional information: on this system no suitable UTF-8 locales were "
39 | "discovered. This most likely requires resolving by reconfiguring the "
40 | "locale system."
41 | msgstr ""
42 |
43 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:64
44 | msgid ""
45 | "This system supports the C.UTF-8 locale which is recommended. You might "
46 | "be able to resolve your issue by exporting the following environment "
47 | "variables:"
48 | msgstr ""
49 |
50 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:74
51 | msgid ""
52 | "This system lists some UTF-8 supporting locales that you can pick from. "
53 | "The following suitable locales were discovered: {locales}"
54 | msgstr ""
55 |
56 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:92
57 | msgid ""
58 | "Click discovered that you exported a UTF-8 locale but the locale system "
59 | "could not pick up from it because it does not exist. The exported locale "
60 | "is {locale!r} but it is not supported."
61 | msgstr ""
62 |
63 | #: venv/lib/python3.8/site-packages/click/core.py:1104
64 | msgid "Aborted!"
65 | msgstr ""
66 |
67 | #: venv/lib/python3.8/site-packages/click/core.py:1288
68 | #: venv/lib/python3.8/site-packages/click/decorators.py:435
69 | msgid "Show this message and exit."
70 | msgstr ""
71 |
72 | #: venv/lib/python3.8/site-packages/click/core.py:1317
73 | #: venv/lib/python3.8/site-packages/click/core.py:1343
74 | msgid "(Deprecated) {text}"
75 | msgstr ""
76 |
77 | #: venv/lib/python3.8/site-packages/click/core.py:1360
78 | msgid "Options"
79 | msgstr ""
80 |
81 | #: venv/lib/python3.8/site-packages/click/core.py:1386
82 | msgid "Got unexpected extra argument ({args})"
83 | msgid_plural "Got unexpected extra arguments ({args})"
84 | msgstr[0] ""
85 | msgstr[1] ""
86 |
87 | #: venv/lib/python3.8/site-packages/click/core.py:1398
88 | msgid "DeprecationWarning: The command {name!r} is deprecated."
89 | msgstr ""
90 |
91 | #: venv/lib/python3.8/site-packages/click/core.py:1616
92 | msgid "Commands"
93 | msgstr ""
94 |
95 | #: venv/lib/python3.8/site-packages/click/core.py:1648
96 | msgid "Missing command."
97 | msgstr ""
98 |
99 | #: venv/lib/python3.8/site-packages/click/core.py:1726
100 | msgid "No such command {name!r}."
101 | msgstr ""
102 |
103 | #: venv/lib/python3.8/site-packages/click/core.py:2274
104 | msgid "Value must be an iterable."
105 | msgstr ""
106 |
107 | #: venv/lib/python3.8/site-packages/click/core.py:2296
108 | msgid "Takes {nargs} values but 1 was given."
109 | msgid_plural "Takes {nargs} values but {len} were given."
110 | msgstr[0] ""
111 | msgstr[1] ""
112 |
113 | #: venv/lib/python3.8/site-packages/click/core.py:2718
114 | msgid "env var: {var}"
115 | msgstr ""
116 |
117 | #: venv/lib/python3.8/site-packages/click/core.py:2741
118 | msgid "(dynamic)"
119 | msgstr ""
120 |
121 | #: venv/lib/python3.8/site-packages/click/core.py:2751
122 | msgid "default: {default}"
123 | msgstr ""
124 |
125 | #: venv/lib/python3.8/site-packages/click/core.py:2760
126 | msgid "required"
127 | msgstr ""
128 |
129 | #: venv/lib/python3.8/site-packages/click/decorators.py:339
130 | #, python-format
131 | msgid "%(prog)s, version %(version)s"
132 | msgstr ""
133 |
134 | #: venv/lib/python3.8/site-packages/click/decorators.py:404
135 | msgid "Show the version and exit."
136 | msgstr ""
137 |
138 | #: venv/lib/python3.8/site-packages/click/exceptions.py:43
139 | #: venv/lib/python3.8/site-packages/click/exceptions.py:79
140 | msgid "Error: {message}"
141 | msgstr ""
142 |
143 | #: venv/lib/python3.8/site-packages/click/exceptions.py:71
144 | msgid "Try '{command} {option}' for help."
145 | msgstr ""
146 |
147 | #: venv/lib/python3.8/site-packages/click/exceptions.py:120
148 | msgid "Invalid value: {message}"
149 | msgstr ""
150 |
151 | #: venv/lib/python3.8/site-packages/click/exceptions.py:122
152 | msgid "Invalid value for {param_hint}: {message}"
153 | msgstr ""
154 |
155 | #: venv/lib/python3.8/site-packages/click/exceptions.py:178
156 | msgid "Missing argument"
157 | msgstr ""
158 |
159 | #: venv/lib/python3.8/site-packages/click/exceptions.py:180
160 | msgid "Missing option"
161 | msgstr ""
162 |
163 | #: venv/lib/python3.8/site-packages/click/exceptions.py:182
164 | msgid "Missing parameter"
165 | msgstr ""
166 |
167 | #: venv/lib/python3.8/site-packages/click/exceptions.py:184
168 | msgid "Missing {param_type}"
169 | msgstr ""
170 |
171 | #: venv/lib/python3.8/site-packages/click/exceptions.py:191
172 | msgid "Missing parameter: {param_name}"
173 | msgstr ""
174 |
175 | #: venv/lib/python3.8/site-packages/click/exceptions.py:211
176 | msgid "No such option: {name}"
177 | msgstr ""
178 |
179 | #: venv/lib/python3.8/site-packages/click/exceptions.py:225
180 | msgid "Did you mean {possibility}?"
181 | msgid_plural "(Possible options: {possibilities})"
182 | msgstr[0] ""
183 | msgstr[1] ""
184 |
185 | #: venv/lib/python3.8/site-packages/click/exceptions.py:261
186 | msgid "unknown error"
187 | msgstr ""
188 |
189 | #: venv/lib/python3.8/site-packages/click/exceptions.py:268
190 | msgid "Could not open file {filename!r}: {message}"
191 | msgstr ""
192 |
193 | #: venv/lib/python3.8/site-packages/click/parser.py:231
194 | msgid "Argument {name!r} takes {nargs} values."
195 | msgstr ""
196 |
197 | #: venv/lib/python3.8/site-packages/click/parser.py:413
198 | msgid "Option {name!r} does not take a value."
199 | msgstr ""
200 |
201 | #: venv/lib/python3.8/site-packages/click/parser.py:473
202 | msgid "Option {name!r} requires an argument."
203 | msgid_plural "Option {name!r} requires {nargs} arguments."
204 | msgstr[0] ""
205 | msgstr[1] ""
206 |
207 | #: venv/lib/python3.8/site-packages/click/shell_completion.py:313
208 | msgid "Shell completion is not supported for Bash versions older than 4.4."
209 | msgstr ""
210 |
211 | #: venv/lib/python3.8/site-packages/click/shell_completion.py:320
212 | msgid "Couldn't detect Bash version, shell completion is not supported."
213 | msgstr ""
214 |
215 | #: venv/lib/python3.8/site-packages/click/termui.py:161
216 | msgid "Repeat for confirmation"
217 | msgstr ""
218 |
219 | #: venv/lib/python3.8/site-packages/click/termui.py:178
220 | msgid "Error: The value you entered was invalid."
221 | msgstr ""
222 |
223 | #: venv/lib/python3.8/site-packages/click/termui.py:180
224 | msgid "Error: {e.message}"
225 | msgstr ""
226 |
227 | #: venv/lib/python3.8/site-packages/click/termui.py:191
228 | msgid "Error: The two entered values do not match."
229 | msgstr ""
230 |
231 | #: venv/lib/python3.8/site-packages/click/termui.py:245
232 | msgid "Error: invalid input"
233 | msgstr ""
234 |
235 | #: venv/lib/python3.8/site-packages/click/termui.py:796
236 | msgid "Press any key to continue..."
237 | msgstr ""
238 |
239 | #: venv/lib/python3.8/site-packages/click/types.py:258
240 | msgid ""
241 | "Choose from:\n"
242 | "\t{choices}"
243 | msgstr ""
244 |
245 | #: venv/lib/python3.8/site-packages/click/types.py:292
246 | msgid "{value!r} is not {choice}."
247 | msgid_plural "{value!r} is not one of {choices}."
248 | msgstr[0] ""
249 | msgstr[1] ""
250 |
251 | #: venv/lib/python3.8/site-packages/click/types.py:382
252 | msgid "{value!r} does not match the format {format}."
253 | msgid_plural "{value!r} does not match the formats {formats}."
254 | msgstr[0] ""
255 | msgstr[1] ""
256 |
257 | #: venv/lib/python3.8/site-packages/click/types.py:402
258 | msgid "{value!r} is not a valid {number_type}."
259 | msgstr ""
260 |
261 | #: venv/lib/python3.8/site-packages/click/types.py:458
262 | msgid "{value} is not in the range {range}."
263 | msgstr ""
264 |
265 | #: venv/lib/python3.8/site-packages/click/types.py:599
266 | msgid "{value!r} is not a valid boolean."
267 | msgstr ""
268 |
269 | #: venv/lib/python3.8/site-packages/click/types.py:623
270 | msgid "{value!r} is not a valid UUID."
271 | msgstr ""
272 |
273 | #: venv/lib/python3.8/site-packages/click/types.py:801
274 | msgid "file"
275 | msgstr ""
276 |
277 | #: venv/lib/python3.8/site-packages/click/types.py:803
278 | msgid "directory"
279 | msgstr ""
280 |
281 | #: venv/lib/python3.8/site-packages/click/types.py:805
282 | msgid "path"
283 | msgstr ""
284 |
285 | #: venv/lib/python3.8/site-packages/click/types.py:851
286 | msgid "{name} {filename!r} does not exist."
287 | msgstr ""
288 |
289 | #: venv/lib/python3.8/site-packages/click/types.py:860
290 | msgid "{name} {filename!r} is a file."
291 | msgstr ""
292 |
293 | #: venv/lib/python3.8/site-packages/click/types.py:868
294 | msgid "{name} {filename!r} is a directory."
295 | msgstr ""
296 |
297 | #: venv/lib/python3.8/site-packages/click/types.py:876
298 | msgid "{name} {filename!r} is not writable."
299 | msgstr ""
300 |
301 | #: venv/lib/python3.8/site-packages/click/types.py:884
302 | msgid "{name} {filename!r} is not readable."
303 | msgstr ""
304 |
305 | #: venv/lib/python3.8/site-packages/click/types.py:950
306 | msgid "{len_type} values are required, but {len_value} was given."
307 | msgid_plural "{len_type} values are required, but {len_value} were given."
308 | msgstr[0] ""
309 | msgstr[1] ""
310 |
311 | #~ msgid "log Value: %(value)s"
312 | #~ msgstr ""
313 |
314 | #~ msgid "Dsp Value: %(value)s"
315 | #~ msgstr ""
316 |
317 | #~ msgid "get competition rank failed"
318 | #~ msgstr "获取赛事排行榜失败"
319 |
320 | #~ msgid "competition not found"
321 | #~ msgstr "赛事不存在"
322 |
323 | #~ msgid "test exception"
324 | #~ msgstr ""
325 |
326 | #~ msgid "database name error"
327 | #~ msgstr ""
328 |
329 | #~ msgid "{}:{} not in dict_data {}"
330 | #~ msgstr ""
331 |
332 | #~ msgid "{}:{} in dict_data {}"
333 | #~ msgstr ""
334 |
335 | #~ msgid "http error: {}"
336 | #~ msgstr ""
337 |
338 | #~ msgid "test_deep_with_session_raise"
339 | #~ msgstr ""
340 |
341 | #~ msgid "test_deep_with_session_raise_catch"
342 | #~ msgstr ""
343 |
344 | #~ msgid "test_deep_with_session_commit_manually"
345 | #~ msgstr ""
346 |
347 | #~ msgid "test_top_with_session_raise"
348 | #~ msgstr ""
349 |
350 | #~ msgid "request data is not a json"
351 | #~ msgstr ""
352 |
353 | #~ msgid "can not dump {obj}"
354 | #~ msgstr ""
355 |
356 |
--------------------------------------------------------------------------------
/core/i18n/zh_CN/LC_MESSAGES/messages.po:
--------------------------------------------------------------------------------
1 | # Chinese (Simplified, China) translations for PROJECT.
2 | # Copyright (C) 2021 ORGANIZATION
3 | # This file is distributed under the same license as the PROJECT project.
4 | # FIRST AUTHOR , 2021.
5 | #
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: PROJECT VERSION\n"
9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
10 | "POT-Creation-Date: 2021-08-23 11:16+0800\n"
11 | "PO-Revision-Date: 2021-08-18 16:28+0800\n"
12 | "Last-Translator: FULL NAME \n"
13 | "Language: zh_Hans_CN\n"
14 | "Language-Team: zh_Hans_CN \n"
15 | "Plural-Forms: nplurals=1; plural=0\n"
16 | "MIME-Version: 1.0\n"
17 | "Content-Type: text/plain; charset=utf-8\n"
18 | "Content-Transfer-Encoding: 8bit\n"
19 | "Generated-By: Babel 2.9.1\n"
20 |
21 | #: venv/lib/python3.8/site-packages/click/_termui_impl.py:495
22 | msgid "{editor}: Editing failed"
23 | msgstr ""
24 |
25 | #: venv/lib/python3.8/site-packages/click/_termui_impl.py:499
26 | msgid "{editor}: Editing failed: {e}"
27 | msgstr ""
28 |
29 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:19
30 | msgid ""
31 | "Click will abort further execution because Python was configured to use "
32 | "ASCII as encoding for the environment. Consult "
33 | "https://click.palletsprojects.com/unicode-support/ for mitigation steps."
34 | msgstr ""
35 |
36 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:55
37 | msgid ""
38 | "Additional information: on this system no suitable UTF-8 locales were "
39 | "discovered. This most likely requires resolving by reconfiguring the "
40 | "locale system."
41 | msgstr ""
42 |
43 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:64
44 | msgid ""
45 | "This system supports the C.UTF-8 locale which is recommended. You might "
46 | "be able to resolve your issue by exporting the following environment "
47 | "variables:"
48 | msgstr ""
49 |
50 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:74
51 | msgid ""
52 | "This system lists some UTF-8 supporting locales that you can pick from. "
53 | "The following suitable locales were discovered: {locales}"
54 | msgstr ""
55 |
56 | #: venv/lib/python3.8/site-packages/click/_unicodefun.py:92
57 | msgid ""
58 | "Click discovered that you exported a UTF-8 locale but the locale system "
59 | "could not pick up from it because it does not exist. The exported locale "
60 | "is {locale!r} but it is not supported."
61 | msgstr ""
62 |
63 | #: venv/lib/python3.8/site-packages/click/core.py:1104
64 | msgid "Aborted!"
65 | msgstr ""
66 |
67 | #: venv/lib/python3.8/site-packages/click/core.py:1288
68 | #: venv/lib/python3.8/site-packages/click/decorators.py:435
69 | msgid "Show this message and exit."
70 | msgstr ""
71 |
72 | #: venv/lib/python3.8/site-packages/click/core.py:1317
73 | #: venv/lib/python3.8/site-packages/click/core.py:1343
74 | msgid "(Deprecated) {text}"
75 | msgstr ""
76 |
77 | #: venv/lib/python3.8/site-packages/click/core.py:1360
78 | msgid "Options"
79 | msgstr ""
80 |
81 | #: venv/lib/python3.8/site-packages/click/core.py:1386
82 | msgid "Got unexpected extra argument ({args})"
83 | msgid_plural "Got unexpected extra arguments ({args})"
84 | msgstr[0] ""
85 |
86 | #: venv/lib/python3.8/site-packages/click/core.py:1398
87 | msgid "DeprecationWarning: The command {name!r} is deprecated."
88 | msgstr ""
89 |
90 | #: venv/lib/python3.8/site-packages/click/core.py:1616
91 | msgid "Commands"
92 | msgstr ""
93 |
94 | #: venv/lib/python3.8/site-packages/click/core.py:1648
95 | msgid "Missing command."
96 | msgstr ""
97 |
98 | #: venv/lib/python3.8/site-packages/click/core.py:1726
99 | msgid "No such command {name!r}."
100 | msgstr ""
101 |
102 | #: venv/lib/python3.8/site-packages/click/core.py:2274
103 | msgid "Value must be an iterable."
104 | msgstr ""
105 |
106 | #: venv/lib/python3.8/site-packages/click/core.py:2296
107 | msgid "Takes {nargs} values but 1 was given."
108 | msgid_plural "Takes {nargs} values but {len} were given."
109 | msgstr[0] ""
110 |
111 | #: venv/lib/python3.8/site-packages/click/core.py:2718
112 | msgid "env var: {var}"
113 | msgstr ""
114 |
115 | #: venv/lib/python3.8/site-packages/click/core.py:2741
116 | msgid "(dynamic)"
117 | msgstr ""
118 |
119 | #: venv/lib/python3.8/site-packages/click/core.py:2751
120 | msgid "default: {default}"
121 | msgstr ""
122 |
123 | #: venv/lib/python3.8/site-packages/click/core.py:2760
124 | msgid "required"
125 | msgstr ""
126 |
127 | #: venv/lib/python3.8/site-packages/click/decorators.py:339
128 | #, python-format
129 | msgid "%(prog)s, version %(version)s"
130 | msgstr ""
131 |
132 | #: venv/lib/python3.8/site-packages/click/decorators.py:404
133 | msgid "Show the version and exit."
134 | msgstr ""
135 |
136 | #: venv/lib/python3.8/site-packages/click/exceptions.py:43
137 | #: venv/lib/python3.8/site-packages/click/exceptions.py:79
138 | msgid "Error: {message}"
139 | msgstr ""
140 |
141 | #: venv/lib/python3.8/site-packages/click/exceptions.py:71
142 | msgid "Try '{command} {option}' for help."
143 | msgstr ""
144 |
145 | #: venv/lib/python3.8/site-packages/click/exceptions.py:120
146 | msgid "Invalid value: {message}"
147 | msgstr ""
148 |
149 | #: venv/lib/python3.8/site-packages/click/exceptions.py:122
150 | msgid "Invalid value for {param_hint}: {message}"
151 | msgstr ""
152 |
153 | #: venv/lib/python3.8/site-packages/click/exceptions.py:178
154 | msgid "Missing argument"
155 | msgstr ""
156 |
157 | #: venv/lib/python3.8/site-packages/click/exceptions.py:180
158 | msgid "Missing option"
159 | msgstr ""
160 |
161 | #: venv/lib/python3.8/site-packages/click/exceptions.py:182
162 | msgid "Missing parameter"
163 | msgstr ""
164 |
165 | #: venv/lib/python3.8/site-packages/click/exceptions.py:184
166 | msgid "Missing {param_type}"
167 | msgstr ""
168 |
169 | #: venv/lib/python3.8/site-packages/click/exceptions.py:191
170 | msgid "Missing parameter: {param_name}"
171 | msgstr ""
172 |
173 | #: venv/lib/python3.8/site-packages/click/exceptions.py:211
174 | msgid "No such option: {name}"
175 | msgstr ""
176 |
177 | #: venv/lib/python3.8/site-packages/click/exceptions.py:225
178 | msgid "Did you mean {possibility}?"
179 | msgid_plural "(Possible options: {possibilities})"
180 | msgstr[0] ""
181 |
182 | #: venv/lib/python3.8/site-packages/click/exceptions.py:261
183 | msgid "unknown error"
184 | msgstr ""
185 |
186 | #: venv/lib/python3.8/site-packages/click/exceptions.py:268
187 | msgid "Could not open file {filename!r}: {message}"
188 | msgstr ""
189 |
190 | #: venv/lib/python3.8/site-packages/click/parser.py:231
191 | msgid "Argument {name!r} takes {nargs} values."
192 | msgstr ""
193 |
194 | #: venv/lib/python3.8/site-packages/click/parser.py:413
195 | msgid "Option {name!r} does not take a value."
196 | msgstr ""
197 |
198 | #: venv/lib/python3.8/site-packages/click/parser.py:473
199 | msgid "Option {name!r} requires an argument."
200 | msgid_plural "Option {name!r} requires {nargs} arguments."
201 | msgstr[0] ""
202 |
203 | #: venv/lib/python3.8/site-packages/click/shell_completion.py:313
204 | msgid "Shell completion is not supported for Bash versions older than 4.4."
205 | msgstr ""
206 |
207 | #: venv/lib/python3.8/site-packages/click/shell_completion.py:320
208 | msgid "Couldn't detect Bash version, shell completion is not supported."
209 | msgstr ""
210 |
211 | #: venv/lib/python3.8/site-packages/click/termui.py:161
212 | msgid "Repeat for confirmation"
213 | msgstr ""
214 |
215 | #: venv/lib/python3.8/site-packages/click/termui.py:178
216 | msgid "Error: The value you entered was invalid."
217 | msgstr ""
218 |
219 | #: venv/lib/python3.8/site-packages/click/termui.py:180
220 | msgid "Error: {e.message}"
221 | msgstr ""
222 |
223 | #: venv/lib/python3.8/site-packages/click/termui.py:191
224 | msgid "Error: The two entered values do not match."
225 | msgstr ""
226 |
227 | #: venv/lib/python3.8/site-packages/click/termui.py:245
228 | msgid "Error: invalid input"
229 | msgstr ""
230 |
231 | #: venv/lib/python3.8/site-packages/click/termui.py:796
232 | msgid "Press any key to continue..."
233 | msgstr ""
234 |
235 | #: venv/lib/python3.8/site-packages/click/types.py:258
236 | msgid ""
237 | "Choose from:\n"
238 | "\t{choices}"
239 | msgstr ""
240 |
241 | #: venv/lib/python3.8/site-packages/click/types.py:292
242 | msgid "{value!r} is not {choice}."
243 | msgid_plural "{value!r} is not one of {choices}."
244 | msgstr[0] ""
245 |
246 | #: venv/lib/python3.8/site-packages/click/types.py:382
247 | msgid "{value!r} does not match the format {format}."
248 | msgid_plural "{value!r} does not match the formats {formats}."
249 | msgstr[0] ""
250 |
251 | #: venv/lib/python3.8/site-packages/click/types.py:402
252 | msgid "{value!r} is not a valid {number_type}."
253 | msgstr ""
254 |
255 | #: venv/lib/python3.8/site-packages/click/types.py:458
256 | msgid "{value} is not in the range {range}."
257 | msgstr ""
258 |
259 | #: venv/lib/python3.8/site-packages/click/types.py:599
260 | msgid "{value!r} is not a valid boolean."
261 | msgstr ""
262 |
263 | #: venv/lib/python3.8/site-packages/click/types.py:623
264 | msgid "{value!r} is not a valid UUID."
265 | msgstr ""
266 |
267 | #: venv/lib/python3.8/site-packages/click/types.py:801
268 | msgid "file"
269 | msgstr ""
270 |
271 | #: venv/lib/python3.8/site-packages/click/types.py:803
272 | msgid "directory"
273 | msgstr ""
274 |
275 | #: venv/lib/python3.8/site-packages/click/types.py:805
276 | msgid "path"
277 | msgstr ""
278 |
279 | #: venv/lib/python3.8/site-packages/click/types.py:851
280 | msgid "{name} {filename!r} does not exist."
281 | msgstr ""
282 |
283 | #: venv/lib/python3.8/site-packages/click/types.py:860
284 | msgid "{name} {filename!r} is a file."
285 | msgstr ""
286 |
287 | #: venv/lib/python3.8/site-packages/click/types.py:868
288 | msgid "{name} {filename!r} is a directory."
289 | msgstr ""
290 |
291 | #: venv/lib/python3.8/site-packages/click/types.py:876
292 | msgid "{name} {filename!r} is not writable."
293 | msgstr ""
294 |
295 | #: venv/lib/python3.8/site-packages/click/types.py:884
296 | msgid "{name} {filename!r} is not readable."
297 | msgstr ""
298 |
299 | #: venv/lib/python3.8/site-packages/click/types.py:950
300 | msgid "{len_type} values are required, but {len_value} was given."
301 | msgid_plural "{len_type} values are required, but {len_value} were given."
302 | msgstr[0] ""
303 |
304 | #~ msgid "log Value: %(value)s"
305 | #~ msgstr ""
306 |
307 | #~ msgid "Dsp Value: %(value)s"
308 | #~ msgstr ""
309 |
310 | #~ msgid "get competition rank failed"
311 | #~ msgstr "获取赛事排行榜失败"
312 |
313 | #~ msgid "competition not found"
314 | #~ msgstr "赛事不存在"
315 |
316 | #~ msgid "test exception"
317 | #~ msgstr ""
318 |
319 | #~ msgid "database name error"
320 | #~ msgstr ""
321 |
322 | #~ msgid "{}:{} not in dict_data {}"
323 | #~ msgstr ""
324 |
325 | #~ msgid "{}:{} in dict_data {}"
326 | #~ msgstr ""
327 |
328 | #~ msgid "http error: {}"
329 | #~ msgstr ""
330 |
331 | #~ msgid "test_deep_with_session_raise"
332 | #~ msgstr ""
333 |
334 | #~ msgid "test_deep_with_session_raise_catch"
335 | #~ msgstr ""
336 |
337 | #~ msgid "test_deep_with_session_commit_manually"
338 | #~ msgstr ""
339 |
340 | #~ msgid "test_top_with_session_raise"
341 | #~ msgstr ""
342 |
343 | #~ msgid "request data is not a json"
344 | #~ msgstr ""
345 |
346 | #~ msgid "can not dump {obj}"
347 | #~ msgstr ""
348 |
349 |
--------------------------------------------------------------------------------
/core/manager/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 | from .base import CommandBase
5 |
6 |
7 | def registry_command():
8 | # noinspection PyUnresolvedReferences
9 | from .gen_code import StartAppCommand
10 | from .babel_cmd import BabelCommand
11 |
12 |
13 | class ManagementUtility:
14 | """
15 | 管理程序类
16 | """
17 |
18 | def __init__(self, argv=None):
19 | self.argv = argv
20 | # 项目名
21 | self.prog_name = os.path.basename(self.argv[0])
22 |
23 | def main_help_text(self):
24 | """
25 | 帮助文档
26 | """
27 | desc = CommandBase.get_command_desc()
28 | out = "支持命令:\n"
29 | for k, v in desc.items():
30 | out = out + " " * 4 + k + ": "
31 | if isinstance(v, list):
32 | out += "\n" + "\n".join([" " * 8 + i for i in v]) + "\n"
33 | else:
34 | out += v + "\n"
35 | return out + "\n"
36 |
37 | def fetch_command(self, subcommand):
38 | commands = CommandBase.get_commands()
39 | try:
40 | return commands[subcommand]
41 | except KeyError:
42 | sys.stderr.write("Unknown command: %r\nType '%s help' for usage.\n" % (subcommand, self.prog_name))
43 | sys.exit(1)
44 |
45 | def execute(self):
46 | """
47 | 执行command
48 | """
49 | try:
50 | command = self.argv[1]
51 | except IndexError:
52 | command = "help"
53 | if command == "help":
54 | sys.stdout.write(self.main_help_text())
55 | sys.exit(0)
56 | executor = self.fetch_command(command)
57 | executor(*self.argv[2:])
58 |
59 |
60 | registry_command()
61 |
--------------------------------------------------------------------------------
/core/manager/babel_cmd.py:
--------------------------------------------------------------------------------
1 | import shutil
2 | import os
3 | import sys
4 |
5 | from .base import CommandBase, str2camel
6 |
7 |
8 | class BabelCommand(CommandBase):
9 | """
10 | i18n翻译
11 | """
12 |
13 | # 命令
14 | command_str = "babel"
15 | # 命令描述
16 | command_desc = ["add: 生成翻译文件", "run: 编译翻译文件"]
17 |
18 | @classmethod
19 | def execute(cls, action):
20 | if action == "add":
21 | cls.add_babel()
22 | elif action == "run":
23 | cls.run_babel()
24 |
25 | @classmethod
26 | def add_babel(cls):
27 | """
28 | 创建翻译文件
29 | """
30 | if os.system("pybabel extract -F core/i18n/babel.cfg -k gettext -o core/i18n/messages.pot ."):
31 | sys.stderr.write("生成messages.pot文件失败\n")
32 | sys.exit(1)
33 | # # 是否已经创建了翻译文件
34 | # if not os.path.exists("core/i18n/zh_CN"):
35 | # if os.system("pybabel init -i core/i18n/messages.pot -d core/i18n/ -l zh_CN"):
36 | # sys.stderr.write("生成翻译文件失败\n")
37 | # sys.exit(1)
38 | if os.system("pybabel update -i core/i18n/messages.pot -d core/i18n/"):
39 | sys.stderr.write("更新翻译文件失败\n")
40 | sys.exit(1)
41 | sys.stdout.write("\n***************** ")
42 | sys.stdout.write("生成翻译文件成功,需要更新文件: core/i18n/zh_CN/LC_MESSAGES/messages.po")
43 | sys.stdout.write(" *****************\n")
44 |
45 | @classmethod
46 | def run_babel(cls):
47 | """
48 | 编译翻译文件
49 | """
50 | if os.system("pybabel compile -d core/i18n/"):
51 | sys.stderr.write("生成messages.pot文件失败")
52 | sys.exit(1)
53 | sys.stdout.write("\n***************** ")
54 | sys.stdout.write("编译成功")
55 | sys.stdout.write(" *****************\n")
56 |
--------------------------------------------------------------------------------
/core/manager/base.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 |
5 | class CommandMetaclass(type):
6 | registry_commands = dict()
7 | commands_desc = dict()
8 |
9 | def __new__(cls, name, bases, attrs):
10 | obj = type.__new__(cls, name, bases, attrs)
11 | if name == "CommandBase":
12 | return obj
13 |
14 | command_str = name
15 | command_desc = "..."
16 | executor = None
17 | for k, v in attrs.items():
18 | if k == "execute":
19 | executor = obj.execute
20 | # 指定命令名
21 | if k == "command_str":
22 | command_str = v
23 | # 指定命令描述
24 | if k == "command_desc":
25 | command_desc = v
26 | cls.registry_commands[command_str] = executor
27 | cls.commands_desc[command_str] = command_desc
28 | return obj
29 |
30 |
31 | class CommandBase(object, metaclass=CommandMetaclass):
32 | """
33 | command基类,所有的继承类只能实现类方法
34 | """
35 |
36 | @classmethod
37 | def get_commands(cls):
38 | return CommandMetaclass.registry_commands
39 |
40 | @classmethod
41 | def get_command_desc(cls):
42 | return CommandMetaclass.commands_desc
43 |
44 | @classmethod
45 | def execute(cls, *args, **kwargs):
46 | """
47 | 子类必须实现execute方法
48 | """
49 | raise NotImplementedError
50 |
51 |
52 | def str2camel(string):
53 | """
54 | 字符串转驼峰
55 | """
56 | return "".join(list(map(lambda x: x.capitalize(), string.split("_"))))
57 |
--------------------------------------------------------------------------------
/core/manager/gen_code.py:
--------------------------------------------------------------------------------
1 | import shutil
2 | import os
3 | import sys
4 |
5 | from .base import CommandBase, str2camel
6 |
7 |
8 | apis_file = """
9 | from fastapi import APIRouter
10 |
11 | from core.dependencies import base_dependen
12 | from core.schema.base import DataSchema, PageSchema
13 | from app.{app_name}.views import list_{app_name}_view, create_{app_name}_view, delete_{app_name}_view
14 | from app.{app_name}.schema import {AppName}ListRes
15 |
16 |
17 | router = APIRouter(prefix="/v1/{app_name}", tags=["{app_name} project"])
18 |
19 | router.add_api_route(
20 | name="list {app_name}",
21 | path="/",
22 | endpoint=list_{app_name}_view,
23 | methods=["GET"],
24 | response_model=PageSchema[{AppName}ListRes],
25 | dependencies=[base_dependen,]
26 | )
27 |
28 | router.add_api_route(
29 | name="create {app_name}",
30 | path="/",
31 | endpoint=create_{app_name}_view,
32 | methods=["POST"],
33 | dependencies=[base_dependen,]
34 | )
35 |
36 | router.add_api_route(
37 | name="delete {app_name}",
38 | path="/",
39 | endpoint=delete_{app_name}_view,
40 | methods=["DELETE"],
41 | dependencies=[base_dependen,]
42 | )
43 | """
44 |
45 | schema_file = """
46 | from typing import List, Optional
47 | from core.schema import BaseSchema
48 | from pydantic.fields import Field
49 |
50 |
51 | class {AppName}ListRes(BaseSchema):
52 | id: Optional[str] = Field(description="id")
53 | name: Optional[str] = Field(description="名称")
54 | description: Optional[str] = Field(description="描述")
55 |
56 | class {AppName}CreateReq(BaseSchema):
57 | name: Optional[str] = Field(description="名称")
58 | description: Optional[str] = Field(description="描述")
59 |
60 | """
61 |
62 | views_file = """
63 | from typing import Any, Optional
64 | from app.{app_name}.{app_name} import list_{app_name}, create_{app_name}, delete_{app_name}
65 | from app.{app_name}.schema import {AppName}CreateReq
66 |
67 |
68 | async def list_{app_name}_view(
69 | page: Optional[int] = 1,
70 | limit: Optional[int] = 10,
71 | ):
72 | return await list_{app_name}(page, limit)
73 |
74 | async def create_{app_name}_view(item: {AppName}CreateReq):
75 | await create_{app_name}(item.name, item.description)
76 | return {{"data": "success"}}
77 |
78 | async def delete_{app_name}_view(record_id: str):
79 | await delete_{app_name}(record_id)
80 | return {{"data": "success"}}
81 |
82 | """
83 |
84 | app_util_file = """
85 | from sqlalchemy import select, insert
86 | from core.middleware import g
87 | from core.schema import paginate_handler
88 | from core.utils.api_exception import NotFoundException
89 | from core.i18n import gettext
90 | from app.{app_name}.models import {AppName}
91 |
92 |
93 | async def list_{app_name}(page: int, limit: int):
94 | stmt = select({AppName})
95 | return await paginate_handler(page=page, limit=limit, db=g.db, stmt=stmt)
96 |
97 | async def create_{app_name}(name: str, description: str):
98 | return await g.db.execute(insert({AppName}).values({{"name": name, "description": description}}))
99 |
100 | async def delete_{app_name}(record_id: str):
101 | obj = await g.db.get({AppName}, record_id)
102 | if not obj:
103 | raise NotFoundException(gettext("record not found"))
104 | await obj.delete()
105 | return
106 |
107 | """
108 |
109 | model_file = """
110 | import uuid
111 |
112 | from core.storage import Base
113 | from sqlalchemy import Column, String
114 |
115 |
116 | class {AppName}(Base):
117 | __tablename__ = "{app_name}"
118 | id = Column(String(36), primary_key=True, default=str(uuid.uuid4()))
119 | name = Column(String(128), nullable=False)
120 | description = Column(String(512), nullable=False)
121 |
122 | """
123 |
124 |
125 | class StartAppCommand(CommandBase):
126 | """
127 | 自动生成标准模块代码
128 | """
129 |
130 | # the command
131 | command_str = "startapp"
132 |
133 | # command description
134 | command_desc = ["[app_name]: 自动生成app名称为[app_name]标准模块代码"]
135 |
136 | # default app name is 'demo'
137 | app_name = "demo"
138 |
139 | # the app module root dir
140 | root = "./app"
141 |
142 | # copyright
143 | copyright_str = ""
144 |
145 | @classmethod
146 | def execute(cls, app_name):
147 | cls.app_name = app_name
148 | cls.init_dir()
149 | cls.add_apis_file()
150 | cls.add_app_util_file()
151 | cls.add_model_file()
152 | cls.add_scheme_file()
153 | cls.add_view_file()
154 | cls.runner_file_handler()
155 | print("create app ' %s ' success" % app_name)
156 |
157 | @classmethod
158 | def init_dir(cls):
159 | """
160 | create app module dir
161 | """
162 | cls.root = os.path.join(cls.root, cls.app_name)
163 | if os.path.exists(cls.root):
164 | sys.stderr.write(f"app {cls.app_name} already exists")
165 | sys.exit(1)
166 | os.makedirs(cls.root)
167 | with open(os.path.join(cls.root, "__init__.py"), "w") as f:
168 | f.write(cls.copyright_str)
169 |
170 | @classmethod
171 | def add_apis_file(cls):
172 | """
173 | 创建目录和init文件
174 | """
175 | api_file_path = os.path.join(cls.root, f"apis.py")
176 | with open(api_file_path, "w") as f:
177 | f.write(cls.copyright_str)
178 | f.write(apis_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name))
179 |
180 | @classmethod
181 | def add_model_file(cls):
182 | """
183 | 创建模型文件
184 | """
185 | api_file_path = os.path.join(cls.root, "models.py")
186 | with open(api_file_path, "w") as f:
187 | f.write(cls.copyright_str)
188 | f.write(model_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name))
189 |
190 | @classmethod
191 | def add_scheme_file(cls):
192 | """
193 | 创建schema文件
194 | """
195 | api_file_path = os.path.join(cls.root, "schema.py")
196 | with open(api_file_path, "w") as f:
197 | f.write(cls.copyright_str)
198 | f.write(schema_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name))
199 |
200 | @classmethod
201 | def add_view_file(cls):
202 | """
203 | 创建view文件
204 | """
205 | api_file_path = os.path.join(cls.root, f"views.py")
206 | with open(api_file_path, "w") as f:
207 | f.write(cls.copyright_str)
208 | f.write(views_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name))
209 |
210 | @classmethod
211 | def add_app_util_file(cls):
212 | """
213 | 创建controller文件
214 | """
215 | api_file_path = os.path.join(cls.root, f"{cls.app_name}.py")
216 | with open(api_file_path, "w") as f:
217 | f.write(cls.copyright_str)
218 | f.write(app_util_file.format(AppName=str2camel(cls.app_name), app_name=cls.app_name))
219 |
220 | @classmethod
221 | def runner_file_handler(cls):
222 | """
223 | register router and configure sqla model
224 | """
225 | new_c = []
226 | with open("main.py", "r+") as f:
227 | f.seek(0)
228 | while True:
229 | c = f.readline()
230 | new_c.append(c)
231 | if c == "":
232 | break
233 | elif "+gencode:register-router" in c:
234 | new_c.append(f" from app.{cls.app_name}.apis import router as {cls.app_name}_router\n")
235 | new_c.append(f" app.include_router({cls.app_name}_router)\n\n")
236 | elif "+gencode:configure-model" in c:
237 | new_c.append(f" # noinspection PyUnresolvedReferences\n")
238 | new_c.append(f" from app.{cls.app_name} import models\n\n")
239 |
240 | with open("main.py", "w") as f:
241 | f.writelines(new_c)
242 |
--------------------------------------------------------------------------------
/core/middleware/__init__.py:
--------------------------------------------------------------------------------
1 | import time
2 | from fastapi import FastAPI
3 | from starlette.requests import Request
4 |
5 | from .fastapi_globals import GlobalsMiddleware, g
6 | from .session import DbSessionMiddleware
7 |
8 |
9 | def register_http_middleware(app: FastAPI):
10 | """
11 | add middleware
12 | """
13 | # use g.db for get sqla session
14 | app.add_middleware(DbSessionMiddleware)
15 |
16 | # the global object: g
17 | app.add_middleware(GlobalsMiddleware)
18 |
--------------------------------------------------------------------------------
/core/middleware/fastapi_globals.py:
--------------------------------------------------------------------------------
1 | from contextvars import ContextVar, Token
2 | from typing import Any, Dict
3 |
4 | from starlette.types import ASGIApp, Receive, Scope, Send
5 |
6 |
7 | class Globals:
8 | __slots__ = ("_vars", "_reset_tokens")
9 |
10 | _vars: Dict[str, ContextVar]
11 | _reset_tokens: Dict[str, Token]
12 |
13 | def __init__(self) -> None:
14 | object.__setattr__(self, "_vars", {})
15 | object.__setattr__(self, "_reset_tokens", {})
16 |
17 | def reset(self) -> None:
18 | for _name, var in self._vars.items():
19 | try:
20 | var.reset(self._reset_tokens[_name])
21 | # ValueError will be thrown if the reset() happens in
22 | # a different context compared to the original set().
23 | # Then just set to None for this new context.
24 | except ValueError:
25 | var.set(None)
26 |
27 | def _ensure_var(self, item: str) -> None:
28 | if item not in self._vars:
29 | self._vars[item] = ContextVar(f"globals:{item}", default=None)
30 | self._reset_tokens[item] = self._vars[item].set(None)
31 |
32 | def __getattr__(self, item: str) -> Any:
33 | return self._vars[item].get()
34 |
35 | def __setattr__(self, item: str, value: Any) -> None:
36 | self._ensure_var(item)
37 | self._vars[item].set(value)
38 |
39 |
40 | class GlobalsMiddleware:
41 | def __init__(self, app: ASGIApp) -> None:
42 | self.app = app
43 |
44 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
45 | if scope["type"] != "lifespan":
46 | g.reset()
47 | await self.app(scope, receive, send)
48 |
49 |
50 | g = Globals()
51 |
--------------------------------------------------------------------------------
/core/middleware/session.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.ext.asyncio import session
2 | from starlette.types import ASGIApp, Receive, Scope, Send
3 | from . import g
4 | from core.storage import db
5 |
6 |
7 | class DbSessionMiddleware:
8 | def __init__(self, app: ASGIApp) -> None:
9 | self.app = app
10 |
11 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
12 | if scope["type"] != "lifespan":
13 | session = db.session()
14 | # add global session
15 | g.db = session
16 |
17 | # add sql log
18 | g.sql_log = []
19 | try:
20 | await self.app(scope, receive, send)
21 | except Exception as e:
22 | await session.rollback()
23 | raise e
24 | else:
25 | await session.commit()
26 | finally:
27 | await session.close()
28 | else:
29 | await self.app(scope, receive, send)
30 |
--------------------------------------------------------------------------------
/core/schema/__init__.py:
--------------------------------------------------------------------------------
1 | from core.schema.base import DataSchema
2 | from core.schema.base import PageSchema
3 | from core.schema.base import BaseSchema
4 | from core.schema.paginate import paginate_handler
5 |
--------------------------------------------------------------------------------
/core/schema/base.py:
--------------------------------------------------------------------------------
1 | import decimal
2 | import datetime
3 |
4 | from typing import Any, Optional, Generic, List
5 | from pydantic import BaseModel, create_model
6 | from pydantic.fields import Field, T
7 | from pydantic.main import BaseConfig
8 | from pydantic.generics import GenericModel
9 |
10 |
11 | class SchemaConfig(BaseConfig):
12 | ...
13 | orm_mode = True
14 | json_encoders = {
15 | datetime: lambda v: v.timestamp() * 1000,
16 | decimal.Decimal: lambda v: float(v),
17 | }
18 |
19 |
20 | class BaseSchema(BaseModel):
21 | __config__ = SchemaConfig
22 |
23 |
24 | class DataSchema(GenericModel, Generic[T]):
25 | """普通json序列化器"""
26 |
27 | data: T
28 |
29 |
30 | class PageSchema(GenericModel, Generic[T]):
31 | """分页序列化器"""
32 |
33 | data: List[T]
34 | total_count: int = Field(..., description="总条数")
35 | total_page: int = Field(..., description="总页数")
36 | current_page: int = Field(..., description="当前页")
37 |
--------------------------------------------------------------------------------
/core/schema/paginate.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Optional
2 | from sqlalchemy import select
3 | from sqlalchemy.orm.session import Session
4 | from sqlalchemy.sql.functions import func
5 |
6 |
7 | async def paginate_handler(
8 | page: int,
9 | limit: int,
10 | data: Optional[Any] = None,
11 | db: Optional[Session] = None,
12 | stmt: Optional[select] = None,
13 | total: Optional[int] = None,
14 | ):
15 | """
16 | 数据分页
17 | """
18 | if page > 0:
19 | offset = (page - 1) * limit
20 | else:
21 | page = 1
22 | offset = 0
23 | if db and select:
24 | res = await db.execute(stmt.offset(offset).limit(limit))
25 | res = res.scalars().all()
26 | res_total = total or await db.scalar(select(func.count()).select_from(stmt))
27 | else:
28 | res = data[offset : offset + limit]
29 | res_total = total or len(data)
30 |
31 | return {
32 | "total_count": res_total,
33 | "current_page": page,
34 | "total_page": (res_total // limit) or 1,
35 | "data": res,
36 | }
37 |
--------------------------------------------------------------------------------
/core/storage/__init__.py:
--------------------------------------------------------------------------------
1 | from .db import *
2 | from .model import Base
3 |
--------------------------------------------------------------------------------
/core/storage/db.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from sqlalchemy import orm, event, false
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 | from sqlalchemy.orm import sessionmaker, Session
6 | from sqlalchemy.ext.asyncio import create_async_engine
7 | from core.config.common import config
8 | from .model import Base, HasPrivate
9 |
10 |
11 | class SQLAlchemy(object):
12 | def __init__(self, database_url=None, echo=False):
13 | self.engine = None
14 | self.session = None
15 |
16 | if database_url:
17 | self.update_engine(database_url, echo)
18 |
19 | def update_engine(self, database_url, echo):
20 | self.engine = create_async_engine(
21 | database_url,
22 | echo=echo,
23 | pool_size=config.DB_MAX_OVERFLOW,
24 | max_overflow=config.DB_POOL_SIZE,
25 | future=True,
26 | pool_recycle=config.DB_POOL_RECYCLE,
27 | query_cache_size=1500, # 设置缓存sql条数
28 | )
29 | self.session = self.create_session()
30 |
31 | def create_session(self, options=None):
32 | return sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)
33 |
34 |
35 | async def create_db(app=None, auto_create_table=True):
36 | db.update_engine(config.DATABASE_URL, echo=config.SHOW_SQL)
37 | print("db init success")
38 | if auto_create_table:
39 | async with db.engine.begin() as conn:
40 | await conn.run_sync(Base.metadata.create_all)
41 |
42 | # show_sql控制, 收集每一条执行sql记录存放到g对象里,最终会在接口返回报文里添加这些记录
43 | if config.RETURN_SQL:
44 |
45 | @event.listens_for(db.engine.sync_engine, "before_cursor_execute")
46 | def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
47 | conn.info.setdefault("query_start_time", []).append(time.time())
48 |
49 | @event.listens_for(db.engine.sync_engine, "after_cursor_execute")
50 | def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
51 | total = time.time() - conn.info["query_start_time"].pop(-1)
52 | tmp = [parameters] if not executemany else parameters
53 | from core.middleware import g
54 |
55 | for i in tmp:
56 | g.sql_log.append({"sql": (statement % i).replace("\n", ""), "duration": f"{int(total * 1000)} ms"})
57 |
58 |
59 | @event.listens_for(Session, "do_orm_execute")
60 | def _add_filtering_criteria(execute_state):
61 | """
62 | soft delete
63 | more detail: https://docs.sqlalchemy.org/en/14/_modules/examples/extending_query/filter_public.html
64 | """
65 |
66 | if (
67 | not execute_state.is_column_load
68 | and not execute_state.is_relationship_load
69 | and not execute_state.execution_options.get("include_private", False)
70 | ):
71 | execute_state.statement = execute_state.statement.options(
72 | orm.with_loader_criteria(
73 | HasPrivate,
74 | lambda cls: cls.deleted == false(),
75 | include_aliases=True,
76 | )
77 | )
78 |
79 |
80 | db = SQLAlchemy()
81 |
--------------------------------------------------------------------------------
/core/storage/model.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | import time
3 | import logging
4 | from sqlalchemy import Boolean, Column, orm, event, false
5 | from sqlalchemy.orm.query import Query
6 | from sqlalchemy.dialects.mysql import DATETIME
7 | from sqlalchemy.ext.declarative import declarative_base
8 | from sqlalchemy.orm import Session
9 | from sqlalchemy.sql.expression import update
10 | from core.middleware import g
11 |
12 | LOG = logging.getLogger(__name__)
13 |
14 |
15 | class HasPrivate(object):
16 | """Mixin that identifies a class as having private entities"""
17 |
18 | deleted = Column(Boolean, default=False, index=True)
19 |
20 |
21 | class _Model(HasPrivate):
22 | """
23 | Base Model: 包含了 Model 的共有字段和方法
24 | """
25 |
26 | created_at = Column(DATETIME, nullable=False, default=lambda: datetime.now())
27 | updated_at = Column(DATETIME, nullable=False, default=lambda: datetime.now(), onupdate=lambda: datetime.now())
28 | deleted_at = Column(DATETIME, nullable=False, default=lambda: datetime.now())
29 | # deleted = Column(Boolean, default=False, index=True)
30 |
31 | async def delete(self):
32 | """
33 | soft delte
34 | """
35 | await g.db.execute(
36 | update(self.__class__)
37 | .where(self.__class__.id == self.id)
38 | .values({"deleted": True, "deleted_at": datetime.now()})
39 | .execution_options(synchronize_session="evaluate")
40 | )
41 |
42 |
43 | Base = declarative_base(cls=_Model, name="Model")
44 |
--------------------------------------------------------------------------------
/core/utils/api_exception.py:
--------------------------------------------------------------------------------
1 | import json
2 | from starlette.responses import JSONResponse
3 | from starlette.requests import Request
4 |
5 |
6 | class APIException(Exception):
7 | """
8 | Http处理基类
9 | """
10 |
11 | def __init__(self, status_code=None, msg=None, error_id=None, message=None):
12 | """
13 |
14 | :param status_code:
15 | :param msg: deprecated ,use message
16 | :param error_id:
17 | :param message:
18 | """
19 | self.status_code = status_code
20 | self.message = message
21 | self.error_id = error_id
22 |
23 | def __repr__(self) -> str:
24 | class_name = self.__class__.__name__
25 | return f"{class_name}(status_code={self.status_code!r}, error_info={self.message!r})"
26 |
27 |
28 | async def http_exception_handler(request: Request, exc: APIException) -> JSONResponse:
29 | """
30 | 替换fastapi的except_handler
31 | """
32 | return JSONResponse(
33 | {"error_info": exc.message, "error_type": exc.error_id},
34 | status_code=exc.status_code,
35 | headers={"Content-Type": "application/json"},
36 | )
37 |
38 |
39 | class BadRequestException(APIException):
40 | def __init__(self, message):
41 | APIException.__init__(self, 400, error_id="error_bad_param", message=message)
42 |
43 |
44 | class ConflictException(APIException):
45 | def __init__(self, message=None):
46 | APIException.__init__(self, 409, error_id="error_already_exists", message=message)
47 |
48 |
49 | class UnAuthenticatedException(APIException):
50 | def __init__(self, message=None):
51 | APIException.__init__(self, 401, error_id="error_unauthenticated", message=message)
52 |
53 |
54 | class ForbiddenException(APIException):
55 | def __init__(self, message=None):
56 | APIException.__init__(self, 403, error_id="error_permission", message=message)
57 |
58 |
59 | class NotFoundException(APIException):
60 | def __init__(self, message=None):
61 | APIException.__init__(self, 404, error_id="error_not_found", message=message)
62 |
63 |
64 | class InternalServerError(APIException):
65 | def __init__(self, message=None):
66 | APIException.__init__(self, 500, error_id="Internal Server Error", message=message)
67 |
--------------------------------------------------------------------------------
/core/utils/enums.py:
--------------------------------------------------------------------------------
1 | class EnumTypeMeta(type):
2 | """
3 | 使用metaclass来使继承的枚举类具有新的特征
4 | 几个概念:
5 | 属性: 定义枚举类的属性
6 | 值: 枚举类属性的value
7 | 说明: 这个枚举值的中文解释
8 | 用法:
9 | class MyTest(EnumType):
10 | TestPeople = ("goofy", "a handsome guy")
11 | print(MyTest.TestPeople) 值 goofy
12 | print(MyTest.values) {属性:值} 字典
13 | print(MyTest.labels) {属性:说明} 字典
14 | print(MyTest.value_label) {值:说明} 字典
15 | 注意:
16 | 切记不要在代码中通过_values或其它属性改变枚举类定义的属性和值,虽然程序允许。
17 | """
18 |
19 | def __new__(cls, name, bases, attrs):
20 | _values = {} # {属性:值}
21 | _labels = {} # {属性:说明}
22 | _value_label = {} # {值:说明}
23 |
24 | for k, v in attrs.items():
25 | # 遇到私有属性跳过
26 | if k.startswith("__"):
27 | continue
28 | if isinstance(v, (tuple, list)) and len(v) == 2:
29 | _values[k] = v[0]
30 | _labels[k] = v[1]
31 | _value_label[v[0]] = v[1]
32 | elif isinstance(v, dict) and "label" in v and "value" in v:
33 | _values[k] = v["value"]
34 | _labels[k] = v["label"]
35 | _value_label[v["value"]] = v["label"]
36 | else:
37 | _values[k] = k
38 | _labels[k] = v
39 | _value_label[v] = None
40 | # 通过元类生成类对象时,传入_values,将类属性的值改写
41 | obj = type.__new__(cls, name, bases, _values)
42 | obj._values = _values
43 | obj._labels = _labels
44 | obj._value_label = _value_label
45 | return obj
46 |
47 | @property
48 | def values(self):
49 | return self._values
50 |
51 | @property
52 | def labels(self):
53 | return self._labels
54 |
55 | @property
56 | def value_label(self):
57 | return self._value_label
58 |
59 | def __eq__(self, other):
60 | print(123)
61 | if isinstance(other, str):
62 | return other == self.__str__()
63 | else:
64 | return hash(id(self)) == hash(id(other))
65 |
66 |
67 | class EnumType(metaclass=EnumTypeMeta):
68 | pass
69 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import uvloop
3 | import asyncio
4 | import logging
5 | import uvicorn
6 |
7 |
8 | from app.ws import create_ws_manager
9 | from core.utils.api_exception import http_exception_handler, APIException
10 | from core.config.common import config
11 | from core.middleware import register_http_middleware
12 | from core.storage import create_db
13 | from fastapi import FastAPI
14 |
15 | logging.basicConfig(
16 | level=logging.INFO,
17 | format="%(asctime)s %(levelname)s %(message)s",
18 | )
19 |
20 | sys.setrecursionlimit(1500)
21 |
22 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
23 |
24 |
25 | def configure_models():
26 | """
27 | add model to sqla metadata
28 | """
29 | # +gencode:configure-model
30 |
31 |
32 | def register_router(app: FastAPI):
33 | """
34 | 注册路由
35 | """
36 | # +gencode:register-router
37 | from app.ws.apis import router as ws_router
38 |
39 | app.include_router(ws_router, prefix="/wss")
40 |
41 |
42 | def init_sync(app):
43 | register_http_middleware(app)
44 | register_router(app)
45 | configure_models()
46 |
47 |
48 | def init_async():
49 | try:
50 | loop = asyncio.get_running_loop()
51 | except RuntimeError: # if cleanup: 'RuntimeError: There is no current event loop..'
52 | loop = None
53 |
54 | if loop and loop.is_running():
55 | loop.create_task(create_db())
56 |
57 | # 注册websocket处理程序
58 | loop.create_task(create_ws_manager())
59 |
60 |
61 | app = FastAPI(
62 | name=config.SERVICE_NAME,
63 | openapi_url="/v1/openapi/openapi.json",
64 | docs_url="/v1/openapi/docs",
65 | redoc_url="/v1/openapi/redoc",
66 | debug=not config.PROD,
67 | exception_handlers={APIException: http_exception_handler},
68 | )
69 |
70 |
71 | init_sync(app)
72 | init_async()
73 |
74 |
75 | if __name__ == "__main__":
76 | uvicorn.run("main:app", host="0.0.0.0", port=8080, log_level="debug", debug=False)
77 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | """fast
2 | 项目管理工具
3 | 用法:
4 | python manage.py [command] [args]
5 | 创建app:
6 | 描述:创建一个规范的app
7 | command: startapp
8 | args: app_name 模块名
9 | 创建i18n翻译:
10 | 描述:通过flask-babel自动生成或编译翻译文件
11 | command: babel
12 | args:
13 | add: 添加翻译文件,之后需要手动去给翻译文件增加中文翻译
14 | run: 编译翻译文件
15 | """
16 |
17 | import sys
18 |
19 | from core.manager import ManagementUtility
20 |
21 | # class FlaskBabelCommand(CommandBase):
22 | # """
23 | # i18n翻译
24 | # """
25 |
26 | # # 命令
27 | # command_str = "babel"
28 | # # 命令描述
29 | # command_desc = ["add: 生成翻译文件", "run: 编译翻译文件"]
30 |
31 | # @classmethod
32 | # def execute(cls, action):
33 | # if action == "add":
34 | # cls.add_babel()
35 | # elif action == "run":
36 | # cls.run_babel()
37 |
38 | # @classmethod
39 | # def add_babel(cls):
40 | # """
41 | # 创建翻译文件
42 | # """
43 | # if os.system("pybabel extract -F static/i18n/babel.cfg -k lazy_gettext -o static/i18n/messages.pot ."):
44 | # sys.stderr.write("生成messages.pot文件失败\n")
45 | # sys.exit(1)
46 | # # 是否已经创建了翻译文件
47 | # if not os.path.exists("static/i18n/zh"):
48 | # if os.system("pybabel init -i static/i18n/messages.pot -d static/i18n/ -l zh_CN"):
49 | # sys.stderr.write("生成翻译文件失败\n")
50 | # sys.exit(1)
51 | # if os.system("pybabel update -i static/i18n/messages.pot -d static/i18n/"):
52 | # sys.stderr.write("更新翻译文件失败\n")
53 | # sys.exit(1)
54 | # sys.stdout.write("\n***************** ")
55 | # sys.stdout.write("生成翻译文件成功,需要更新文件: static/i18n/zh_CN/LC_MESSAGES/messages.po")
56 | # sys.stdout.write(" *****************\n")
57 |
58 | # @classmethod
59 | # def run_babel(cls):
60 | # """
61 | # 编译翻译文件
62 | # """
63 | # if os.system("pybabel compile -d static/i18n/"):
64 | # sys.stderr.write("生成messages.pot文件失败")
65 | # sys.exit(1)
66 | # sys.stdout.write("\n***************** ")
67 | # sys.stdout.write("编译成功")
68 | # sys.stdout.write(" *****************\n")
69 |
70 |
71 | def execute_from_command_line(argv=None):
72 | """
73 | 创建管理工具实例,并执行命令
74 | """
75 | utility = ManagementUtility(argv)
76 | utility.execute()
77 |
78 |
79 | if __name__ == "__main__":
80 | execute_from_command_line(sys.argv)
81 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiomysql==0.0.21
2 | asgiref==3.4.1
3 | click==8.0.1
4 | fastapi==0.68.0
5 | greenlet==1.1.1
6 | h11==0.12.0
7 | httptools==0.2.0
8 | pycodestyle==2.7.0
9 | pydantic==1.8.2
10 | PyJWT==2.1.0
11 | PyMySQL==0.9.3
12 | python-dotenv==0.19.0
13 | PyYAML==5.4.1
14 | SQLAlchemy==1.4.22
15 | starlette==0.14.2
16 | toml==0.10.2
17 | typing-extensions==3.10.0.0
18 | uvicorn==0.14.0
19 | uvloop==0.15.3
20 | watchgod==0.7
21 | websockets==9.1
22 | Babel==2.9.1
23 |
--------------------------------------------------------------------------------