atinypgtool 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- atinypgtool/__init__.py +13 -0
- atinypgtool/core.py +196 -0
- atinypgtool/sql.py +105 -0
- atinypgtool/utils.py +56 -0
- atinypgtool-0.0.1.dist-info/METADATA +13 -0
- atinypgtool-0.0.1.dist-info/RECORD +9 -0
- atinypgtool-0.0.1.dist-info/WHEEL +5 -0
- atinypgtool-0.0.1.dist-info/licenses/LICENSE +21 -0
- atinypgtool-0.0.1.dist-info/top_level.txt +1 -0
atinypgtool/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from atinypgtool.core import close, close_all, init, with_cursor
|
|
3
|
+
from atinypgtool.sql import SQLHelper
|
|
4
|
+
from atinypgtool.utils import CursorPlaceholder
|
|
5
|
+
|
|
6
|
+
__all__ = (
|
|
7
|
+
'init',
|
|
8
|
+
'close',
|
|
9
|
+
'close_all',
|
|
10
|
+
'with_cursor',
|
|
11
|
+
'CursorPlaceholder',
|
|
12
|
+
'SQLHelper',
|
|
13
|
+
)
|
atinypgtool/core.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
import asyncio
|
|
3
|
+
import contextlib
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
import orjson
|
|
10
|
+
from psycopg import AsyncConnection, AsyncCursor
|
|
11
|
+
from psycopg.types.json import set_json_dumps, set_json_loads
|
|
12
|
+
from psycopg_pool import AsyncConnectionPool
|
|
13
|
+
from psycopg_pool.abc import AsyncKwargsParam
|
|
14
|
+
|
|
15
|
+
from atinypgtool.utils import ConfigureFunc, SequencePlaceholder
|
|
16
|
+
|
|
17
|
+
set_json_loads(orjson.loads)
|
|
18
|
+
set_json_dumps(orjson.dumps)
|
|
19
|
+
|
|
20
|
+
_NAMED_POOL_DICT: dict[str, AsyncConnectionPool] = {}
|
|
21
|
+
_POOL_CHECKER_TASK_DICT: dict[str, asyncio.Task[None]] = {}
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _check_pool_forever(
|
|
27
|
+
*,
|
|
28
|
+
name: str,
|
|
29
|
+
pool: AsyncConnectionPool,
|
|
30
|
+
interval: int,
|
|
31
|
+
) -> None:
|
|
32
|
+
while True:
|
|
33
|
+
await asyncio.sleep(interval)
|
|
34
|
+
try:
|
|
35
|
+
await pool.check()
|
|
36
|
+
except Exception as e:
|
|
37
|
+
logger.error(
|
|
38
|
+
'Postgres pool "%s" health check failed',
|
|
39
|
+
name,
|
|
40
|
+
exc_info=e,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ensure_pool_checker(
|
|
45
|
+
*,
|
|
46
|
+
name: str,
|
|
47
|
+
pool: AsyncConnectionPool,
|
|
48
|
+
interval: int,
|
|
49
|
+
) -> None:
|
|
50
|
+
task = _POOL_CHECKER_TASK_DICT.get(name)
|
|
51
|
+
if task and not task.done():
|
|
52
|
+
return
|
|
53
|
+
_POOL_CHECKER_TASK_DICT[name] = asyncio.create_task(
|
|
54
|
+
_check_pool_forever(name=name, pool=pool, interval=interval),
|
|
55
|
+
name=f'postgres-pool-checker-{name}',
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _stop_pool_checker(*, name: str) -> None:
|
|
60
|
+
task = _POOL_CHECKER_TASK_DICT.pop(name, None)
|
|
61
|
+
if not task:
|
|
62
|
+
return
|
|
63
|
+
task.cancel()
|
|
64
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
65
|
+
await task
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _stop_pool_checkers() -> None:
|
|
69
|
+
for name in tuple(_POOL_CHECKER_TASK_DICT):
|
|
70
|
+
await _stop_pool_checker(name=name)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def init(
|
|
74
|
+
*,
|
|
75
|
+
name: str,
|
|
76
|
+
dsn: str,
|
|
77
|
+
pool_size: int = 16,
|
|
78
|
+
open_timeout: int = 15,
|
|
79
|
+
health_check_interval: int = 0,
|
|
80
|
+
configure_funcs: typing.Sequence[ConfigureFunc] = SequencePlaceholder,
|
|
81
|
+
) -> None:
|
|
82
|
+
if name in _NAMED_POOL_DICT:
|
|
83
|
+
raise SyntaxError(f'Pool "{name}" already exists')
|
|
84
|
+
if pool_size < 1:
|
|
85
|
+
raise SyntaxError('Pool size should be greater than 0')
|
|
86
|
+
if health_check_interval < 0:
|
|
87
|
+
raise SyntaxError('Health check interval should not be less than 0')
|
|
88
|
+
minsize, maxsize = pool_size, pool_size
|
|
89
|
+
if minsize > 4:
|
|
90
|
+
minsize = 4
|
|
91
|
+
kwargs: AsyncKwargsParam = {
|
|
92
|
+
'autocommit': False,
|
|
93
|
+
# 新建连接超过 5 秒未完成时快速失败,避免请求长时间卡在重连上。
|
|
94
|
+
'connect_timeout': 5,
|
|
95
|
+
# 开启 TCP keepalive 探测,用于发现已经被网络或数据库断开的空闲连接。
|
|
96
|
+
'keepalives': 1,
|
|
97
|
+
# TCP 连接空闲 15 秒后发送第一次 keepalive 探测。
|
|
98
|
+
'keepalives_idle': 15,
|
|
99
|
+
# 第一次探测后,如果没有响应,每隔 5 秒继续重试探测。
|
|
100
|
+
'keepalives_interval': 5,
|
|
101
|
+
# 连续 3 次 keepalive 探测无响应后,将连接视为已断开。
|
|
102
|
+
'keepalives_count': 3,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
configure = None
|
|
106
|
+
if configure_funcs:
|
|
107
|
+
|
|
108
|
+
async def _configure(conn: AsyncConnection) -> None:
|
|
109
|
+
for func in configure_funcs:
|
|
110
|
+
if inspect.iscoroutinefunction(func):
|
|
111
|
+
await func(conn)
|
|
112
|
+
else:
|
|
113
|
+
func(conn)
|
|
114
|
+
|
|
115
|
+
configure = _configure
|
|
116
|
+
|
|
117
|
+
pool = AsyncConnectionPool(
|
|
118
|
+
conninfo=dsn,
|
|
119
|
+
min_size=minsize,
|
|
120
|
+
max_size=maxsize,
|
|
121
|
+
open=False,
|
|
122
|
+
configure=configure,
|
|
123
|
+
name=name,
|
|
124
|
+
kwargs=kwargs,
|
|
125
|
+
check=AsyncConnectionPool.check_connection,
|
|
126
|
+
num_workers=4,
|
|
127
|
+
timeout=5,
|
|
128
|
+
reconnect_timeout=30,
|
|
129
|
+
)
|
|
130
|
+
await pool.open(wait=True, timeout=open_timeout)
|
|
131
|
+
_NAMED_POOL_DICT[name] = pool
|
|
132
|
+
if health_check_interval > 0:
|
|
133
|
+
_ensure_pool_checker(
|
|
134
|
+
name=name,
|
|
135
|
+
pool=pool,
|
|
136
|
+
interval=health_check_interval,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def close(*, name: str) -> None:
|
|
141
|
+
if name not in _NAMED_POOL_DICT:
|
|
142
|
+
raise ValueError(f'Pool "{name}" not found')
|
|
143
|
+
pool = _NAMED_POOL_DICT.pop(name)
|
|
144
|
+
await _stop_pool_checker(name=name)
|
|
145
|
+
await pool.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def close_all() -> None:
|
|
149
|
+
await _stop_pool_checkers()
|
|
150
|
+
for pool in _NAMED_POOL_DICT.values():
|
|
151
|
+
await pool.close()
|
|
152
|
+
_NAMED_POOL_DICT.clear()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def with_cursor(*, name: str, transaction: bool) -> typing.Callable:
|
|
156
|
+
def wrapper(func: typing.Callable) -> typing.Callable:
|
|
157
|
+
argspec = inspect.getfullargspec(func)
|
|
158
|
+
if all('cursor' not in x for x in (argspec.args, argspec.kwonlyargs)):
|
|
159
|
+
raise SyntaxError('`cursor` is a required argument')
|
|
160
|
+
|
|
161
|
+
@functools.wraps(func)
|
|
162
|
+
async def wrapped(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
|
|
163
|
+
if name not in _NAMED_POOL_DICT:
|
|
164
|
+
raise SyntaxError(f'Pool "{name}" not found')
|
|
165
|
+
if 'cursor' in kwargs and kwargs['cursor']:
|
|
166
|
+
raise SyntaxError('`cursor` is a reserved argument')
|
|
167
|
+
async with _NAMED_POOL_DICT[name].connection() as conn: # noqa: SIM117
|
|
168
|
+
async with conn.cursor() as cursor:
|
|
169
|
+
kwargs['cursor'] = cursor
|
|
170
|
+
if transaction:
|
|
171
|
+
async with conn.transaction():
|
|
172
|
+
result = await func(*args, **kwargs)
|
|
173
|
+
else:
|
|
174
|
+
result = await func(*args, **kwargs)
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
return wrapped
|
|
178
|
+
|
|
179
|
+
return wrapper
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@contextlib.asynccontextmanager
|
|
183
|
+
async def with_cursor_context(
|
|
184
|
+
*,
|
|
185
|
+
name: str,
|
|
186
|
+
transaction: bool,
|
|
187
|
+
) -> typing.AsyncGenerator[AsyncCursor, None]:
|
|
188
|
+
if name not in _NAMED_POOL_DICT:
|
|
189
|
+
raise RuntimeError(f'Pool "{name}" not found')
|
|
190
|
+
async with _NAMED_POOL_DICT[name].connection() as conn: # noqa: SIM117
|
|
191
|
+
async with conn.cursor() as cursor:
|
|
192
|
+
if transaction:
|
|
193
|
+
async with conn.transaction():
|
|
194
|
+
yield cursor
|
|
195
|
+
else:
|
|
196
|
+
yield cursor
|
atinypgtool/sql.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import dataclasses
|
|
3
|
+
import inspect
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
from psycopg.sql import SQL
|
|
7
|
+
|
|
8
|
+
from atinypgtool.utils import SequencePlaceholder
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SQLHelper:
|
|
12
|
+
def __init__(self, base_model_cls: type | None = None) -> None:
|
|
13
|
+
if base_model_cls is not None:
|
|
14
|
+
try:
|
|
15
|
+
assert isinstance(base_model_cls, type)
|
|
16
|
+
assert dataclasses.is_dataclass(base_model_cls)
|
|
17
|
+
except AssertionError as e:
|
|
18
|
+
raise SyntaxError(
|
|
19
|
+
f'Base model "{base_model_cls}" '
|
|
20
|
+
f'should be a dataclass or None',
|
|
21
|
+
) from e
|
|
22
|
+
self._base_model_cls = base_model_cls
|
|
23
|
+
|
|
24
|
+
def _gen_fields(self, *, dataclass: type) -> tuple[list[str], list[str]]:
|
|
25
|
+
default_fields = []
|
|
26
|
+
if self._base_model_cls is not None and issubclass(
|
|
27
|
+
dataclass,
|
|
28
|
+
self._base_model_cls, # noqa: type: ignore
|
|
29
|
+
):
|
|
30
|
+
default_fields = [
|
|
31
|
+
i.strip('_')
|
|
32
|
+
for i in inspect.get_annotations(self._base_model_cls)
|
|
33
|
+
]
|
|
34
|
+
custom_fields = [
|
|
35
|
+
i.strip('_') for i in inspect.get_annotations(dataclass)
|
|
36
|
+
]
|
|
37
|
+
return default_fields, custom_fields
|
|
38
|
+
|
|
39
|
+
def gen_select_base(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
dataclass: type,
|
|
43
|
+
table_name: str,
|
|
44
|
+
) -> str:
|
|
45
|
+
default_fields, custom_fields = self._gen_fields(dataclass=dataclass)
|
|
46
|
+
return (
|
|
47
|
+
f'SELECT {", ".join(default_fields + custom_fields)} '
|
|
48
|
+
f'FROM {table_name}'
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def gen_get(self, *, dataclass: type, table_name: str) -> SQL:
|
|
52
|
+
default_fields, custom_fields = self._gen_fields(dataclass=dataclass)
|
|
53
|
+
return self.make_sql(
|
|
54
|
+
sql_str=(
|
|
55
|
+
f'SELECT {", ".join(default_fields + custom_fields)} '
|
|
56
|
+
f'FROM {table_name} WHERE id=%s'
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def gen_insert(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
dataclass: type,
|
|
64
|
+
table_name: str,
|
|
65
|
+
limited_fields: typing.Sequence[str] = SequencePlaceholder,
|
|
66
|
+
) -> SQL:
|
|
67
|
+
_, custom_fields = self._gen_fields(dataclass=dataclass)
|
|
68
|
+
if limited_fields and (
|
|
69
|
+
not set(limited_fields).issubset(set(custom_fields))
|
|
70
|
+
):
|
|
71
|
+
unexcepted = set(limited_fields) - set(custom_fields)
|
|
72
|
+
raise SyntaxError(f'Field names not allowed: {unexcepted}')
|
|
73
|
+
fields = limited_fields or custom_fields
|
|
74
|
+
return self.make_sql(
|
|
75
|
+
sql_str=(
|
|
76
|
+
f'INSERT INTO {table_name} ({", ".join(fields)}) '
|
|
77
|
+
f'VALUES ({", ".join(["%s"] * len(fields))})'
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def gen_insert_returning(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
dataclass: type,
|
|
85
|
+
table_name: str,
|
|
86
|
+
limited_fields: typing.Sequence[str] = SequencePlaceholder,
|
|
87
|
+
) -> SQL:
|
|
88
|
+
default_fields, custom_fields = self._gen_fields(dataclass=dataclass)
|
|
89
|
+
if limited_fields and (
|
|
90
|
+
not set(limited_fields).issubset(set(custom_fields))
|
|
91
|
+
):
|
|
92
|
+
unexcepted = set(limited_fields) - set(custom_fields)
|
|
93
|
+
raise SyntaxError(f'Field names not allowed: {unexcepted}')
|
|
94
|
+
fields = limited_fields or custom_fields
|
|
95
|
+
return self.make_sql(
|
|
96
|
+
sql_str=(
|
|
97
|
+
f'INSERT INTO {table_name} ({", ".join(fields)}) '
|
|
98
|
+
f'VALUES ({", ".join(["%s"] * len(fields))}) '
|
|
99
|
+
f'RETURNING {", ".join(default_fields + custom_fields)}'
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def make_sql(*, sql_str: str) -> SQL:
|
|
105
|
+
return SQL(obj=sql_str.strip()) # type: ignore
|
atinypgtool/utils.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import dataclasses
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from psycopg import AsyncConnection, AsyncCursor
|
|
6
|
+
from psycopg.pq import PGconn
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _SequencePlaceholder[T](typing.Sequence):
|
|
10
|
+
@typing.overload
|
|
11
|
+
def __getitem__(self, index: int, /) -> T: ...
|
|
12
|
+
|
|
13
|
+
@typing.overload
|
|
14
|
+
def __getitem__(self, index: slice, /) -> typing.Sequence[T]: ...
|
|
15
|
+
|
|
16
|
+
def __getitem__(self, _, /):
|
|
17
|
+
raise SyntaxError('SequencePlaceholder is empty')
|
|
18
|
+
|
|
19
|
+
def __len__(self) -> int:
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
def __bool__(self) -> bool:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SequencePlaceholder = _SequencePlaceholder[typing.Any]()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
type ConfigureFunc = typing.Callable[
|
|
30
|
+
[AsyncConnection],
|
|
31
|
+
typing.Awaitable | None,
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@typing.runtime_checkable
|
|
36
|
+
class DataclassInstance(typing.Protocol):
|
|
37
|
+
__dataclass_fields__: typing.ClassVar[
|
|
38
|
+
dict[str, dataclasses.Field[typing.Any]]
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _PGConnPlaceholder(PGconn):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _CursorPlaceholder(AsyncCursor):
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
super().__init__(
|
|
49
|
+
connection=AsyncConnection(pgconn=_PGConnPlaceholder()),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __bool__(self) -> bool:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
CursorPlaceholder = _CursorPlaceholder()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: atinypgtool
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: AsyncIO-based Tiny PostgreSQL Tool
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: orjson
|
|
9
|
+
Requires-Dist: psycopg[binary,pool]
|
|
10
|
+
Requires-Dist: psycopg_pool
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# ez-psycopg
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
atinypgtool/__init__.py,sha256=QfPBszp-bUE0ukn72992P9sQPJvt9AghPtFUYNJpnAs,293
|
|
2
|
+
atinypgtool/core.py,sha256=S0Jn2toXaGggSFw1hssmtsbiVPOipTAAih3uK_UJ3xA,5998
|
|
3
|
+
atinypgtool/sql.py,sha256=VW4DtGA_c_zqsag6WC8M_K0kNqnAccHz2K3ksTK6CL0,3586
|
|
4
|
+
atinypgtool/utils.py,sha256=tri9votbV-rnUH_VT8ompDGRS4c7pJ8pLdT-6AHZEh4,1186
|
|
5
|
+
atinypgtool-0.0.1.dist-info/licenses/LICENSE,sha256=tc4Ew4FFA8XrGfDzY3obl6RgMc-ORvOftbv-799gUYk,1072
|
|
6
|
+
atinypgtool-0.0.1.dist-info/METADATA,sha256=UqLVMKGkQd2eVZOv9yhqhCr41tCbeD0rnt_E4Hqhrws,307
|
|
7
|
+
atinypgtool-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
atinypgtool-0.0.1.dist-info/top_level.txt,sha256=RhTyPWKIJHqrmc1S-kHDio2Kc9LFFiN0XTAD9KSicAM,12
|
|
9
|
+
atinypgtool-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 M@gicAgCl(DEAD)
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
atinypgtool
|