sdpy-kit 0.2.4__tar.gz → 0.2.5__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.
Files changed (46) hide show
  1. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/PKG-INFO +29 -16
  2. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/README.md +18 -5
  3. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/__init__.py +4 -1
  4. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/__init__.py +4 -0
  5. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/base_db_kit.py +132 -21
  6. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/pyproject.toml +10 -10
  7. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/.gitignore +0 -0
  8. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/__init__.py +0 -0
  9. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/base_service.py +0 -0
  10. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/chat_service.py +0 -0
  11. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/embedding_service.py +0 -0
  12. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/exceptions.py +0 -0
  13. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/failover.py +0 -0
  14. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/image_service.py +0 -0
  15. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/model_builder.py +0 -0
  16. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/model_client.py +0 -0
  17. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/pool.py +0 -0
  18. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/profiles.py +0 -0
  19. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/rerank_service.py +0 -0
  20. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/runner.py +0 -0
  21. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/ai/types.py +0 -0
  22. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/anydoc_kit.py +0 -0
  23. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/common_kit.py +0 -0
  24. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/config/__init__.py +0 -0
  25. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/config/kit_config.py +0 -0
  26. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/config/log_config.py +0 -0
  27. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/crud.py +0 -0
  28. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/engine.py +0 -0
  29. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/exceptions.py +0 -0
  30. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/models/__init__.py +0 -0
  31. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/models/base.py +0 -0
  32. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/mysql_kit.py +0 -0
  33. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/pg_kit.py +0 -0
  34. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/query.py +0 -0
  35. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/session.py +0 -0
  36. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/sqlite_kit.py +0 -0
  37. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/db/transaction.py +0 -0
  38. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/docx_kit.py +0 -0
  39. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/file_kit.py +0 -0
  40. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/marker_kit.py +0 -0
  41. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/mcp_kit.py +0 -0
  42. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/md2doc_kit.py +0 -0
  43. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/mineru_kit.py +0 -0
  44. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/redis_kit.py +0 -0
  45. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/wx_article_kit.py +0 -0
  46. {sdpy_kit-0.2.4 → sdpy_kit-0.2.5}/kit/zvec_kit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/MCP/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -18,38 +18,38 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.13
20
20
  Requires-Dist: pydantic-settings>=2.15.0
21
- Requires-Dist: pydantic>=2.13.5
21
+ Requires-Dist: pydantic>=2.14.1
22
22
  Requires-Dist: sqlalchemy~=2.0.54
23
- Requires-Dist: sqlmodel>=0.0.46
23
+ Requires-Dist: sqlmodel>=0.0.48
24
24
  Provides-Extra: ai
25
25
  Requires-Dist: httpx>=0.28.1; extra == 'ai'
26
- Requires-Dist: python-dotenv>=1.2.3; extra == 'ai'
26
+ Requires-Dist: python-dotenv>=1.2.4; extra == 'ai'
27
27
  Provides-Extra: all
28
28
  Requires-Dist: docxtpl>=0.20.2; extra == 'all'
29
- Requires-Dist: fastmcp>=4.0.5; extra == 'all'
29
+ Requires-Dist: fastmcp>=4.1.0; extra == 'all'
30
30
  Requires-Dist: firecrawl-anydoc>=0.2.4; extra == 'all'
31
31
  Requires-Dist: httpx>=0.28.1; extra == 'all'
32
32
  Requires-Dist: markdown-it-py>=4.2.0; extra == 'all'
33
33
  Requires-Dist: marker-pdf[full]>=2.0.0; extra == 'all'
34
- Requires-Dist: numpy>=2.5.3; extra == 'all'
35
- Requires-Dist: pikepdf>=10.13.0; extra == 'all'
34
+ Requires-Dist: numpy>=2.5.4; extra == 'all'
35
+ Requires-Dist: pikepdf>=10.17.0; extra == 'all'
36
36
  Requires-Dist: pimd[pdfa]>=2.2.5; extra == 'all'
37
37
  Requires-Dist: psycopg2-binary>=2.9.13; extra == 'all'
38
38
  Requires-Dist: pymysql>=1.2.3; extra == 'all'
39
- Requires-Dist: python-dotenv>=1.2.3; extra == 'all'
39
+ Requires-Dist: python-dotenv>=1.2.4; extra == 'all'
40
40
  Requires-Dist: redis>=8.1.0; extra == 'all'
41
41
  Requires-Dist: requests>=2.34.2; extra == 'all'
42
42
  Requires-Dist: sqliteai-vector>=1.1.2; extra == 'all'
43
43
  Requires-Dist: zvec>=0.7.0; extra == 'all'
44
44
  Provides-Extra: anydoc
45
45
  Requires-Dist: firecrawl-anydoc>=0.2.4; extra == 'anydoc'
46
- Requires-Dist: pikepdf>=10.13.0; extra == 'anydoc'
46
+ Requires-Dist: pikepdf>=10.17.0; extra == 'anydoc'
47
47
  Provides-Extra: docx
48
48
  Requires-Dist: docxtpl>=0.20.2; extra == 'docx'
49
49
  Provides-Extra: marker
50
50
  Requires-Dist: marker-pdf[full]>=2.0.0; extra == 'marker'
51
51
  Provides-Extra: mcp
52
- Requires-Dist: fastmcp>=4.0.5; extra == 'mcp'
52
+ Requires-Dist: fastmcp>=4.1.0; extra == 'mcp'
53
53
  Provides-Extra: md2doc
54
54
  Requires-Dist: markdown-it-py>=4.2.0; extra == 'md2doc'
55
55
  Requires-Dist: pimd[pdfa]>=2.2.5; extra == 'md2doc'
@@ -62,7 +62,7 @@ Requires-Dist: psycopg2-binary>=2.9.13; extra == 'pg'
62
62
  Provides-Extra: redis
63
63
  Requires-Dist: redis>=8.1.0; extra == 'redis'
64
64
  Provides-Extra: sqliteai
65
- Requires-Dist: numpy>=2.5.3; extra == 'sqliteai'
65
+ Requires-Dist: numpy>=2.5.4; extra == 'sqliteai'
66
66
  Requires-Dist: sqliteai-vector>=1.1.2; extra == 'sqliteai'
67
67
  Provides-Extra: zvec
68
68
  Requires-Dist: zvec>=0.7.0; extra == 'zvec'
@@ -362,7 +362,8 @@ from kit import session_scope, transactional, DBError # 等价
362
362
  | ORM | `upsert(model, data, conflict_fields) -> Model` | 原子插入或更新(`conflict_fields` 须为唯一约束) |
363
363
  | ORM | `count(model) -> int` | 记录总数 |
364
364
  | 原生 SQL | `query(sql, params=None) -> list[dict]` | 原生查询,返回字典列表 |
365
- | 原生 SQL | `execute(sql, params=None) -> int` | 原生写入,返回影响行数 |
365
+ | 原生 SQL | `execute(sql, params=None) -> int` | 原生写入,返回影响行数;`params` 传**字典列表**时批量执行(executemany,行键集须一致、行值须为标量、空列表返回 0) |
366
+ | 原生 SQL | `prepare_statement(sql, params=None) -> (语句, dict \| None)` | **模块级函数**(`from kit.db import prepare_statement`):构造可在**自有事务**内 `session.execute(stmt, bound)` 的语句,支持 `IN :ids`,调用方无须 import `sqlalchemy` |
366
367
  | 原生表 | `create_raw_table(table_name, columns, if_not_exists=True) -> None` | 按「列名 → 列定义」建普通表;⚠ 列定义须按目标库方言书写,不做方言翻译 |
367
368
  | 原生表 | `raw_insert(table_name, data) -> int` | 插入一行,返回影响行数 |
368
369
  | 原生表 | `raw_get_by(table_name, column, value) -> dict \| None` | 按单列等值取首行 |
@@ -511,6 +512,17 @@ PgKit.search_vectors("my_vec", [0.1] * 384, top_k=5, id_col="vid", column="vec")
511
512
  > 新增(非破坏性):`VECTOR_CAPABILITIES` 能力声明、`id_col` / `column` 逃生口参数、
512
513
  > `insert_vectors` 支持二维 numpy 数组与批量提交。
513
514
 
515
+ #### 0.2.5 变更(开发中,未发布)
516
+
517
+ | 编号 | 变更 | 影响 |
518
+ |------|------|------|
519
+ | — | 新增**模块级公共函数** `prepare_statement(sql, params=None) -> (语句, 归一化参数)`:供调用方在**自有事务**内执行原生 SQL(含 `IN :ids`),**调用方无须 import `sqlalchemy`**;与 `query` / `execute` 同一实现(单一事实源) | **纯新增**:`query` / `transaction` 的签名与行为**零变更**;共享方法数仍为 **24**(模块级函数不计入) |
520
+ | — | `execute` 的 `params` 支持**字典列表**(executemany,同一语句 + N 行参数一次往返);**空列表**不触库返回 `0` | 向后兼容:单字典 / `None` 形态行为不变;批量要求各行字典**键集一致**、行值为**标量**(与 `IN` 展开互斥),违规在本地抛可操作 `ValueError`(含行号),不触库 |
521
+ | — | 文档:自有事务示例改用 `prepare_statement`(不再需要 `from sqlalchemy import text`);补「批量执行」说明与约束 | 文档 |
522
+
523
+ > ⚠ `:name::type` 这类 PostgreSQL 风格 cast **不被 SQLAlchemy 的 `text()` 识别**(0.2.5 之前亦如此),
524
+ > 请改用 `CAST(:name AS type)`。
525
+
514
526
  #### 0.2.4 变更
515
527
 
516
528
  | 编号 | 变更 | 影响 |
@@ -541,15 +553,16 @@ rows = SQLiteKit.query("SELECT id, title FROM items WHERE id IN :ids",
541
553
  **自有事务内写向量(推荐:`build_insert_vectors` + `transaction()`)**:
542
554
 
543
555
  ```python
544
- from sqlalchemy import text
556
+ from kit.db import SQLiteKit, prepare_statement # 无须 import sqlalchemy
545
557
 
546
558
  with SQLiteKit.transaction() as session: # 换成 PgKit / MySqlKit 写法相同
547
- session.execute(text("INSERT INTO docs_item (id, title) VALUES (:id, :t)"),
548
- {"id": doc_id, "t": title})
559
+ stmt, bound = prepare_statement(
560
+ "INSERT INTO docs_item (id, title) VALUES (:id, :t)", {"id": doc_id, "t": title})
561
+ session.execute(stmt, bound) # 第二个值必须一并传入
549
562
  for sql, params in SQLiteKit.build_insert_vectors("docs_doc_vectors", vectors,
550
563
  ids=row_ids, extra_data=metas,
551
564
  id_col="rowid"):
552
- session.execute(text(sql), params)
565
+ session.execute(*prepare_statement(sql, params))
553
566
  # 正常退出统一提交;异常自动回滚(多处写入保持同一事务)
554
567
  ```
555
568
 
@@ -292,7 +292,8 @@ from kit import session_scope, transactional, DBError # 等价
292
292
  | ORM | `upsert(model, data, conflict_fields) -> Model` | 原子插入或更新(`conflict_fields` 须为唯一约束) |
293
293
  | ORM | `count(model) -> int` | 记录总数 |
294
294
  | 原生 SQL | `query(sql, params=None) -> list[dict]` | 原生查询,返回字典列表 |
295
- | 原生 SQL | `execute(sql, params=None) -> int` | 原生写入,返回影响行数 |
295
+ | 原生 SQL | `execute(sql, params=None) -> int` | 原生写入,返回影响行数;`params` 传**字典列表**时批量执行(executemany,行键集须一致、行值须为标量、空列表返回 0) |
296
+ | 原生 SQL | `prepare_statement(sql, params=None) -> (语句, dict \| None)` | **模块级函数**(`from kit.db import prepare_statement`):构造可在**自有事务**内 `session.execute(stmt, bound)` 的语句,支持 `IN :ids`,调用方无须 import `sqlalchemy` |
296
297
  | 原生表 | `create_raw_table(table_name, columns, if_not_exists=True) -> None` | 按「列名 → 列定义」建普通表;⚠ 列定义须按目标库方言书写,不做方言翻译 |
297
298
  | 原生表 | `raw_insert(table_name, data) -> int` | 插入一行,返回影响行数 |
298
299
  | 原生表 | `raw_get_by(table_name, column, value) -> dict \| None` | 按单列等值取首行 |
@@ -441,6 +442,17 @@ PgKit.search_vectors("my_vec", [0.1] * 384, top_k=5, id_col="vid", column="vec")
441
442
  > 新增(非破坏性):`VECTOR_CAPABILITIES` 能力声明、`id_col` / `column` 逃生口参数、
442
443
  > `insert_vectors` 支持二维 numpy 数组与批量提交。
443
444
 
445
+ #### 0.2.5 变更(开发中,未发布)
446
+
447
+ | 编号 | 变更 | 影响 |
448
+ |------|------|------|
449
+ | — | 新增**模块级公共函数** `prepare_statement(sql, params=None) -> (语句, 归一化参数)`:供调用方在**自有事务**内执行原生 SQL(含 `IN :ids`),**调用方无须 import `sqlalchemy`**;与 `query` / `execute` 同一实现(单一事实源) | **纯新增**:`query` / `transaction` 的签名与行为**零变更**;共享方法数仍为 **24**(模块级函数不计入) |
450
+ | — | `execute` 的 `params` 支持**字典列表**(executemany,同一语句 + N 行参数一次往返);**空列表**不触库返回 `0` | 向后兼容:单字典 / `None` 形态行为不变;批量要求各行字典**键集一致**、行值为**标量**(与 `IN` 展开互斥),违规在本地抛可操作 `ValueError`(含行号),不触库 |
451
+ | — | 文档:自有事务示例改用 `prepare_statement`(不再需要 `from sqlalchemy import text`);补「批量执行」说明与约束 | 文档 |
452
+
453
+ > ⚠ `:name::type` 这类 PostgreSQL 风格 cast **不被 SQLAlchemy 的 `text()` 识别**(0.2.5 之前亦如此),
454
+ > 请改用 `CAST(:name AS type)`。
455
+
444
456
  #### 0.2.4 变更
445
457
 
446
458
  | 编号 | 变更 | 影响 |
@@ -471,15 +483,16 @@ rows = SQLiteKit.query("SELECT id, title FROM items WHERE id IN :ids",
471
483
  **自有事务内写向量(推荐:`build_insert_vectors` + `transaction()`)**:
472
484
 
473
485
  ```python
474
- from sqlalchemy import text
486
+ from kit.db import SQLiteKit, prepare_statement # 无须 import sqlalchemy
475
487
 
476
488
  with SQLiteKit.transaction() as session: # 换成 PgKit / MySqlKit 写法相同
477
- session.execute(text("INSERT INTO docs_item (id, title) VALUES (:id, :t)"),
478
- {"id": doc_id, "t": title})
489
+ stmt, bound = prepare_statement(
490
+ "INSERT INTO docs_item (id, title) VALUES (:id, :t)", {"id": doc_id, "t": title})
491
+ session.execute(stmt, bound) # 第二个值必须一并传入
479
492
  for sql, params in SQLiteKit.build_insert_vectors("docs_doc_vectors", vectors,
480
493
  ids=row_ids, extra_data=metas,
481
494
  id_col="rowid"):
482
- session.execute(text(sql), params)
495
+ session.execute(*prepare_statement(sql, params))
483
496
  # 正常退出统一提交;异常自动回滚(多处写入保持同一事务)
484
497
  ```
485
498
 
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.2.4"
26
+ __version__ = "0.2.5"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -47,6 +47,7 @@ from kit.db import (
47
47
  get_engine,
48
48
  get_session,
49
49
  list_tables,
50
+ prepare_statement,
50
51
  session_scope,
51
52
  table_exists,
52
53
  to_vector_literal,
@@ -85,6 +86,8 @@ __all__ = [
85
86
  "CRUDBase",
86
87
  # 事务
87
88
  "transactional",
89
+ # 原生 SQL 语句构造(公共)
90
+ "prepare_statement",
88
91
  # 向量工具(公共)
89
92
  "to_vector_literal",
90
93
  "generate_snowflake_ids",
@@ -32,6 +32,7 @@ Author: Clark Chang
32
32
  from kit.db.base_db_kit import (
33
33
  DEFAULT_DISTANCE_TYPE,
34
34
  generate_snowflake_ids,
35
+ prepare_statement,
35
36
  to_vector_literal,
36
37
  )
37
38
  from kit.db.crud import CRUDBase
@@ -87,6 +88,9 @@ __all__ = [
87
88
  "CRUDBase",
88
89
  # 事务
89
90
  "transactional",
91
+ # 原生 SQL 语句构造(公共)
92
+ # 自有事务内执行原生 SQL(含列表参数 IN :ids)时使用,调用方无须 import sqlalchemy
93
+ "prepare_statement",
90
94
  # 向量工具(公共)
91
95
  "to_vector_literal",
92
96
  "generate_snowflake_ids",
@@ -201,23 +201,51 @@ def generate_snowflake_ids(count: int) -> builtins.list[int]:
201
201
  _EXPANDING_TYPES = (list, tuple, set, frozenset)
202
202
 
203
203
 
204
- def _prepare_statement(
205
- sql: str, params: FieldData | None
204
+ def prepare_statement(
205
+ sql: str, params: FieldData | None = None
206
206
  ) -> tuple[TextClause, FieldData | None]:
207
- """把 SQL 文本 + 参数字典转为(可执行语句, 归一化后的参数)
207
+ """把 SQL 文本 + 参数字典转为(可直接执行的语句, 归一化后的参数)
208
208
 
209
- `params` 中值为**序列类型**(list / tuple / set / frozenset)的键按 SQLAlchemy
210
- **expanding** 语义绑定,使 ``WHERE id IN :ids`` 可直接书写(三库通用)。
209
+ 供调用方在**自有事务**(`*Kit.transaction()`)内执行原生 SQL,
210
+ 与 ``query`` / ``execute`` 使用**同一实现**(单一事实源)——后两者等价于
211
+ ``session.execute(*prepare_statement(sql, params))``;本函数**不建连接、
212
+ 不改状态、不执行**。
211
213
 
212
- 三条实现约束:
214
+ 典型用法(调用方**无须 import sqlalchemy**)::
213
215
 
214
- 1. 按类型白名单判断(见 `_EXPANDING_TYPES`),字符串不会被当作列表;
215
- 2. 只对**确实出现在 SQL 中**的参数名做 expanding —— 否则传入"未使用的序列参数"
216
- 会触发 SQLAlchemy 的 ``ArgumentError: This text() construct doesn't define
217
- a bound parameter …``,该报错对使用方没有意义;
218
- 3. `set` / `frozenset` 归一化为 `list` —— SQLAlchemy 的 expanding 只接受
219
- **可下标序列**(直接传 set 会抛 ``TypeError: 'set' object is not subscriptable``);
220
- ``IN`` 与顺序无关,故归一化不改变语义。
216
+ from kit.db import SQLiteKit, prepare_statement
217
+
218
+ stmt, bound = prepare_statement(
219
+ "DELETE FROM t WHERE id IN :ids", {"ids": [1, 2, 3]}
220
+ )
221
+ with SQLiteKit.transaction() as session:
222
+ session.execute(stmt, bound) # 第二个值必须一并传入
223
+
224
+ 三条实现约束(与 ``query`` / ``execute`` 现有语义完全一致):
225
+
226
+ 1. 只对**确实出现在 SQL 文本中**的参数名做 expanding —— 否则传入"未使用的
227
+ 序列参数"会触发 SQLAlchemy 的 ``ArgumentError: This text() construct
228
+ doesn't define a bound parameter …``,该报错对使用方没有意义;
229
+ 2. 按类型白名单判断(见 `_EXPANDING_TYPES`),字符串不会被当作列表;
230
+ 3. ``set`` / ``frozenset`` 归一化为 ``list`` —— SQLAlchemy 的 expanding 只接受
231
+ **可下标序列**(直接传 set 会抛 ``TypeError: 'set' object is not
232
+ subscriptable``);``IN`` 与顺序无关,故归一化不改变语义。
233
+
234
+ Args:
235
+ sql: SQL 语句(使用命名参数 ``:param``)
236
+ params: 参数字典,值为 ``list`` / ``tuple`` / ``set`` / ``frozenset`` 的键
237
+ 按 ``IN`` 展开(三库通用)
238
+
239
+ Returns:
240
+ ``(语句, 归一化后的参数字典)``:执行时**必须**把第二个值一并传给
241
+ ``session.execute``;``params`` 为 None 时第二项即 None
242
+
243
+ Note:
244
+ ``:name::type`` 这类 PostgreSQL 风格 cast **不被 SQLAlchemy 的 ``text()``
245
+ 识别**(该写法在支持本函数之前同样不可用),请改用 ``CAST(:name AS type)``。
246
+
247
+ See Also:
248
+ ``query`` / ``execute``:kit 自管事务的原生 SQL 入口(同样支持列表参数)。
221
249
  """
222
250
  statement = text(sql)
223
251
  if not params:
@@ -230,6 +258,7 @@ def _prepare_statement(
230
258
  if isinstance(value, (set, frozenset)):
231
259
  value = list(value)
232
260
  normalized[name] = value
261
+ # 约束 1:只对确实出现在 SQL 文本中的参数名做 expanding
233
262
  if re.search(rf":{re.escape(name)}\b", sql):
234
263
  expanding.append(bindparam(name, expanding=True))
235
264
  else:
@@ -240,6 +269,46 @@ def _prepare_statement(
240
269
  return statement, normalized
241
270
 
242
271
 
272
+ def _validate_batch_rows(rows: builtins.list[FieldData]) -> None:
273
+ """批量执行(executemany)的本地预校验:键集一致 + 不含序列参数
274
+
275
+ 约束来自 DBAPI 的 executemany 语义(实测):
276
+
277
+ 1. 各行字典的**键集必须完全一致** —— 否则 SQLAlchemy 报
278
+ ``StatementError: A value is required for bind parameter 'xxx'``,
279
+ 不指明是哪一行,对使用方没有意义;
280
+ 2. 行值须为**标量** —— 批量与 ``IN`` 展开(expanding)**互斥**:对批量语句
281
+ 做 expanding 标注后按行执行会得到 ``OperationalError: near "?": syntax error``
282
+ 这类驱动级错误(三库通用行为)。
283
+
284
+ 在本地提前给出**可操作的** ``ValueError``(含行号与差异明细),
285
+ 避免把上述两类无意义报错暴露给使用方。
286
+
287
+ Args:
288
+ rows: 批量参数(非空)
289
+
290
+ Raises:
291
+ ValueError: 键集不一致,或行值中含序列类型
292
+ """
293
+ base = set(rows[0])
294
+ for index, row in enumerate(rows):
295
+ keys = set(row)
296
+ if keys != base:
297
+ raise ValueError(
298
+ f"批量参数第 {index} 行的键集与第 0 行不一致:"
299
+ f"缺少 {sorted(base - keys)},多出 {sorted(keys - base)};"
300
+ f"executemany 要求各行键集完全一致"
301
+ )
302
+ for name, value in row.items():
303
+ if isinstance(value, _EXPANDING_TYPES):
304
+ raise ValueError(
305
+ f"批量参数第 {index} 行的 {name!r} 是序列类型:"
306
+ f"批量执行(executemany)与 IN 展开(expanding)互斥;"
307
+ f"如需 IN 语义请改用单个参数字典(execute 的单字典形态),"
308
+ f"或逐行调用 execute"
309
+ )
310
+
311
+
243
312
  class BaseDbKit(ABC):
244
313
  """数据库工具类共享基类
245
314
 
@@ -606,7 +675,7 @@ class BaseDbKit(ABC):
606
675
  ``IN`` **不保证返回顺序**,需要顺序请自行按 id 重排;参数列表过大时
607
676
  (如 PG 单语句参数上限 65535)应分批查询。
608
677
  """
609
- statement, bound = _prepare_statement(sql, params)
678
+ statement, bound = prepare_statement(sql, params)
610
679
  with session_scope(cls.DB_TYPE, autocommit=False) as session:
611
680
  result = session.execute(statement, bound or {})
612
681
  rows = result.fetchall()
@@ -616,24 +685,66 @@ class BaseDbKit(ABC):
616
685
  return data
617
686
 
618
687
  @classmethod
619
- def execute(cls, sql: str, params: FieldData | None = None) -> int:
620
- """执行原生更新 SQL(INSERT/UPDATE/DELETE)
688
+ def execute(
689
+ cls, sql: str, params: FieldData | builtins.list[FieldData] | None = None
690
+ ) -> int:
691
+ """执行原生更新 SQL(INSERT/UPDATE/DELETE),支持批量(executemany)
621
692
 
622
693
  Args:
623
- sql: SQL 语句(使用命名参数 :param)
624
- params: 参数字典。值为 list / tuple / set / frozenset 的参数按 ``IN`` 展开
625
- (如 ``DELETE FROM t WHERE id IN :ids``,三库通用)
694
+ sql: SQL 语句(使用命名参数 ``:param``)
695
+ params: 两种形态:
696
+
697
+ - **None / 单个参数字典**:执行一次;值为 ``list`` / ``tuple`` /
698
+ ``set`` / ``frozenset`` 的参数按 ``IN`` 展开
699
+ (如 ``DELETE FROM t WHERE id IN :ids``,三库通用);
700
+ - **参数字典的列表**:批量执行(executemany,同一语句 + N 行参数,
701
+ 一次往返)。⚠ 与 ``IN`` 展开**互斥**(行值须为标量),
702
+ 各行字典的**键集必须完全一致**,**空列表**不触库直接返回 0。
626
703
 
627
704
  Returns:
628
- 受影响的行数
705
+ 受影响的行数(批量形态为各行累加;部分驱动对 executemany 不上报
706
+ 精确行数时可能为 ``-1``,请以数据为准)
707
+
708
+ Raises:
709
+ ValueError: 批量形态的行参数键集不一致,或行值中含序列类型
710
+ (批量与 ``IN`` 展开互斥)
629
711
  """
630
- statement, bound = _prepare_statement(sql, params)
712
+ if isinstance(params, builtins.list):
713
+ return cls._execute_batch(sql, params)
714
+ statement, bound = prepare_statement(sql, params)
631
715
  with session_scope(cls.DB_TYPE) as session:
632
716
  result = session.execute(statement, bound or {})
633
717
  affected = getattr(result, "rowcount", 0) or 0
634
718
  logger.info(f"✅ 执行更新 → 影响 {affected} 行")
635
719
  return affected
636
720
 
721
+ @classmethod
722
+ def _execute_batch(cls, sql: str, rows: builtins.list[FieldData]) -> int:
723
+ """批量执行(executemany):先本地预校验,再一次往返执行 N 行参数
724
+
725
+ 私有方法(不属于公共面):仅 ``execute`` 的列表形态会进入。
726
+
727
+ Args:
728
+ sql: SQL 语句(使用命名参数 ``:param``;**不传** ``params`` 构造语句,
729
+ 即不做 ``IN`` 展开——批量与展开参数互斥)
730
+ rows: 参数字典列表(非空;键集一致;值须为标量)
731
+
732
+ Returns:
733
+ 受影响的行数(各行累加;驱动不上报时为 ``-1``)
734
+ """
735
+ if not rows:
736
+ # 空列表语义:不触库、不报错,直接返回 0(DBAPI 对 executemany
737
+ # 传空列表会报 "A value is required for bind parameter")
738
+ logger.info("批量执行收到空参数列表 → 不执行任何语句")
739
+ return 0
740
+ _validate_batch_rows(rows)
741
+ statement, _ = prepare_statement(sql)
742
+ with session_scope(cls.DB_TYPE) as session:
743
+ result = session.execute(statement, rows)
744
+ affected = getattr(result, "rowcount", 0) or 0
745
+ logger.info(f"✅ 批量执行 → {len(rows)} 行参数,影响 {affected} 行")
746
+ return affected
747
+
637
748
  # ==================== 原生表操作 ====================
638
749
 
639
750
  @classmethod
@@ -31,10 +31,10 @@ classifiers = [
31
31
  # base + extras 须与主工程 pyproject.toml 的 dependencies 保持一致(双向锚定),
32
32
  # publish.py --check-only 会自动校验,主工程缺失即报错。
33
33
  dependencies = [
34
- "pydantic>=2.13.5",
34
+ "pydantic>=2.14.1",
35
35
  "pydantic-settings>=2.15.0",
36
36
  "sqlalchemy~=2.0.54",
37
- "sqlmodel>=0.0.46",
37
+ "sqlmodel>=0.0.48",
38
38
  ]
39
39
  dynamic = ["version"]
40
40
 
@@ -57,15 +57,15 @@ dynamic = ["version"]
57
57
  pg = ["psycopg2-binary>=2.9.13"]
58
58
  mysql = ["pymysql>=1.2.3"]
59
59
  sqliteai = [
60
- "numpy>=2.5.3",
60
+ "numpy>=2.5.4",
61
61
  "sqliteai-vector>=1.1.2",
62
62
  ]
63
63
  zvec = ["zvec>=0.7.0"]
64
64
  redis = ["redis>=8.1.0"]
65
- mcp = ["fastmcp>=4.0.5"]
65
+ mcp = ["fastmcp>=4.1.0"]
66
66
  ai = [
67
67
  "httpx>=0.28.1",
68
- "python-dotenv>=1.2.3",
68
+ "python-dotenv>=1.2.4",
69
69
  ]
70
70
  md2doc = [
71
71
  "pimd[pdfa]>=2.2.5",
@@ -73,7 +73,7 @@ md2doc = [
73
73
  ]
74
74
  anydoc = [
75
75
  "firecrawl-anydoc>=0.2.4",
76
- "pikepdf>=10.13.0",
76
+ "pikepdf>=10.17.0",
77
77
  ]
78
78
  docx = ["docxtpl>=0.20.2"]
79
79
  mineru = ["requests>=2.34.2"]
@@ -81,17 +81,17 @@ marker = ["marker-pdf[full]>=2.0.0"]
81
81
  all = [
82
82
  "psycopg2-binary>=2.9.13",
83
83
  "pymysql>=1.2.3",
84
- "numpy>=2.5.3",
84
+ "numpy>=2.5.4",
85
85
  "sqliteai-vector>=1.1.2",
86
86
  "zvec>=0.7.0",
87
87
  "redis>=8.1.0",
88
- "fastmcp>=4.0.5",
88
+ "fastmcp>=4.1.0",
89
89
  "httpx>=0.28.1",
90
- "python-dotenv>=1.2.3",
90
+ "python-dotenv>=1.2.4",
91
91
  "pimd[pdfa]>=2.2.5",
92
92
  "markdown-it-py>=4.2.0",
93
93
  "firecrawl-anydoc>=0.2.4",
94
- "pikepdf>=10.13.0",
94
+ "pikepdf>=10.17.0",
95
95
  "docxtpl>=0.20.2",
96
96
  "requests>=2.34.2",
97
97
  "marker-pdf[full]>=2.0.0",
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes