├── .gitignore ├── LICENSE ├── README.md ├── image ├── otter.sql └── pip.txt ├── logs └── .gitkeep ├── tests ├── get_messages.py └── get_messages_thread.py └── viper ├── __init__.py ├── core ├── __init__.py ├── db.py ├── routes.py └── settings.py ├── models ├── __init__.py └── model_todo.py ├── services ├── __init__.py └── service_todo.py ├── utils ├── __init__.py ├── util_db.py ├── util_encrypt.py ├── util_json.py ├── util_log.py └── util_time.py ├── views ├── __init__.py └── view_todo.py └── wrappers ├── __init__.py └── response.py /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Development 107 | *.db 108 | .idea 109 | logs/* 110 | !logs/.gitkeep 111 | uploads/* 112 | !uploads/.gitkeep 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sun Geer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Otter 2 | 3 | *Async backend depends on the Starlette ASGI toolkit.* 4 | 5 | ## Installation 6 | 7 | clone: 8 | ``` 9 | $ git clone https://github.com/sungeer/otter.git 10 | $ cd otter 11 | ``` 12 | create & activate virtual env then install dependency: 13 | 14 | with venv + pip: 15 | ``` 16 | $ python -m venv venv 17 | $ source venv/bin/activate # use `venv\Scripts\activate` on Windows 18 | $ pip install -r requirements.txt 19 | ``` 20 | 21 | run: 22 | ``` 23 | $ granian --interface wsgi otter:app 24 | * Running on http://127.0.0.1:8000/ 25 | ``` 26 | 27 | ## License 28 | 29 | This project is licensed under the GPLv3 License (see the 30 | [LICENSE](LICENSE) file for details). 31 | -------------------------------------------------------------------------------- /image/otter.sql: -------------------------------------------------------------------------------- 1 | 2 | 3 | CREATE TABLE message ( 4 | id INT AUTO_INCREMENT, 5 | name VARCHAR(20) NOT NULL COMMENT '用户名', 6 | body VARCHAR(200) NOT NULL COMMENT '留言', 7 | is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除 0-未删除 1-已删除', 8 | create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', 9 | update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', 10 | 11 | PRIMARY KEY (id) 12 | ) COMMENT='留言表'; 13 | 14 | 15 | 16 | -- 初始化数据 17 | INSERT INTO message (name, body, create_time) VALUES 18 | ('Alice', 'Hello, this is Alice!', '2024-06-01 09:05:00'), 19 | ('Bob', 'Just checking in.', '2024-06-01 10:15:00'), 20 | ('Charlie', 'How is everyone?', '2024-06-01 11:25:00'), 21 | ('David', 'Good morning!', '2024-06-01 12:35:00'), 22 | ('Eve', 'Ready for the meeting?', '2024-06-01 13:45:00'), 23 | ('Frank', 'Let’s start the project.', '2024-06-01 14:55:00'), 24 | ('Grace', 'Can anyone help me?', '2024-06-01 15:05:00'), 25 | ('Heidi', 'I will be late today.', '2024-06-01 16:15:00'), 26 | ('Ivan', 'Check out this link.', '2024-06-01 17:25:00'), 27 | ('Judy', 'Lunch break?', '2024-06-01 18:35:00'), 28 | ('Mallory', 'See you all tomorrow.', '2024-06-02 09:05:00'), 29 | ('Niaj', 'This is a test message.', '2024-06-02 10:15:00'), 30 | ('Olivia', 'Meeting postponed.', '2024-06-02 11:25:00'), 31 | ('Peggy', 'Project completed!', '2024-06-02 12:35:00'), 32 | ('Quentin', 'Review the documents.', '2024-06-02 13:45:00'), 33 | ('Rupert', 'Great job, team!', '2024-06-02 14:55:00'), 34 | ('Sybil', 'Who is available now?', '2024-06-02 15:05:00'), 35 | ('Trudy', 'Deadline extended.', '2024-06-02 16:15:00'), 36 | ('Uma', 'Any questions?', '2024-06-02 17:25:00'), 37 | ('Victor', 'Let’s celebrate!', '2024-06-02 18:35:00'); 38 | 39 | -------------------------------------------------------------------------------- /image/pip.txt: -------------------------------------------------------------------------------- 1 | pip cache purge # 清除缓存 2 | 3 | pip freeze > requirements.txt 4 | pip install -r requirements.txt 5 | 6 | 7 | python -m pip install python-dotenv loguru starlette uvicorn 8 | 9 | 10 | python -m pip install SQLAlchemy asyncmy 11 | 12 | 13 | uvicorn viper:app --host 127.0.0.1 --port 7788 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sungeer/otter/66ce8e46ebff1cc3021c090b8afeaad4e20feb56/logs/.gitkeep -------------------------------------------------------------------------------- /tests/get_messages.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | def get_messages(): 5 | url = 'http://127.0.0.1:8000/messages' 6 | data = { 7 | 'content': '你好,这是一个测试消息', 8 | 'sender': 'user' 9 | } 10 | 11 | response = requests.post(url, json=data) 12 | return response 13 | 14 | 15 | if __name__ == '__main__': 16 | import time 17 | 18 | start_time = time.time() 19 | ret = get_messages() 20 | end_time = time.time() 21 | 22 | print('状态码:', ret.status_code) 23 | print('响应内容:', ret.text) 24 | 25 | elapsed_ms = (end_time - start_time) * 1000 26 | print('耗时:{:.2f} 毫秒'.format(elapsed_ms)) 27 | -------------------------------------------------------------------------------- /tests/get_messages_thread.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import threading 3 | 4 | url = "http://127.0.0.1:8000/messages" 5 | data = { 6 | "content": "你好,这是一个测试消息", 7 | "sender": "user" 8 | } 9 | 10 | 11 | def send_request(number): 12 | response = requests.post(url, json=data) 13 | print(f'线程{number} 状态码:{response.status_code} 响应内容:{response.text}') 14 | 15 | 16 | # 配置并发数和请求总数 17 | concurrent_num = 20 # 并发线程数 18 | request_total = 100 # 请求总次数 19 | 20 | threads = [] 21 | for i in range(request_total): 22 | t = threading.Thread(target=send_request, args=(i,)) 23 | threads.append(t) 24 | t.start() 25 | if (i + 1) % concurrent_num == 0: 26 | for t in threads: 27 | t.join() 28 | threads = [] 29 | 30 | # 等待剩余线程 31 | for t in threads: 32 | t.join() 33 | -------------------------------------------------------------------------------- /viper/__init__.py: -------------------------------------------------------------------------------- 1 | from starlette.requests import Request 2 | 3 | from viper.core.routes import response_for_path 4 | 5 | 6 | async def app(scope, receive, send): 7 | if scope['type'] == 'http': 8 | request = Request(scope, receive) 9 | method = request.method 10 | path = request.url.path 11 | query_string = str(request.url.query) 12 | headers = dict(request.headers) 13 | body = await request.body() # body.decode(errors='replace') 14 | 15 | response = await response_for_path(request) 16 | await response(scope, receive, send) 17 | elif scope['type'] == 'websocket': 18 | # 1. 接受连接 19 | while True: 20 | message = await receive() 21 | if message['type'] == 'websocket.connect': 22 | # 允许连接 23 | await send({'type': 'websocket.accept'}) 24 | elif message['type'] == 'websocket.receive': 25 | # 获取客户端发来的消息 26 | data = message.get('text') or message.get('bytes') 27 | print(f'WebSocket收到消息: {data}') 28 | 29 | # 回发消息给客户端 30 | await send({ 31 | 'type': 'websocket.send', 32 | 'text': f'你发来: {data}' 33 | }) 34 | elif message['type'] == 'websocket.disconnect': 35 | print('WebSocket断开') 36 | break 37 | if scope['type'] == 'lifespan': 38 | while True: 39 | message = await receive() 40 | if message['type'] == 'lifespan.startup': 41 | print('应用启动:初始化资源') 42 | # ...(初始化操作) 43 | await send({'type': 'lifespan.startup.complete'}) 44 | elif message['type'] == 'lifespan.shutdown': 45 | print('应用关闭:释放资源') 46 | # ...(清理操作) 47 | await send({'type': 'lifespan.shutdown.complete'}) 48 | break 49 | -------------------------------------------------------------------------------- /viper/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sungeer/otter/66ce8e46ebff1cc3021c090b8afeaad4e20feb56/viper/core/__init__.py -------------------------------------------------------------------------------- /viper/core/db.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import create_async_engine 2 | 3 | DATABASE_URL = "mysql+asyncmy://user:password@localhost:3306/dbname" 4 | 5 | engine = create_async_engine( 6 | DATABASE_URL, 7 | max_overflow=20, # 最大溢出 8 | pool_recycle=1800, 9 | ) 10 | -------------------------------------------------------------------------------- /viper/core/routes.py: -------------------------------------------------------------------------------- 1 | from viper.utils.util_log import logger 2 | from viper.wrappers.response import jsonify, abort 3 | from viper.views.view_todo import route_dict as todo_route 4 | 5 | routes = {} 6 | routes.update(todo_route) 7 | 8 | 9 | async def error_404(request): 10 | return abort(404) 11 | 12 | 13 | async def response_for_path(request): 14 | path = request.url.path 15 | view_func = routes.get(path, error_404) 16 | try: 17 | response = await view_func(request) 18 | except (Exception,): 19 | logger.exception('Internal Server Error') 20 | return abort(500) 21 | return response 22 | -------------------------------------------------------------------------------- /viper/core/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from dotenv import load_dotenv, find_dotenv 5 | 6 | load_dotenv(find_dotenv()) 7 | 8 | DEV_MODE = os.getenv('DEBUG') == '1' # None 9 | 10 | BASE_DIR = Path(__file__).resolve().parent.parent.parent 11 | 12 | LOG_DIR = BASE_DIR / 'logs' 13 | -------------------------------------------------------------------------------- /viper/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sungeer/otter/66ce8e46ebff1cc3021c090b8afeaad4e20feb56/viper/models/__init__.py -------------------------------------------------------------------------------- /viper/models/model_todo.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import text 2 | 3 | from viper.core.db import engine 4 | 5 | 6 | async def get_todos(): 7 | sql_str = ''' 8 | SELECT 9 | id, name 10 | FROM 11 | your_table 12 | WHERE 13 | id < :max_id 14 | ''' 15 | params = {'max_id': 10} 16 | 17 | async with engine.connect() as conn: 18 | result = await conn.execute(text(sql_str), params) 19 | # data is [] or [{'id': 1, 'name': 'a'}, {'id': 2, 'name': 'b'}] 20 | data = [dict(r) for r in result.mappings()] 21 | return data 22 | 23 | 24 | async def get_todo_by_id(): 25 | sql_str = ''' 26 | SELECT 27 | id, name 28 | FROM 29 | your_table 30 | WHERE 31 | id < :max_id 32 | LIMIT 1 33 | ''' 34 | params = {'max_id': 123} 35 | 36 | async with engine.connect() as conn: 37 | result = await conn.execute(text(sql_str), params) 38 | row = result.mappings().first() 39 | # data is None or {'id': 123, 'name': '张三'} 40 | data = None if row is None else dict(row) 41 | return data 42 | 43 | 44 | async def add_todo(): 45 | sql_str = ''' 46 | INSERT INTO your_table (name, age) 47 | VALUES (:name, :age) 48 | ''' 49 | params = {'name': '张三', 'age': 20} 50 | 51 | async with engine.begin() as conn: 52 | result = await conn.execute(text(sql_str), params) 53 | # rowcount = result.rowcount # 插入成功,受影响行数 54 | inserted_id = result.lastrowid # 自增主键id 55 | return inserted_id 56 | 57 | 58 | async def add_todos(): 59 | sql_str = ''' 60 | INSERT INTO your_table (name, age) 61 | VALUES (:name, :age) 62 | ''' 63 | data = [ 64 | {'name': '张三', 'age': 20}, 65 | {'name': '李四', 'age': 22}, 66 | {'name': '王五', 'age': 25}, 67 | ] 68 | 69 | async with engine.begin() as conn: 70 | result = await conn.execute(text(sql_str), data) 71 | rowcount = result.rowcount # 插入成功,受影响行数 72 | return rowcount 73 | 74 | 75 | async def del_todo(): 76 | sql_str = ''' 77 | DELETE FROM your_table 78 | WHERE id=:id 79 | ''' 80 | params = {'id': 5} 81 | 82 | async with engine.begin() as conn: 83 | result = await conn.execute(text(sql_str), params) 84 | rowcount = result.rowcount # 删除成功,受影响行数 85 | return rowcount 86 | 87 | 88 | async def update_todo(): 89 | sql_str = ''' 90 | UPDATE your_table 91 | SET name=:name, age=:age 92 | WHERE id=:id 93 | ''' 94 | params = {'name': '李四', 'age': 22, 'id': 3} 95 | 96 | async with engine.begin() as conn: 97 | result = await conn.execute(text(sql_str), params) 98 | rowcount = result.rowcount # 更新成功,受影响行数 99 | return rowcount 100 | -------------------------------------------------------------------------------- /viper/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sungeer/otter/66ce8e46ebff1cc3021c090b8afeaad4e20feb56/viper/services/__init__.py -------------------------------------------------------------------------------- /viper/services/service_todo.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | async def stream_data(): 5 | for i in range(5): 6 | await asyncio.sleep(1) 7 | yield {'progress': i * 20, 'info': f'step {i}'} 8 | yield {'progress': '', 'info': 'finish'} 9 | -------------------------------------------------------------------------------- /viper/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sungeer/otter/66ce8e46ebff1cc3021c090b8afeaad4e20feb56/viper/utils/__init__.py -------------------------------------------------------------------------------- /viper/utils/util_db.py: -------------------------------------------------------------------------------- 1 | import math 2 | import re 3 | 4 | 5 | def parse_limit_str(page_info=None): 6 | if page_info is None: 7 | page_info = {} 8 | page = int(page_info.get('page', 1)) 9 | page_size = int(page_info.get('rows', 20)) 10 | limit_str = f' LIMIT {(page - 1) * page_size}, {page_size} ' 11 | return limit_str 12 | 13 | 14 | def parse_update_str(table, p_key, p_id, update_dict): 15 | sql_str = f' UPDATE {table} SET ' 16 | temp_str = [] 17 | sql_values = {} 18 | for key, value in update_dict.items(): 19 | temp_str.append(f'{key} = :{key}') 20 | sql_values[key] = value 21 | sql_str += ', '.join(temp_str) + f' WHERE {p_key} = :p_id' 22 | sql_values['p_id'] = p_id 23 | return sql_str, sql_values 24 | 25 | 26 | def parse_where_str(filter_fields, where_dict): 27 | if not isinstance(filter_fields, (tuple, list)): 28 | filter_fields = (filter_fields,) 29 | where_str = ' WHERE 1 = :const ' 30 | where_values = {'const': 1} 31 | for key in filter_fields: 32 | value = where_dict.get(key) 33 | if value is not None: 34 | param_name = f'where_{key}' 35 | where_str += f' AND {key} = :{param_name} ' 36 | where_values[param_name] = value 37 | return where_str, where_values 38 | 39 | 40 | def parse_where_like_str(filter_fields, where_dict): 41 | if not isinstance(filter_fields, (tuple, list)): 42 | filter_fields = (filter_fields,) 43 | where_str = ' WHERE 1 = :const ' 44 | where_values = {'const': 1} 45 | for key in filter_fields: 46 | value = where_dict.get(key) 47 | if value is not None: 48 | param_name = f'like_{key}' 49 | where_str += f' AND {key} LIKE :{param_name} ' 50 | where_values[param_name] = f'%{value}%' 51 | return where_str, where_values 52 | 53 | 54 | def parse_count_str(sql_str, truncate=False): 55 | if truncate: 56 | if 'GROUP BY' in sql_str: 57 | sql_str = f'SELECT COUNT(*) total FROM ({sql_str}) AS TEMP' 58 | else: 59 | sql_str = re.sub(r'SELECT[\s\S]*?FROM', 'SELECT COUNT(*) total FROM', sql_str, count=1) 60 | if 'ORDER BY' in sql_str: 61 | sql_str = sql_str[:sql_str.find('ORDER BY')] 62 | if 'LIMIT' in sql_str: 63 | sql_str = sql_str[:sql_str.find('LIMIT')] 64 | return sql_str 65 | 66 | 67 | def get_page_info(total, page=1, per_page=20): 68 | pages = math.ceil(total / per_page) 69 | next_num = page + 1 if page < pages else None 70 | has_next = page < pages 71 | prev_num = page - 1 if page > 1 else None 72 | has_prev = page > 1 73 | page_info = { 74 | 'page': page, 75 | 'per_page': per_page, # 每页显示的记录数 76 | 'pages': pages, # 总页数 77 | 'total': total, 78 | 'next_num': next_num, 79 | 'has_next': has_next, 80 | 'prev_num': prev_num, 81 | 'has_prev': has_prev 82 | } 83 | return page_info 84 | 85 | 86 | if __name__ == '__main__': 87 | sql, params = parse_update_str('user', 'id', 3, {'name': 'newname', 'age': 22}) 88 | 89 | fields = ['name', 'age'] 90 | data = {'name': 'Alice', 'age': 25} 91 | where_str, where_params = parse_where_str(fields, data) 92 | 93 | fields = ['username', 'email'] 94 | data = {'username': 'Ali', 'email': 'gmail.com'} 95 | where_str, params = parse_where_like_str(fields, data) 96 | -------------------------------------------------------------------------------- /viper/utils/util_encrypt.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | 5 | def generate_password(length=12): 6 | if length < 12 or length > 64: 7 | raise ValueError('Password length must be between 12 and 64 characters.') 8 | 9 | # 定义字符集 10 | digits = string.digits 11 | lowers = string.ascii_lowercase 12 | uppers = string.ascii_uppercase 13 | specials = string.punctuation 14 | 15 | # 随机选择3种字符类型 16 | char_types = [digits, lowers, uppers, specials] 17 | selected_types = random.sample(char_types, 3) 18 | 19 | # 先保证每种都至少有一个 20 | password_chars = [random.choice(char_set) for char_set in selected_types] 21 | 22 | # 剩余的字符可从所有字符集中任选 23 | all_chars = ''.join(selected_types) 24 | password_chars += [random.choice(all_chars) for _ in range(length - 3)] 25 | 26 | # 打乱顺序 27 | random.shuffle(password_chars) 28 | return ''.join(password_chars) 29 | 30 | 31 | if __name__ == '__main__': 32 | pwd = generate_password() 33 | print(pwd) 34 | -------------------------------------------------------------------------------- /viper/utils/util_json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, date 3 | from decimal import Decimal 4 | 5 | 6 | def dict_to_json(data): 7 | return json.dumps(data, cls=JsonExtendEncoder, ensure_ascii=False) 8 | 9 | 10 | def dict_to_json_stream(data): 11 | return json.dumps(data, cls=JsonExtendEncoder, ensure_ascii=False).encode('utf-8') 12 | 13 | 14 | def json_to_dict(json_data): 15 | return json.loads(json_data) 16 | 17 | 18 | class JsonExtendEncoder(json.JSONEncoder): 19 | 20 | def default(self, obj): 21 | if isinstance(obj, datetime): 22 | return obj.strftime('%Y-%m-%d %H:%M:%S') 23 | elif isinstance(obj, date): 24 | return obj.strftime('%Y-%m-%d') 25 | elif isinstance(obj, Decimal): 26 | return float(obj) 27 | elif isinstance(obj, bytes): 28 | return obj.decode('utf-8') 29 | return super().default(obj) 30 | -------------------------------------------------------------------------------- /viper/utils/util_log.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from loguru import logger 4 | 5 | from viper.core import settings 6 | from viper.utils import util_time 7 | 8 | LOG_FILE = settings.LOG_DIR.joinpath(f'{util_time.get_now('date')}_viper.log') 9 | 10 | logger.remove() 11 | 12 | logger.add( 13 | LOG_FILE, 14 | rotation='50MB', 15 | # retention=1, # 只保留1个日志文件 16 | format='{time:YYYY-MM-DD HH:mm:ss.SSS} - {level} - {message}', # 日志格式 17 | encoding='utf-8', 18 | enqueue=True, # 启用异步日志处理 19 | level='INFO', 20 | diagnose=False, # 关闭变量值 21 | backtrace=False, # 关闭完整堆栈跟踪 22 | colorize=False 23 | ) 24 | 25 | if settings.DEV_MODE: 26 | logger.add( 27 | sink=sys.stdout, # 输出到标准输出流 28 | format='{time:YYYY-MM-DD HH:mm:ss.SSS} - {level} - {message}', 29 | enqueue=True, 30 | level='DEBUG', 31 | diagnose=False, 32 | backtrace=False, 33 | colorize=False 34 | ) 35 | -------------------------------------------------------------------------------- /viper/utils/util_time.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | 5 | def get_now(format_type: str) -> str: 6 | now = datetime.now() 7 | format_map = { 8 | 'datetime': lambda: now.strftime('%Y-%m-%d %H:%M:%S'), 9 | 'date': lambda: now.strftime('%Y-%m-%d'), 10 | 'time': lambda: now.strftime('%H:%M:%S'), 11 | 'timestamp': lambda: str(int(time.time() * 1000)), # 毫秒级时间戳 12 | 'filename': lambda: now.strftime('%Y%m%d_%H%M%S'), 13 | 'logfile': lambda: now.strftime('%Y-%m-%d_%H:%M:%S.%f'), 14 | } 15 | func = format_map.get(format_type, '') 16 | if not func: 17 | raise ValueError(f'Unsupported format_type: {format_type}') 18 | return func() 19 | 20 | 21 | # 时间戳 转 日期 22 | def timestamp_to_datetime(ts: int) -> str: 23 | return datetime.fromtimestamp(ts / 1000).strftime('%Y-%m-%d %H:%M:%S') 24 | 25 | 26 | if __name__ == '__main__': 27 | print(get_now('datetime')) # 2025-07-13 21:33:12 28 | print(get_now('date')) # 2025-07-13 29 | print(get_now('time')) # 21:33:12 30 | print(get_now('timestamp')) # 1713028392000 31 | print(get_now('filename')) # 20250713_213312 32 | print(get_now('logfile')) # 2025-07-23_10:06:20.127633 33 | 34 | print(timestamp_to_datetime(1713028392000)) # 2024-07-14 01:13:12 35 | -------------------------------------------------------------------------------- /viper/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sungeer/otter/66ce8e46ebff1cc3021c090b8afeaad4e20feb56/viper/views/__init__.py -------------------------------------------------------------------------------- /viper/views/view_todo.py: -------------------------------------------------------------------------------- 1 | from viper.wrappers.response import jsonify, sseify 2 | from viper.services import service_todo 3 | 4 | 5 | async def get_todos(request): 6 | data = {'abc': 'qaz'} 7 | return jsonify(data) 8 | 9 | 10 | async def sse_todo(): 11 | return sseify(service_todo.stream_data) 12 | 13 | 14 | route_dict = { 15 | '/api/get_todos': get_todos, 16 | } 17 | -------------------------------------------------------------------------------- /viper/wrappers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sungeer/otter/66ce8e46ebff1cc3021c090b8afeaad4e20feb56/viper/wrappers/__init__.py -------------------------------------------------------------------------------- /viper/wrappers/response.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from starlette.responses import JSONResponse 4 | from starlette.responses import StreamingResponse 5 | 6 | from viper.utils import util_json 7 | 8 | 9 | class JsonExtendResponse(JSONResponse): 10 | 11 | def render(self, content): 12 | return util_json.dict_to_json_stream(content) 13 | 14 | 15 | class BaseResponse: 16 | 17 | def __init__(self): 18 | self.status = True 19 | self.error_code = None 20 | self.message = None 21 | self.data = None 22 | 23 | def to_dict(self): 24 | resp_dict = { 25 | 'status': self.status, 26 | 'error_code': self.error_code, 27 | 'message': self.message, 28 | 'data': self.data 29 | } 30 | return resp_dict 31 | 32 | 33 | def jsonify(*args, **kwargs): 34 | if args and kwargs: 35 | raise TypeError('jsonify() behavior undefined when passed both args and kwargs') 36 | elif len(args) == 1: 37 | content = args[0] 38 | else: 39 | content = args or kwargs 40 | response = BaseResponse() 41 | response.data = content 42 | response = response.to_dict() 43 | return JsonExtendResponse(response) 44 | 45 | 46 | def abort(error_code, message=None): 47 | if not message: 48 | message = HTTPStatus(error_code).phrase 49 | response = BaseResponse() 50 | response.status = False 51 | response.error_code = error_code 52 | response.message = message 53 | response = response.to_dict() 54 | return JsonExtendResponse(response) 55 | 56 | 57 | def sseify(event_generator): 58 | async def wrapper(): 59 | async for data in event_generator(): 60 | # return f'data: {json.dumps(data)}\n\n' 61 | payload = BaseResponse() 62 | payload.data = data 63 | payload = payload.to_dict() 64 | sse_data = f'data: {util_json.dict_to_json(payload)}\n\n' 65 | yield sse_data 66 | return StreamingResponse(wrapper(), media_type='text/event-stream') 67 | --------------------------------------------------------------------------------