python-mapper 0.3.0__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.
@@ -0,0 +1,212 @@
1
+ """asyncpg pool lifecycle owned by python-mapper."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ from collections.abc import AsyncGenerator, Callable
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import dataclass
9
+ from typing import Any, Protocol, cast
10
+
11
+ import asyncpg
12
+
13
+
14
+ class ConnectionLike(Protocol):
15
+ """Execution surface used by the mapper runtime and test doubles."""
16
+
17
+ async def execute(self, query: str, *args: Any, timeout: float | None = None) -> str: ...
18
+
19
+ async def fetch(self, query: str, *args: Any, timeout: float | None = None) -> list[Any]: ...
20
+
21
+ async def fetchval(
22
+ self,
23
+ query: str,
24
+ *args: Any,
25
+ column: int = 0,
26
+ timeout: float | None = None,
27
+ ) -> Any: ...
28
+
29
+ def transaction(
30
+ self,
31
+ *,
32
+ isolation: str | None = None,
33
+ readonly: bool = False,
34
+ deferrable: bool = False,
35
+ ) -> Any: ...
36
+
37
+
38
+ class PoolLike(Protocol):
39
+ """Pool surface required by the runtime."""
40
+
41
+ def acquire(self) -> Any: ...
42
+
43
+ async def close(self) -> None: ...
44
+
45
+
46
+ PoolFactory = Callable[..., Any]
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class DatabaseConfig:
51
+ dsn: str
52
+ min_size: int = 1
53
+ max_size: int = 10
54
+ command_timeout: float | None = None
55
+ ssl: Any = None
56
+ statement_cache_size: int = 100
57
+
58
+
59
+ _DATABASE_CONFIG: DatabaseConfig | None = None
60
+ _POOL: PoolLike | None = None
61
+ _POOL_OWNED = False
62
+ _POOL_FACTORY: PoolFactory = asyncpg.create_pool
63
+
64
+
65
+ def _encode_json(value: Any) -> str:
66
+ """Accept native JSON values and legacy pre-serialized JSON strings."""
67
+ if isinstance(value, str):
68
+ return value
69
+ return json.dumps(value, ensure_ascii=False, default=str)
70
+
71
+
72
+ async def _initialize_connection(connection: asyncpg.Connection) -> None:
73
+ """Decode PostgreSQL JSON/JSONB columns into native Python values."""
74
+ for type_name in ("json", "jsonb"):
75
+ await connection.set_type_codec(
76
+ type_name,
77
+ schema="pg_catalog",
78
+ encoder=_encode_json,
79
+ decoder=json.loads,
80
+ format="text",
81
+ )
82
+
83
+
84
+ def normalize_asyncpg_dsn(dsn: str) -> str:
85
+ """Convert SQLAlchemy-style PostgreSQL URLs into asyncpg-compatible DSNs."""
86
+ normalized = dsn.strip()
87
+ if normalized.startswith("postgresql+asyncpg://"):
88
+ return "postgresql://" + normalized[len("postgresql+asyncpg://"):]
89
+ if normalized.startswith("postgres+asyncpg://"):
90
+ return "postgres://" + normalized[len("postgres+asyncpg://"):]
91
+ return normalized
92
+
93
+
94
+ def configure_database(
95
+ *,
96
+ dsn: str,
97
+ min_size: int = 1,
98
+ max_size: int = 10,
99
+ command_timeout: float | None = None,
100
+ ssl: Any = None,
101
+ statement_cache_size: int = 100,
102
+ pool_factory: PoolFactory | None = None,
103
+ ) -> None:
104
+ """Register pool settings. Network connections are opened by ``open_database``."""
105
+ if _POOL is not None:
106
+ raise RuntimeError("cannot reconfigure python-mapper while its database pool is open")
107
+ if not dsn.strip():
108
+ raise ValueError("database dsn must not be empty")
109
+ if min_size < 0 or max_size < 1 or min_size > max_size:
110
+ raise ValueError("pool sizes must satisfy 0 <= min_size <= max_size")
111
+ global _DATABASE_CONFIG, _POOL_FACTORY
112
+ _DATABASE_CONFIG = DatabaseConfig(
113
+ dsn=normalize_asyncpg_dsn(dsn),
114
+ min_size=min_size,
115
+ max_size=max_size,
116
+ command_timeout=command_timeout,
117
+ ssl=ssl,
118
+ statement_cache_size=statement_cache_size,
119
+ )
120
+ if pool_factory is not None:
121
+ _POOL_FACTORY = pool_factory
122
+
123
+
124
+ def configure_pool(pool: PoolLike) -> None:
125
+ """Inject an externally managed pool, primarily for tests and host integration."""
126
+ global _POOL, _POOL_OWNED
127
+ if _POOL is pool:
128
+ return
129
+ if _POOL is not None:
130
+ raise RuntimeError("python-mapper database pool is already configured")
131
+ _POOL = pool
132
+ _POOL_OWNED = False
133
+
134
+
135
+ async def open_database() -> PoolLike:
136
+ """Open the configured asyncpg pool once during application startup."""
137
+ global _POOL, _POOL_OWNED
138
+ if _POOL is not None:
139
+ return _POOL
140
+ config = _DATABASE_CONFIG
141
+ if config is None:
142
+ raise RuntimeError("python-mapper database is not configured")
143
+ created = await _POOL_FACTORY(
144
+ dsn=config.dsn,
145
+ min_size=config.min_size,
146
+ max_size=config.max_size,
147
+ command_timeout=config.command_timeout,
148
+ ssl=config.ssl,
149
+ statement_cache_size=config.statement_cache_size,
150
+ init=_initialize_connection,
151
+ )
152
+ _POOL = cast(PoolLike, created)
153
+ _POOL_OWNED = True
154
+ return _POOL
155
+
156
+
157
+ def get_pool() -> PoolLike:
158
+ """Return the active pool or fail before any SQL is attempted."""
159
+ if _POOL is None:
160
+ raise RuntimeError("python-mapper database pool is not open; call open_database() at startup")
161
+ return _POOL
162
+
163
+
164
+ @asynccontextmanager
165
+ async def acquire_raw_connection() -> AsyncGenerator[ConnectionLike]:
166
+ """Borrow one connection without opening an explicit transaction."""
167
+ async with get_pool().acquire() as connection:
168
+ yield cast(ConnectionLike, connection)
169
+
170
+
171
+ async def ping_database() -> bool:
172
+ """Check the primary database through the same pool used by mappers."""
173
+ async with acquire_raw_connection() as connection:
174
+ return await connection.fetchval("SELECT 1") == 1
175
+
176
+
177
+ async def close_database(*, timeout: float = 10.0) -> None:
178
+ """Close an owned pool during application shutdown."""
179
+ global _POOL, _POOL_OWNED
180
+ pool, owned = _POOL, _POOL_OWNED
181
+ _POOL = None
182
+ _POOL_OWNED = False
183
+ if pool is None or not owned:
184
+ return
185
+ await asyncio.wait_for(pool.close(), timeout=timeout)
186
+
187
+
188
+ def clear_database_configuration() -> None:
189
+ """Clear unopened configuration and injected test pools."""
190
+ global _DATABASE_CONFIG, _POOL, _POOL_OWNED, _POOL_FACTORY
191
+ if _POOL is not None and _POOL_OWNED:
192
+ raise RuntimeError("close_database() must be awaited before resetting an owned pool")
193
+ _DATABASE_CONFIG = None
194
+ _POOL = None
195
+ _POOL_OWNED = False
196
+ _POOL_FACTORY = asyncpg.create_pool
197
+
198
+
199
+ __all__ = [
200
+ "ConnectionLike",
201
+ "DatabaseConfig",
202
+ "PoolLike",
203
+ "acquire_raw_connection",
204
+ "clear_database_configuration",
205
+ "close_database",
206
+ "configure_database",
207
+ "configure_pool",
208
+ "get_pool",
209
+ "normalize_asyncpg_dsn",
210
+ "open_database",
211
+ "ping_database",
212
+ ]
@@ -0,0 +1,26 @@
1
+ """Public exception types raised by python-mapper."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class PyMapperError(RuntimeError):
6
+ """Base class for python-mapper runtime errors."""
7
+
8
+
9
+ class TooManyResultsError(PyMapperError):
10
+ """A ``single=true`` statement returned more than one database row."""
11
+
12
+
13
+ class PaginationError(PyMapperError):
14
+ """A paginated query declaration or invocation is invalid."""
15
+
16
+
17
+ class PaginationConflictError(PaginationError):
18
+ """Framework pagination conflicts with a manual SQL pagination clause."""
19
+
20
+
21
+ __all__ = [
22
+ "PaginationConflictError",
23
+ "PaginationError",
24
+ "PyMapperError",
25
+ "TooManyResultsError",
26
+ ]
@@ -0,0 +1,140 @@
1
+ """Application bootstrap for mapper discovery, validation and pool lifecycle."""
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+ import logging
6
+ import pkgutil
7
+ from collections.abc import AsyncGenerator, Sequence
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from types import ModuleType
12
+ from typing import Any
13
+
14
+ from python_mapper.database import (
15
+ PoolLike,
16
+ close_database,
17
+ configure_database,
18
+ configure_pool,
19
+ open_database,
20
+ ping_database,
21
+ )
22
+ from python_mapper.plugins import StatementPlugin
23
+ from python_mapper.runtime import configure_mapper_paths, configure_plugins, load_all_mappers
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class MapperStartupState:
30
+ """Observable result of one mapper application startup."""
31
+
32
+ statement_count: int
33
+ database_ready: bool
34
+ database_error: Exception | None = None
35
+
36
+
37
+ def _import_mapper_package(package_ref: str | ModuleType) -> tuple[str, ...]:
38
+ package = importlib.import_module(package_ref) if isinstance(package_ref, str) else package_ref
39
+ imported = [package.__name__]
40
+ package_paths = getattr(package, "__path__", None)
41
+ if package_paths is None:
42
+ return tuple(imported)
43
+ prefix = f"{package.__name__}."
44
+ module_names = sorted(
45
+ module_info.name
46
+ for module_info in pkgutil.walk_packages(package_paths, prefix=prefix)
47
+ )
48
+ for module_name in module_names:
49
+ importlib.import_module(module_name)
50
+ imported.extend(module_names)
51
+ return tuple(imported)
52
+
53
+
54
+ class PyMapperExtension:
55
+ """Configure python-mapper once and own its application lifecycle.
56
+
57
+ Construction only records configuration; it never performs network I/O.
58
+ ``lifespan()`` imports every configured mapper package, validates all XML,
59
+ opens the asyncpg pool and closes the owned pool on exit.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ mapper_paths: Sequence[str | Path],
66
+ mapper_packages: Sequence[str | ModuleType] = (),
67
+ database_url: str | None = None,
68
+ pool: PoolLike | None = None,
69
+ min_pool_size: int = 1,
70
+ max_pool_size: int = 10,
71
+ command_timeout: float | None = None,
72
+ ssl: Any = None,
73
+ statement_cache_size: int = 100,
74
+ plugins: Sequence[StatementPlugin] = (),
75
+ ) -> None:
76
+ if (database_url is None) == (pool is None):
77
+ raise ValueError("PyMapperExtension requires exactly one of database_url or pool")
78
+ self._mapper_packages = tuple(mapper_packages)
79
+ self._imported_modules: tuple[str, ...] = ()
80
+ if pool is not None:
81
+ configure_pool(pool)
82
+ else:
83
+ configure_database(
84
+ dsn=database_url or "",
85
+ min_size=min_pool_size,
86
+ max_size=max_pool_size,
87
+ command_timeout=command_timeout,
88
+ ssl=ssl,
89
+ statement_cache_size=statement_cache_size,
90
+ )
91
+ configure_mapper_paths(tuple(mapper_paths))
92
+ configure_plugins(tuple(plugins))
93
+
94
+ @property
95
+ def imported_modules(self) -> tuple[str, ...]:
96
+ return self._imported_modules
97
+
98
+ def load_mappers(self) -> int:
99
+ """Import configured packages and validate every mapper/XML contract."""
100
+ imported: list[str] = []
101
+ for package_ref in self._mapper_packages:
102
+ imported.extend(_import_mapper_package(package_ref))
103
+ self._imported_modules = tuple(dict.fromkeys(imported))
104
+ return load_all_mappers()
105
+
106
+ async def startup(self, *, require_database: bool = True) -> MapperStartupState:
107
+ """Load mappers and open the database pool.
108
+
109
+ ``require_database=False`` is intended for applications whose health
110
+ endpoint must report an unavailable database while the HTTP process stays
111
+ alive. XML/import errors always fail startup because they are code defects.
112
+ """
113
+ statement_count = self.load_mappers()
114
+ try:
115
+ await open_database()
116
+ database_ready = await ping_database()
117
+ return MapperStartupState(statement_count, database_ready)
118
+ except Exception as error:
119
+ logger.exception("python-mapper database startup failed")
120
+ if require_database:
121
+ raise
122
+ return MapperStartupState(statement_count, False, error)
123
+
124
+ async def shutdown(self) -> None:
125
+ await close_database()
126
+
127
+ @asynccontextmanager
128
+ async def lifespan(
129
+ self,
130
+ *,
131
+ require_database: bool = True,
132
+ ) -> AsyncGenerator[MapperStartupState]:
133
+ """Framework-neutral lifespan usable by FastAPI and other ASGI hosts."""
134
+ try:
135
+ yield await self.startup(require_database=require_database)
136
+ finally:
137
+ await self.shutdown()
138
+
139
+
140
+ __all__ = ["MapperStartupState", "PyMapperExtension"]
@@ -0,0 +1,210 @@
1
+ """MyBatis-style result declarations, validation, and row materialization."""
2
+ from __future__ import annotations
3
+
4
+ import importlib
5
+ import inspect
6
+ import xml.etree.ElementTree as ET
7
+ from dataclasses import fields, is_dataclass
8
+ from pathlib import Path
9
+ from typing import Any, NamedTuple
10
+
11
+ from python_mapper.errors import TooManyResultsError
12
+
13
+
14
+ class ResultSpec(NamedTuple):
15
+ dotted: str
16
+ column_map: dict[str, str] | None
17
+ single: bool
18
+
19
+
20
+ # XML result declarations and imported model types are owned by this module.
21
+ _RESULT_SPEC: dict[str, ResultSpec] = {}
22
+ _RESULT_MAP_DEFS: dict[str, tuple[str, dict[str, str]]] = {}
23
+ _TYPE_CACHE: dict[str, type] = {}
24
+
25
+
26
+ def clear_result_mappings() -> None:
27
+ """Clear all XML-derived result declarations and resolved model classes."""
28
+ _RESULT_SPEC.clear()
29
+ _RESULT_MAP_DEFS.clear()
30
+ _TYPE_CACHE.clear()
31
+
32
+
33
+ def load_result_map(namespace: str, elem: ET.Element, file: Path) -> None:
34
+ """Register one explicit ``resultMap`` declaration from mapper XML."""
35
+ map_id = elem.attrib.get("id")
36
+ dotted = elem.attrib.get("type")
37
+ if not map_id or not dotted:
38
+ raise ValueError(f"<resultMap> 必须带 id 与 type: {file}")
39
+ key = f"{namespace}.{map_id}"
40
+ if key in _RESULT_MAP_DEFS:
41
+ raise ValueError(f"<resultMap> id 重复: {key}")
42
+ column_map: dict[str, str] = {}
43
+ for sub in elem:
44
+ if sub.tag not in ("result", "id"):
45
+ raise ValueError(
46
+ f"<resultMap {map_id}> 含不支持的子元素 <{sub.tag}> "
47
+ f"({file.name}; 只支持 <result>/<id>)"
48
+ )
49
+ column, property_name = sub.attrib.get("column"), sub.attrib.get("property")
50
+ if not column or not property_name:
51
+ raise ValueError(
52
+ f"<resultMap {map_id}> 的 <{sub.tag}> 必须带 column 与 property "
53
+ f"({file.name})"
54
+ )
55
+ column_map[column] = property_name
56
+ _RESULT_MAP_DEFS[key] = (dotted, column_map)
57
+
58
+
59
+ def load_result_spec(
60
+ namespace: str,
61
+ full_id: str,
62
+ elem: ET.Element,
63
+ file: Path,
64
+ ) -> None:
65
+ """Register ``resultType``/``resultMap`` and strict single-row semantics."""
66
+ result_map = elem.attrib.get("resultMap")
67
+ result_type = elem.attrib.get("resultType")
68
+ single = str(elem.attrib.get("single", "")).lower() in ("1", "true", "yes")
69
+ if result_map and result_type:
70
+ raise ValueError(
71
+ f"条目 '{full_id}' ({file.name}) 不能同时给 resultMap 与 resultType"
72
+ )
73
+ if result_map:
74
+ key = f"{namespace}.{result_map}"
75
+ if key not in _RESULT_MAP_DEFS:
76
+ available = sorted(
77
+ result_key.split(".")[-1]
78
+ for result_key in _RESULT_MAP_DEFS
79
+ if result_key.startswith(namespace + ".")
80
+ )
81
+ raise ValueError(
82
+ f"条目 '{full_id}' ({file.name}) 的 resultMap=\"{result_map}\" 未声明 "
83
+ f"(可用: {available})"
84
+ )
85
+ dotted, column_map = _RESULT_MAP_DEFS[key]
86
+ _RESULT_SPEC[full_id] = ResultSpec(dotted, column_map, single)
87
+ elif result_type:
88
+ _RESULT_SPEC[full_id] = ResultSpec(result_type, None, single)
89
+ elif single:
90
+ _RESULT_SPEC[full_id] = ResultSpec("", None, True)
91
+
92
+
93
+ def _resolve_type(dotted: str, full_id: str) -> type:
94
+ cached = _TYPE_CACHE.get(dotted)
95
+ if cached is not None:
96
+ return cached
97
+ module_path, _, class_name = dotted.rpartition(".")
98
+ if not module_path:
99
+ raise ValueError(
100
+ f"条目 '{full_id}' 的 resultType '{dotted}' 不是合法点路径(要 模块.类名)"
101
+ )
102
+ try:
103
+ model_class = getattr(importlib.import_module(module_path), class_name)
104
+ except (ImportError, AttributeError) as exc:
105
+ raise ValueError(
106
+ f"条目 '{full_id}' 的 resultType '{dotted}' 无法导入: {exc}"
107
+ ) from exc
108
+ if not inspect.isclass(model_class):
109
+ raise ValueError(
110
+ f"条目 '{full_id}' 的 resultType '{dotted}' 指向的不是类: "
111
+ f"{type(model_class).__name__}"
112
+ )
113
+ _TYPE_CACHE[dotted] = model_class
114
+ return model_class
115
+
116
+
117
+ def _model_init_fields(model_class: type) -> frozenset[str] | None:
118
+ """Return constructor fields; ``None`` means arbitrary kwargs are accepted."""
119
+ if is_dataclass(model_class):
120
+ return frozenset(field.name for field in fields(model_class) if field.init)
121
+
122
+ pydantic_fields = getattr(model_class, "model_fields", None)
123
+ if isinstance(pydantic_fields, dict):
124
+ return frozenset(pydantic_fields)
125
+
126
+ annotations: dict[str, Any] = {}
127
+ for base_class in reversed(model_class.__mro__):
128
+ annotations.update(getattr(base_class, "__annotations__", {}))
129
+ if annotations:
130
+ return frozenset(annotations)
131
+
132
+ try:
133
+ parameters = inspect.signature(model_class).parameters.values()
134
+ except (TypeError, ValueError):
135
+ return frozenset()
136
+ if any(parameter.kind is parameter.VAR_KEYWORD for parameter in parameters):
137
+ return None
138
+ return frozenset(
139
+ parameter.name
140
+ for parameter in parameters
141
+ if parameter.kind
142
+ in (parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY)
143
+ )
144
+
145
+
146
+ def _validate_result_spec(full_id: str, spec: ResultSpec) -> None:
147
+ """Validate type paths and properties explicitly forced by ``resultMap``."""
148
+ if not spec.dotted:
149
+ return
150
+ model_class = _resolve_type(spec.dotted, full_id)
151
+ if spec.column_map is None:
152
+ return
153
+ model_fields = _model_init_fields(model_class)
154
+ if model_fields is None:
155
+ return
156
+ unknown_properties = sorted(set(spec.column_map.values()) - model_fields)
157
+ if unknown_properties:
158
+ raise ValueError(
159
+ f"条目 '{full_id}' 的 resultMap 显式映射了模型 {spec.dotted} 不存在的属性 "
160
+ f"{unknown_properties} (可用: {sorted(model_fields)})"
161
+ )
162
+
163
+
164
+ def validate_result_types() -> None:
165
+ """Validate every result type after all mapper XML files have loaded."""
166
+ for full_id, spec in list(_RESULT_SPEC.items()):
167
+ _validate_result_spec(full_id, spec)
168
+
169
+
170
+ def shape_rows(full_id: str, rows: list[Any]) -> Any:
171
+ """Auto-map known fields, ignore extra columns, and enforce result cardinality."""
172
+ spec = _RESULT_SPEC.get(full_id)
173
+ if spec is None:
174
+ return rows
175
+ if spec.dotted:
176
+ model_class = _resolve_type(spec.dotted, full_id)
177
+ _validate_result_spec(full_id, spec)
178
+ model_fields = _model_init_fields(model_class)
179
+ built = []
180
+ for row in rows:
181
+ row_data: dict[str, Any] = dict(row)
182
+ if spec.column_map is not None:
183
+ row_data = {
184
+ spec.column_map.get(column, column): value
185
+ for column, value in row_data.items()
186
+ }
187
+ if model_fields is not None:
188
+ row_data = {
189
+ field_name: value
190
+ for field_name, value in row_data.items()
191
+ if field_name in model_fields
192
+ }
193
+ try:
194
+ built.append(model_class(**row_data))
195
+ except TypeError as exc:
196
+ raise TypeError(
197
+ f"条目 '{full_id}' 结果映射到 {spec.dotted} 失败: {exc}; "
198
+ f"实际填充字段={sorted(row_data)}"
199
+ ) from exc
200
+ rows = built
201
+ if spec.single:
202
+ if len(rows) > 1:
203
+ raise TooManyResultsError(
204
+ f"条目 '{full_id}' 声明 single=true,但查询返回了 {len(rows)} 行"
205
+ )
206
+ return rows[0] if rows else None
207
+ return rows
208
+
209
+
210
+ __all__ = ["ResultSpec", "clear_result_mappings", "load_result_map", "load_result_spec", "shape_rows", "validate_result_types"]