Skip to content

Commit a0f4747

Browse files
committed
v2.8.1: clear output dir for gen_openapi with --split, specify encoding (utf-8 by default) in env file reading, optimize file path and name handling in save() method, orm.Schema support using pk list in serialize() with orders, fix type resolve for many included field with concrete lookup
1 parent f8cb22f commit a0f4747

15 files changed

Lines changed: 128 additions & 50 deletions

File tree

docs/en/community/release.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ Release Date: 2025/9/10
77
### New features
88

99
* API support **Server-Sent Events (SSE)**: [Use SSE in API](../../guide/api-route#server-sent-events)
10-
* Clien support handling Server-Sent Events (SSE) response: [Handle SSE Response](../../guide/client#server-sent-events)
10+
* Client support handling Server-Sent Events (SSE) response: [Handle SSE Response](../../guide/client#handle-server-sent-events-sse)
1111
* `@api` decorator added `timeout` parameter to specifiy the timeout for API function processing time or Client request timeout.
12-
* Added HTTP cache plugin: `api.Cache`: can handle HTTP caching like `Last-Modifed` / `Etag` automatically
12+
* Added HTTP cache plugin: `api.Cache`: can handle HTTP caching like `Last-Modified` / `Etag` automatically
1313
* Added search parameter `orm.Search`, support various search mode for multiple model fields: [Search Param](../../guide/schema-query/#search-param)
1414
* `orm.Schema` support defining request context variables (such as current request user / IP) and query request context fields by passing the current request object to `context` param of the serialize methods: [Use context vars in query function](../../guide/schema-query/#relational-query-function)
1515
* `meta gen_openapi` support `--split` parameter that spliting the API document in seperate files by endpoint, convenient for AI tools to parse and read

docs/zh/community/release.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 版本发布记录
22

3-
## v2.8
3+
## v2.8.0
44

55
发布时间:2025/9/10
66

@@ -9,7 +9,7 @@
99
* API 接口支持 Server-Sent Events (SSE) 事件流响应: [API 中实现 SSE](../../guide/api-route#server-sent-events)
1010
* Client 客户端支持处理 Server-Sent Events (SSE) 等流式响应: [处理 SSE 响应](../../guide/client#server-sent-events)
1111
* `@api` 装饰器新增 `timeout` 参数,用于为 API 类的处理函数指定超时时间或为 Client 类的请求函数指定请求超时
12-
* 新增 HTTP 缓存插件 `api.Cache`:可以自动处理 Last-Modifed / Etag 等 HTTP 缓存机制
12+
* 新增 HTTP 缓存插件 `api.Cache`:可以自动处理 Last-Modified / Etag 等 HTTP 缓存机制
1313
* 新增搜索参数 `orm.Search`, 可以支持多种搜索模式对多个模型字段进行搜索匹配
1414
* `orm.Schema` 支持在查询函数中定义请求上下文参数(如当前请求用户 / IP),在序列化时通过 `context` 参数传当前请求对象实现请求上下文字段的查询
1515
* `meta gen_openapi` 命令支持 `--split` 参数,可以将 API 文档按照接口输出到独立的文件中,每个接口文档文件都包含接口的完整参数与响应定义,更方便 AI 工具解析读取

tests/test_1_orm/test_orm_schemas.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import utype.utils.exceptions
2+
13
from utilmeta.core import orm
24
import pytest
35
from django.db import models
@@ -19,6 +21,19 @@ def test_incorrect_related_model(self):
1921
class UserSchema(orm.Schema[User]):
2022
followers: List[ContentSchema]
2123

24+
def test_multiple_single_field_type(self):
25+
from app.models import User
26+
27+
class UserTestSchema(orm.Schema[User]):
28+
follower_names = orm.Field('followers.username')
29+
30+
ft = UserTestSchema.__parser__.get_field('follower_names').type
31+
assert getattr(ft, '__origin__', None) == list
32+
assert ft([1, 2]) == ['1', '2']
33+
34+
with pytest.raises(utype.utils.exceptions.ParseError):
35+
ft(['a' * 30])
36+
2237
def test_non_exists_fields(self):
2338
from app.models import User, Article
2439

tests/test_1_orm/test_schema_query.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -391,16 +391,32 @@ async def test_async_init_users_with_sync_query(self, service, db_using):
391391
assert user.top_2_articles[0].author_tag["name"] == "alice"
392392
assert user.top_2_articles[0].views == 103
393393

394+
def test_serialize_orders(self, service, db_using):
395+
from app.schema import ArticleSchema
396+
articles_1 = ArticleSchema.serialize(
397+
[1, 2, 3], context=orm.QueryContext(using=db_using)
398+
)
399+
articles_2 = ArticleSchema.serialize(
400+
[3, 2, 1], context=orm.QueryContext(using=db_using)
401+
)
402+
articles_3 = ArticleSchema.serialize(
403+
[2, 3, 1], context=orm.QueryContext(using=db_using)
404+
)
405+
assert [a.pk for a in articles_1] == [1, 2, 3]
406+
assert [a.pk for a in articles_2] == [3, 2, 1]
407+
assert [a.pk for a in articles_3] == [2, 3, 1]
408+
394409
@pytest.mark.asyncio
395410
async def test_async_serialize_articles(self, service, db_using):
396411
await self.refresh_db(db_using)
397412
from app.schema import ArticleSchema
398413
from app.models import Article
399414
articles = await ArticleSchema.aserialize(
400-
[1, 2], context=orm.QueryContext(using=db_using)
415+
[1, 2, 3], context=orm.QueryContext(using=db_using)
401416
)
402-
assert len(articles) == 2
403-
assert {a.pk for a in articles} == {1, 2}
417+
assert [a.pk for a in articles] == [1, 2, 3]
418+
# test pk order is equal to input list
419+
404420
articles = await ArticleSchema.aserialize(
405421
Article.objects.filter(
406422
slug='big-shot'

utilmeta/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
__website__ = "https://utilmeta.com"
22
__homepage__ = "https://utilmeta.com/py"
33
__author__ = "Xulin Zhou (@voidZXL)"
4-
__version__ = "2.8.0"
4+
__version__ = "2.8.1"
55

66

77
def version_info() -> str:

utilmeta/bin/meta.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def gen_openapi(
176176
else:
177177
to = f'openapi.{suffix}'
178178
if split:
179-
path = OpenAPI.split_to(openapi, to, format=format)
179+
path = OpenAPI.split_to(openapi, to, format=format, clear_dir=True)
180180
else:
181181
path = OpenAPI.save_to(openapi, to)
182182
print(f"OpenAPI document generated at {path}")

utilmeta/conf/env.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ def __init__(
1818
sys_env: Union[bool, str] = None,
1919
ref: str = None,
2020
file: str = None,
21+
encoding: str = 'utf-8',
2122
):
2223
self._data = data or {}
2324
self._sys_env = bool(sys_env)
2425
self._sys_env_prefix = sys_env if isinstance(sys_env, str) else ""
2526
self._ref = ref
2627
self._file = file
28+
self._encoding = encoding
2729
for items in (
2830
self._load_from_ref(),
2931
self._load_from_file(),
@@ -78,17 +80,17 @@ def _load_from_file(self) -> Mapping:
7880
self._file = rel_file
7981

8082
if self._file.endswith(".json"):
81-
return json.load(open(self._file, "r"))
83+
return json.load(open(self._file, "r", encoding=self._encoding))
8284

8385
if self._file.endswith(".yml") or self._file.endswith(".yaml"):
8486
from utilmeta.utils import requires
8587

8688
requires(yaml="pyyaml")
8789
import yaml
8890

89-
return yaml.safe_load(open(self._file, "r"))
91+
return yaml.safe_load(open(self._file, "r", encoding=self._encoding))
9092

91-
content = open(self._file, "r").read()
93+
content = open(self._file, "r", encoding=self._encoding).read()
9294
data = {}
9395
for line in content.splitlines():
9496
if not line.strip():

utilmeta/core/api/specs/openapi.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
import json
3939
import re
4040
import copy
41-
41+
from pathlib import Path
4242

4343
if TYPE_CHECKING:
4444
from utilmeta import UtilMeta
@@ -128,6 +128,18 @@ def normalize_dict(data: dict, key_orders: list):
128128
return sorted_dict
129129

130130

131+
def remove_files(directory, suffixes):
132+
directory = Path(directory)
133+
for suffix in suffixes:
134+
for file in directory.glob(f"*{suffix}"):
135+
if file.is_file():
136+
try:
137+
file.unlink()
138+
except Exception as e:
139+
print(f"Failed to delete {file}: {e}")
140+
continue
141+
142+
131143
class OpenAPIGenerator(JsonSchemaGenerator):
132144
DEFAULT_REF_PREFIX = "#/components/schemas/"
133145

@@ -726,12 +738,19 @@ def _resolve_ref(cls, obj, root, visited=None):
726738
return obj
727739

728740
@classmethod
729-
def split_to(cls, openapi: dict, directory: str, format: str = 'json', compressed: bool = False):
741+
def split_to(cls, openapi: dict, directory: str, format: str = 'json',
742+
compressed: bool = False,
743+
clear_dir: bool = False
744+
):
730745
if not isinstance(openapi, OpenAPISchema):
731746
try:
732747
openapi = OpenAPISchema(openapi)
733748
except Exception as e:
734749
raise e.__class__(f'Invalid openapi schema: {openapi}, raised error: {e}') from e
750+
751+
if clear_dir and os.path.exists(directory):
752+
remove_files(directory, suffixes=['.json'] if format == 'json' else ['.yml', '.yaml'])
753+
735754
os.makedirs(directory, exist_ok=True)
736755

737756
for path, methods in openapi.paths.items():

utilmeta/core/file/backends/base.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,33 @@ def filename(self):
5656
def filepath(self):
5757
return None
5858

59-
def save(self, path: str, name: str = None):
60-
file_path = path
61-
name = name or self.filename
62-
if name:
63-
if not os.path.exists(file_path):
64-
os.makedirs(file_path, exist_ok=True)
59+
def _get_file_path(self, path: str, name: str = None):
60+
file_path = path or ''
61+
# 1. path exist:
62+
# is dir: as dir
63+
# not dir: as target file
64+
65+
if os.path.exists(file_path):
6566
if os.path.isdir(file_path):
66-
file_path = os.path.join(file_path, name)
67+
name = name or self.filename
68+
if name:
69+
file_path = os.path.join(file_path, name)
70+
else:
71+
raise PermissionError(
72+
f"Attempt to write file to directory: {file_path}"
73+
)
6774
else:
68-
if os.path.isdir(file_path):
69-
raise PermissionError(
70-
f"Attempt to write file to directory: {file_path}"
71-
)
75+
if name:
76+
os.makedirs(file_path, exist_ok=True)
77+
file_path = os.path.join(file_path, name)
78+
else:
79+
base_path = os.path.dirname(file_path)
80+
# make sure base dir exists
81+
os.makedirs(base_path, exist_ok=True)
82+
return file_path
83+
84+
def save(self, path: str, name: str = None):
85+
file_path = self._get_file_path(path, name=name)
7286
with open(file_path, "wb") as fp:
7387
if self.seekable:
7488
self.object.seek(0)
@@ -80,14 +94,7 @@ def save(self, path: str, name: str = None):
8094
return file_path
8195

8296
async def asave(self, path: str, name: str = None):
83-
file_path = path
84-
name = name or self.filename
85-
if name:
86-
if not os.path.exists(file_path):
87-
os.makedirs(file_path, exist_ok=True)
88-
if os.path.isdir(file_path):
89-
file_path = os.path.join(file_path, name)
90-
97+
file_path = self._get_file_path(path, name=name)
9198
with open(file_path, "wb") as fp:
9299
if self.seekable:
93100
r = self.object.seek(0)

utilmeta/core/file/base.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,12 @@ def seekable(self):
9595
return self.adaptor.seekable
9696

9797
def save(self, path: str, name: str = None):
98-
return self.adaptor.save(path, name or self.filename)
98+
# not pass adaptor.filename as name
99+
# in case user want to just save to the path
100+
return self.adaptor.save(path, name or self._filename)
99101

100102
async def asave(self, path: str, name: str = None):
101-
return await self.adaptor.asave(path, name or self.filename)
103+
return await self.adaptor.asave(path, name or self._filename)
102104

103105
def __iter__(self):
104106
return iter(self.file)

0 commit comments

Comments
 (0)