python-mapper 0.3.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 z77777777777
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,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-mapper
3
+ Version: 0.3.0
4
+ Summary: PostgreSQL XML mapper with asyncpg pooling and implicit transactions
5
+ Author: z77777777777
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/z77777777777/python-mapper
8
+ Project-URL: Repository, https://github.com/z77777777777/python-mapper
9
+ Project-URL: Issues, https://github.com/z77777777777/python-mapper/issues
10
+ Keywords: postgresql,asyncpg,sql,mapper,mybatis,xml,async
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: SQL
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Database :: Front-Ends
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.12
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: Jinja2<4,>=3.1
24
+ Requires-Dist: asyncpg<1,>=0.31
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8; extra == "test"
27
+ Requires-Dist: pytest-asyncio>=1.0; extra == "test"
28
+ Dynamic: license-file
29
+
30
+ # python-mapper
31
+
32
+ [English](README.md) · [简体中文](README.zh-CN.md)
33
+
34
+ PostgreSQL-first XML mapper runtime backed directly by asyncpg. The package is
35
+ framework-neutral: it does not import FastAPI, a host application's settings,
36
+ logging formatter, schema, or business models.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install "git+https://github.com/z77777777777/python-mapper.git@v0.3.0"
42
+ ```
43
+
44
+ Requires Python 3.12+. The only runtime dependencies are `asyncpg` and `Jinja2`.
45
+
46
+ ## Package layout
47
+
48
+ - `database.py`: asyncpg pool configuration, startup, shutdown, and connection checkout.
49
+ - `extension.py`: application bootstrap, mapper-package scanning and pool lifespan.
50
+ - `base.py`: implicit connection and transaction propagation.
51
+ - `compiler.py`: safe `:name` to `$1` compilation and collection expansion.
52
+ - `plugins.py`: generic around-execution plugin contract.
53
+ - `pagination.py`: opt-in list pagination and stable page result models.
54
+ - `observability.py`: parameter-safe structured SQL logging plugin.
55
+ - `mapping.py`: `resultType`/`resultMap`, model validation, row materialization, and
56
+ strict 0..1 cardinality.
57
+ - `errors.py`: public framework exception hierarchy.
58
+ - `runtime.py`: current public facade plus XML loading, mapper binding, SQL rendering,
59
+ and execution.
60
+
61
+ `runtime.py` can later be separated into `builder`, `binding`, and `executor`, but only
62
+ after its shared dictionaries and load lock are owned by a `MapperRegistry`/`Configuration`
63
+ object. Splitting those functions first would replace one cohesive module with circular
64
+ imports and shared global state spread across several files.
65
+
66
+ ## Application setup
67
+
68
+ Wire everything once through the extension:
69
+
70
+ ```python
71
+ from pathlib import Path
72
+
73
+ from python_mapper import PyMapperExtension, SqlLoggingPlugin
74
+
75
+ pymapper = PyMapperExtension(
76
+ database_url="postgresql://user:password@localhost/database",
77
+ mapper_paths=[Path(__file__).resolve().parent / "mapper"],
78
+ mapper_packages=["app.repositories"],
79
+ plugins=[SqlLoggingPlugin(slow_query_threshold_ms=500)],
80
+ )
81
+
82
+ # Embed this lifespan in FastAPI, Starlette, a CLI worker, or your own host.
83
+ async def run_application() -> None:
84
+ async with pymapper.lifespan() as state:
85
+ print(state.statement_count)
86
+ await serve_application()
87
+ ```
88
+
89
+ The extension imports every module under `mapper_packages`, loads the XML, runs
90
+ startup-time contract validation, opens the pool, and closes it on exit. Lower-level
91
+ wiring remains available through `configure()`, `load_all_mappers()` and
92
+ `open_database()`. Use `reset_state()` for test isolation instead of clearing the
93
+ registry by hand.
94
+
95
+ ## Mapper declaration
96
+
97
+ ```python
98
+ from python_mapper import amapper, transactional
99
+
100
+ @amapper()
101
+ class OrdersMapper:
102
+ async def find(*, order_id: int | None = None) -> list: ...
103
+ async def update(*, order_id: int | None = None) -> int: ...
104
+ async def add_log(*, order_id: int | None = None) -> int: ...
105
+
106
+ @transactional()
107
+ async def update_order(order_id: int) -> None:
108
+ await OrdersMapper.update(order_id=order_id)
109
+ await OrdersMapper.add_log(order_id=order_id)
110
+ ```
111
+
112
+ Mapper methods do not accept a connection. A direct mapper call borrows one connection
113
+ without opening an explicit transaction; PostgreSQL commits that statement as its own
114
+ transaction. Calls inside `@transactional()` reuse the task-local connection and commit or
115
+ roll back together. `REQUIRED` and `REQUIRES_NEW` propagation are supported; a `REQUIRED`
116
+ participant inherits the outer transaction's isolation level, matching Spring/MyBatis
117
+ semantics.
118
+
119
+ Jinja blocks may control SQL structure, but values must use named binds
120
+ such as `:order_id`. `{{ value }}` interpolation is rejected while loading XML.
121
+ Only `if/elif/else/endif` Jinja tags are accepted; output, include, macro, loop,
122
+ assignment and filter tags fail during mapper loading. Native asyncpg `$n` binds
123
+ are also rejected because XML statements use one binding contract: `:name`.
124
+
125
+ ## Result mapping
126
+
127
+ - `resultType="module.Row"` maps by matching names: only fields the model declares as
128
+ constructor arguments are passed in. Extra columns returned by the SQL are ignored, and
129
+ optional fields the query did not return fall back to the model's defaults.
130
+ - `resultMap="rowMap"` declares `column -> property` explicitly. If an explicit property
131
+ does not exist on the model, `load_all_mappers()` fails during startup validation.
132
+ - Every `resultType` path is validated after all XML has loaded, so a typo in a dotted
133
+ path surfaces at startup rather than on the first query.
134
+ - `single="true"` is a strict 0..1 row contract: zero rows return `None`, one row returns
135
+ the object, and more than one raises `TooManyResultsError` — it never silently takes the
136
+ first row.
137
+
138
+ Framework exceptions live in `python_mapper.errors` and are re-exported from the package
139
+ root:
140
+
141
+ ```python
142
+ from python_mapper import PyMapperError, TooManyResultsError
143
+ ```
144
+
145
+ ## Optional pagination
146
+
147
+ Pagination is explicit and opt-in. A plain mapper call never rewrites your SQL:
148
+
149
+ ```xml
150
+ <select id="list_orders" countRef="count_orders" resultType="app.types.OrderRow">
151
+ SELECT id, order_no, status
152
+ FROM orders
153
+ WHERE status = :status
154
+ ORDER BY created_at DESC, id DESC
155
+ </select>
156
+
157
+ <select id="count_orders" expose="false">
158
+ SELECT COUNT(*) FROM orders WHERE status = :status
159
+ </select>
160
+ ```
161
+
162
+ ```python
163
+ from python_mapper import Page
164
+
165
+ class OrdersMapper:
166
+ async def list_orders(
167
+ *, status: str | None = None, page: int = 1, page_size: int = 30,
168
+ ) -> Page[OrderRow]: ...
169
+
170
+ page_result = await OrdersMapper.list_orders(
171
+ page=2,
172
+ status="active",
173
+ )
174
+ ```
175
+
176
+ When the return annotation is `Page[T]`, the mapper call returns a finished, web-framework
177
+ agnostic page object whose fields are fixed as `items / total / page / page_size / pages`.
178
+ The framework puts the `list[T]` returned by the SQL into the `Page`; callers never touch
179
+ `PaginationOptions`, `QueryResult` or `PageMetadata`. Methods that do not paginate keep
180
+ declaring and returning `list[T]`.
181
+
182
+ `query(..., pagination=PaginationOptions(...))` remains as a low-level entry point, for
183
+ dynamically disabling pagination or for slice/`has_next` cases with `include_total=False`.
184
+
185
+ - `page_size` defaults to 30 and is capped at 200.
186
+ - With `enabled=False` no pagination clause is added and no count runs; if the XML
187
+ declares `<page/>`, only that internal marker is removed before executing the
188
+ unpaginated SQL.
189
+ - A stable top-level `ORDER BY` is required whenever pagination is enabled.
190
+ - `include_total=True` requires an explicit `countRef`. With `False`, `has_next` is
191
+ determined by fetching one extra row and no count is executed.
192
+ - Without `<page/>` the pagination clause is appended at the end of the SQL. Use `<page/>`
193
+ when the insertion point matters, for example with `FOR UPDATE`. The marker must sit
194
+ after a complete top-level `ORDER BY` and before any `FOR` locking clause — never inside
195
+ select columns, a `WHERE`, a subquery, a string, or a comment.
196
+ - A hand-written top-level `LIMIT/OFFSET/FETCH` conflicts with enabled framework
197
+ pagination; with framework pagination disabled, hand-written pagination is preserved
198
+ exactly. Declaring `<page/>` together with hand-written pagination fails at XML load
199
+ time.
200
+ - This feature provides page-number `LIMIT/OFFSET` pagination. For deep paging over
201
+ millions of rows, disable it and implement keyset (cursor) pagination explicitly in your
202
+ own mapper — the framework will not guess your business cursor.
203
+
204
+ ## SQL execution plugins and logging
205
+
206
+ Hosts configure execution plugins through `plugins=[...]`. A plugin depends only on
207
+ `StatementContext`, `StatementResult` and `StatementPlugin`, so it can serve metrics,
208
+ tracing, auditing or read-only protection without depending on any web framework.
209
+
210
+ `SqlLoggingPlugin` emits a structured `LogRecord.pymapper` by default: statement id, final
211
+ asyncpg SQL, SQL fingerprint, elapsed time, connection wait, row count, parameter names
212
+ and parameter types. It never logs parameter values, and exceptions go through
213
+ `logger.exception` so the traceback is preserved. A host can lift `record.pymapper` into
214
+ its own JSON formatter payload.
215
+
216
+ Plugin instances are process-level configuration and may be reused across concurrent
217
+ requests. Custom plugins must be stateless, or guard their own mutable state.
218
+
219
+ ## Known pitfalls (read before writing code)
220
+
221
+ - **An empty expanding bind silently matches zero rows.** Passing an empty list to
222
+ `IN :ids` renders an "empty set" expression — no error, no matching rows. `NOT IN :ids`
223
+ with an empty list behaves the same way in reverse: nothing is excluded, so everything
224
+ passes. When a collection can legitimately be empty, branch in the caller or give the
225
+ parameter a non-optional default.
226
+ - **Result sets are fully materialized.** A mapper call pulls the whole result set into
227
+ memory before mapping rows. Always paginate or `LIMIT` large sets; streaming is
228
+ deliberately not offered, so that a cursor's lifetime is never leaked to the caller.
229
+ - **Never call a mapper from `asyncio.create_task` inside a transaction.** The child task
230
+ would either use the same connection concurrently (asyncpg raises) or use a connection
231
+ that has already been released. See the `transactional` docstring.
232
+ - **`:name::type` is rejected at load time.** Write `CAST(:name AS type)` instead.
233
+
234
+ ## Tests
235
+
236
+ ```bash
237
+ pip install -e ".[test]"
238
+ pytest
239
+ ```
240
+
241
+ The package's test fixtures snapshot and restore global state, so they can run alongside a
242
+ host project's own suite without polluting the host's mapper registry.
243
+
244
+ CI runs the suite on Windows and Linux across Python 3.12 / 3.13 / 3.14. No host
245
+ application, business database or web framework takes part in that matrix.
@@ -0,0 +1,216 @@
1
+ # python-mapper
2
+
3
+ [English](README.md) · [简体中文](README.zh-CN.md)
4
+
5
+ PostgreSQL-first XML mapper runtime backed directly by asyncpg. The package is
6
+ framework-neutral: it does not import FastAPI, a host application's settings,
7
+ logging formatter, schema, or business models.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install "git+https://github.com/z77777777777/python-mapper.git@v0.3.0"
13
+ ```
14
+
15
+ Requires Python 3.12+. The only runtime dependencies are `asyncpg` and `Jinja2`.
16
+
17
+ ## Package layout
18
+
19
+ - `database.py`: asyncpg pool configuration, startup, shutdown, and connection checkout.
20
+ - `extension.py`: application bootstrap, mapper-package scanning and pool lifespan.
21
+ - `base.py`: implicit connection and transaction propagation.
22
+ - `compiler.py`: safe `:name` to `$1` compilation and collection expansion.
23
+ - `plugins.py`: generic around-execution plugin contract.
24
+ - `pagination.py`: opt-in list pagination and stable page result models.
25
+ - `observability.py`: parameter-safe structured SQL logging plugin.
26
+ - `mapping.py`: `resultType`/`resultMap`, model validation, row materialization, and
27
+ strict 0..1 cardinality.
28
+ - `errors.py`: public framework exception hierarchy.
29
+ - `runtime.py`: current public facade plus XML loading, mapper binding, SQL rendering,
30
+ and execution.
31
+
32
+ `runtime.py` can later be separated into `builder`, `binding`, and `executor`, but only
33
+ after its shared dictionaries and load lock are owned by a `MapperRegistry`/`Configuration`
34
+ object. Splitting those functions first would replace one cohesive module with circular
35
+ imports and shared global state spread across several files.
36
+
37
+ ## Application setup
38
+
39
+ Wire everything once through the extension:
40
+
41
+ ```python
42
+ from pathlib import Path
43
+
44
+ from python_mapper import PyMapperExtension, SqlLoggingPlugin
45
+
46
+ pymapper = PyMapperExtension(
47
+ database_url="postgresql://user:password@localhost/database",
48
+ mapper_paths=[Path(__file__).resolve().parent / "mapper"],
49
+ mapper_packages=["app.repositories"],
50
+ plugins=[SqlLoggingPlugin(slow_query_threshold_ms=500)],
51
+ )
52
+
53
+ # Embed this lifespan in FastAPI, Starlette, a CLI worker, or your own host.
54
+ async def run_application() -> None:
55
+ async with pymapper.lifespan() as state:
56
+ print(state.statement_count)
57
+ await serve_application()
58
+ ```
59
+
60
+ The extension imports every module under `mapper_packages`, loads the XML, runs
61
+ startup-time contract validation, opens the pool, and closes it on exit. Lower-level
62
+ wiring remains available through `configure()`, `load_all_mappers()` and
63
+ `open_database()`. Use `reset_state()` for test isolation instead of clearing the
64
+ registry by hand.
65
+
66
+ ## Mapper declaration
67
+
68
+ ```python
69
+ from python_mapper import amapper, transactional
70
+
71
+ @amapper()
72
+ class OrdersMapper:
73
+ async def find(*, order_id: int | None = None) -> list: ...
74
+ async def update(*, order_id: int | None = None) -> int: ...
75
+ async def add_log(*, order_id: int | None = None) -> int: ...
76
+
77
+ @transactional()
78
+ async def update_order(order_id: int) -> None:
79
+ await OrdersMapper.update(order_id=order_id)
80
+ await OrdersMapper.add_log(order_id=order_id)
81
+ ```
82
+
83
+ Mapper methods do not accept a connection. A direct mapper call borrows one connection
84
+ without opening an explicit transaction; PostgreSQL commits that statement as its own
85
+ transaction. Calls inside `@transactional()` reuse the task-local connection and commit or
86
+ roll back together. `REQUIRED` and `REQUIRES_NEW` propagation are supported; a `REQUIRED`
87
+ participant inherits the outer transaction's isolation level, matching Spring/MyBatis
88
+ semantics.
89
+
90
+ Jinja blocks may control SQL structure, but values must use named binds
91
+ such as `:order_id`. `{{ value }}` interpolation is rejected while loading XML.
92
+ Only `if/elif/else/endif` Jinja tags are accepted; output, include, macro, loop,
93
+ assignment and filter tags fail during mapper loading. Native asyncpg `$n` binds
94
+ are also rejected because XML statements use one binding contract: `:name`.
95
+
96
+ ## Result mapping
97
+
98
+ - `resultType="module.Row"` maps by matching names: only fields the model declares as
99
+ constructor arguments are passed in. Extra columns returned by the SQL are ignored, and
100
+ optional fields the query did not return fall back to the model's defaults.
101
+ - `resultMap="rowMap"` declares `column -> property` explicitly. If an explicit property
102
+ does not exist on the model, `load_all_mappers()` fails during startup validation.
103
+ - Every `resultType` path is validated after all XML has loaded, so a typo in a dotted
104
+ path surfaces at startup rather than on the first query.
105
+ - `single="true"` is a strict 0..1 row contract: zero rows return `None`, one row returns
106
+ the object, and more than one raises `TooManyResultsError` — it never silently takes the
107
+ first row.
108
+
109
+ Framework exceptions live in `python_mapper.errors` and are re-exported from the package
110
+ root:
111
+
112
+ ```python
113
+ from python_mapper import PyMapperError, TooManyResultsError
114
+ ```
115
+
116
+ ## Optional pagination
117
+
118
+ Pagination is explicit and opt-in. A plain mapper call never rewrites your SQL:
119
+
120
+ ```xml
121
+ <select id="list_orders" countRef="count_orders" resultType="app.types.OrderRow">
122
+ SELECT id, order_no, status
123
+ FROM orders
124
+ WHERE status = :status
125
+ ORDER BY created_at DESC, id DESC
126
+ </select>
127
+
128
+ <select id="count_orders" expose="false">
129
+ SELECT COUNT(*) FROM orders WHERE status = :status
130
+ </select>
131
+ ```
132
+
133
+ ```python
134
+ from python_mapper import Page
135
+
136
+ class OrdersMapper:
137
+ async def list_orders(
138
+ *, status: str | None = None, page: int = 1, page_size: int = 30,
139
+ ) -> Page[OrderRow]: ...
140
+
141
+ page_result = await OrdersMapper.list_orders(
142
+ page=2,
143
+ status="active",
144
+ )
145
+ ```
146
+
147
+ When the return annotation is `Page[T]`, the mapper call returns a finished, web-framework
148
+ agnostic page object whose fields are fixed as `items / total / page / page_size / pages`.
149
+ The framework puts the `list[T]` returned by the SQL into the `Page`; callers never touch
150
+ `PaginationOptions`, `QueryResult` or `PageMetadata`. Methods that do not paginate keep
151
+ declaring and returning `list[T]`.
152
+
153
+ `query(..., pagination=PaginationOptions(...))` remains as a low-level entry point, for
154
+ dynamically disabling pagination or for slice/`has_next` cases with `include_total=False`.
155
+
156
+ - `page_size` defaults to 30 and is capped at 200.
157
+ - With `enabled=False` no pagination clause is added and no count runs; if the XML
158
+ declares `<page/>`, only that internal marker is removed before executing the
159
+ unpaginated SQL.
160
+ - A stable top-level `ORDER BY` is required whenever pagination is enabled.
161
+ - `include_total=True` requires an explicit `countRef`. With `False`, `has_next` is
162
+ determined by fetching one extra row and no count is executed.
163
+ - Without `<page/>` the pagination clause is appended at the end of the SQL. Use `<page/>`
164
+ when the insertion point matters, for example with `FOR UPDATE`. The marker must sit
165
+ after a complete top-level `ORDER BY` and before any `FOR` locking clause — never inside
166
+ select columns, a `WHERE`, a subquery, a string, or a comment.
167
+ - A hand-written top-level `LIMIT/OFFSET/FETCH` conflicts with enabled framework
168
+ pagination; with framework pagination disabled, hand-written pagination is preserved
169
+ exactly. Declaring `<page/>` together with hand-written pagination fails at XML load
170
+ time.
171
+ - This feature provides page-number `LIMIT/OFFSET` pagination. For deep paging over
172
+ millions of rows, disable it and implement keyset (cursor) pagination explicitly in your
173
+ own mapper — the framework will not guess your business cursor.
174
+
175
+ ## SQL execution plugins and logging
176
+
177
+ Hosts configure execution plugins through `plugins=[...]`. A plugin depends only on
178
+ `StatementContext`, `StatementResult` and `StatementPlugin`, so it can serve metrics,
179
+ tracing, auditing or read-only protection without depending on any web framework.
180
+
181
+ `SqlLoggingPlugin` emits a structured `LogRecord.pymapper` by default: statement id, final
182
+ asyncpg SQL, SQL fingerprint, elapsed time, connection wait, row count, parameter names
183
+ and parameter types. It never logs parameter values, and exceptions go through
184
+ `logger.exception` so the traceback is preserved. A host can lift `record.pymapper` into
185
+ its own JSON formatter payload.
186
+
187
+ Plugin instances are process-level configuration and may be reused across concurrent
188
+ requests. Custom plugins must be stateless, or guard their own mutable state.
189
+
190
+ ## Known pitfalls (read before writing code)
191
+
192
+ - **An empty expanding bind silently matches zero rows.** Passing an empty list to
193
+ `IN :ids` renders an "empty set" expression — no error, no matching rows. `NOT IN :ids`
194
+ with an empty list behaves the same way in reverse: nothing is excluded, so everything
195
+ passes. When a collection can legitimately be empty, branch in the caller or give the
196
+ parameter a non-optional default.
197
+ - **Result sets are fully materialized.** A mapper call pulls the whole result set into
198
+ memory before mapping rows. Always paginate or `LIMIT` large sets; streaming is
199
+ deliberately not offered, so that a cursor's lifetime is never leaked to the caller.
200
+ - **Never call a mapper from `asyncio.create_task` inside a transaction.** The child task
201
+ would either use the same connection concurrently (asyncpg raises) or use a connection
202
+ that has already been released. See the `transactional` docstring.
203
+ - **`:name::type` is rejected at load time.** Write `CAST(:name AS type)` instead.
204
+
205
+ ## Tests
206
+
207
+ ```bash
208
+ pip install -e ".[test]"
209
+ pytest
210
+ ```
211
+
212
+ The package's test fixtures snapshot and restore global state, so they can run alongside a
213
+ host project's own suite without polluting the host's mapper registry.
214
+
215
+ CI runs the suite on Windows and Linux across Python 3.12 / 3.13 / 3.14. No host
216
+ application, business database or web framework takes part in that matrix.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "python-mapper"
7
+ version = "0.3.0"
8
+ description = "PostgreSQL XML mapper with asyncpg pooling and implicit transactions"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "z77777777777" }]
14
+ keywords = ["postgresql", "asyncpg", "sql", "mapper", "mybatis", "xml", "async"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: SQL",
22
+ "Topic :: Database",
23
+ "Topic :: Database :: Front-Ends",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "Jinja2>=3.1,<4",
28
+ "asyncpg>=0.31,<1",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/z77777777777/python-mapper"
33
+ Repository = "https://github.com/z77777777777/python-mapper"
34
+ Issues = "https://github.com/z77777777777/python-mapper/issues"
35
+
36
+ [project.optional-dependencies]
37
+ # 包测试自带依赖声明: 换个环境跑 tests/ 不再靠宿主项目碰巧装了 pytest-asyncio
38
+ test = [
39
+ "pytest>=8",
40
+ "pytest-asyncio>=1.0",
41
+ ]
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ # py.typed 必须随 wheel 分发: 缺了它, 消费项目的 pyright/mypy 视本包为无类型,
47
+ # 全部导出退化成 Unknown;源码路径直连的宿主通常感知不到这个打包缺陷。
48
+ [tool.setuptools.package-data]
49
+ python_mapper = ["py.typed"]
50
+
51
+ [tool.pytest.ini_options]
52
+ asyncio_mode = "auto"
53
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,139 @@
1
+ """PostgreSQL XML mapper with an asyncpg pool and implicit transactions."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+
7
+ from python_mapper import runtime
8
+ from python_mapper.base import (
9
+ MapperBase,
10
+ bind_connection,
11
+ current_connection,
12
+ require_connection,
13
+ transactional,
14
+ transactional_scope,
15
+ )
16
+ from python_mapper.database import (
17
+ ConnectionLike,
18
+ PoolLike,
19
+ acquire_raw_connection,
20
+ close_database,
21
+ configure_database,
22
+ configure_pool,
23
+ get_pool,
24
+ open_database,
25
+ ping_database,
26
+ )
27
+ from python_mapper.errors import (
28
+ PaginationConflictError,
29
+ PaginationError,
30
+ PyMapperError,
31
+ TooManyResultsError,
32
+ )
33
+ from python_mapper.extension import MapperStartupState, PyMapperExtension
34
+ from python_mapper.observability import SqlLoggingPlugin
35
+ from python_mapper.pagination import (
36
+ DEFAULT_PAGE_SIZE,
37
+ MAX_PAGE_SIZE,
38
+ Page,
39
+ PageMetadata,
40
+ PaginationOptions,
41
+ QueryResult,
42
+ )
43
+ from python_mapper.plugins import (
44
+ StatementContext,
45
+ StatementExecutor,
46
+ StatementPlugin,
47
+ StatementResult,
48
+ )
49
+ from python_mapper.runtime import (
50
+ AMapper,
51
+ amapper,
52
+ configure_mapper_paths,
53
+ configure_plugins,
54
+ load_all_mappers,
55
+ load_mapper,
56
+ query,
57
+ render_sql,
58
+ reset_state,
59
+ scalar,
60
+ validate_result_types,
61
+ )
62
+
63
+ __version__ = "0.3.0"
64
+
65
+
66
+ def configure(
67
+ *,
68
+ mapper_paths: Sequence[str | Path],
69
+ database_url: str | None = None,
70
+ pool: PoolLike | None = None,
71
+ min_pool_size: int = 1,
72
+ max_pool_size: int = 10,
73
+ command_timeout: float | None = None,
74
+ ssl=None,
75
+ statement_cache_size: int = 100,
76
+ plugins: Sequence[StatementPlugin] = (),
77
+ ) -> None:
78
+ """Bind one database backend and the application's XML roots."""
79
+ if (database_url is None) == (pool is None):
80
+ raise ValueError("configure requires exactly one of database_url or pool")
81
+ if pool is not None:
82
+ configure_pool(pool)
83
+ else:
84
+ configure_database(
85
+ dsn=database_url or "",
86
+ min_size=min_pool_size,
87
+ max_size=max_pool_size,
88
+ command_timeout=command_timeout,
89
+ ssl=ssl,
90
+ statement_cache_size=statement_cache_size,
91
+ )
92
+ configure_mapper_paths(tuple(mapper_paths))
93
+ configure_plugins(tuple(plugins))
94
+
95
+
96
+ __all__ = [
97
+ "AMapper",
98
+ "ConnectionLike",
99
+ "DEFAULT_PAGE_SIZE",
100
+ "MAX_PAGE_SIZE",
101
+ "MapperBase",
102
+ "MapperStartupState",
103
+ "Page",
104
+ "PageMetadata",
105
+ "PaginationConflictError",
106
+ "PaginationError",
107
+ "PaginationOptions",
108
+ "PoolLike",
109
+ "PyMapperError",
110
+ "PyMapperExtension",
111
+ "QueryResult",
112
+ "SqlLoggingPlugin",
113
+ "StatementContext",
114
+ "StatementExecutor",
115
+ "StatementPlugin",
116
+ "StatementResult",
117
+ "TooManyResultsError",
118
+ "__version__",
119
+ "amapper",
120
+ "acquire_raw_connection",
121
+ "bind_connection",
122
+ "close_database",
123
+ "configure",
124
+ "current_connection",
125
+ "get_pool",
126
+ "load_all_mappers",
127
+ "load_mapper",
128
+ "render_sql",
129
+ "open_database",
130
+ "ping_database",
131
+ "query",
132
+ "require_connection",
133
+ "reset_state",
134
+ "runtime",
135
+ "scalar",
136
+ "transactional",
137
+ "transactional_scope",
138
+ "validate_result_types",
139
+ ]