sdpy-kit 0.2.4__tar.gz → 0.2.6__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.6}/PKG-INFO +37 -16
  2. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/README.md +26 -5
  3. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/__init__.py +4 -1
  4. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/__init__.py +4 -0
  5. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/base_db_kit.py +163 -25
  6. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/pyproject.toml +10 -10
  7. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/.gitignore +0 -0
  8. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/__init__.py +0 -0
  9. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/base_service.py +0 -0
  10. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/chat_service.py +0 -0
  11. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/embedding_service.py +0 -0
  12. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/exceptions.py +0 -0
  13. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/failover.py +0 -0
  14. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/image_service.py +0 -0
  15. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/model_builder.py +0 -0
  16. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/model_client.py +0 -0
  17. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/pool.py +0 -0
  18. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/profiles.py +0 -0
  19. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/rerank_service.py +0 -0
  20. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/runner.py +0 -0
  21. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/ai/types.py +0 -0
  22. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/anydoc_kit.py +0 -0
  23. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/common_kit.py +0 -0
  24. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/config/__init__.py +0 -0
  25. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/config/kit_config.py +0 -0
  26. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/config/log_config.py +0 -0
  27. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/crud.py +0 -0
  28. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/engine.py +0 -0
  29. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/exceptions.py +0 -0
  30. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/models/__init__.py +0 -0
  31. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/models/base.py +0 -0
  32. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/mysql_kit.py +0 -0
  33. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/pg_kit.py +0 -0
  34. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/query.py +0 -0
  35. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/session.py +0 -0
  36. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/sqlite_kit.py +0 -0
  37. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/db/transaction.py +0 -0
  38. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/docx_kit.py +0 -0
  39. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/file_kit.py +0 -0
  40. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/marker_kit.py +0 -0
  41. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/mcp_kit.py +0 -0
  42. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/md2doc_kit.py +0 -0
  43. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/mineru_kit.py +0 -0
  44. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/redis_kit.py +0 -0
  45. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/kit/wx_article_kit.py +0 -0
  46. {sdpy_kit-0.2.4 → sdpy_kit-0.2.6}/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.6
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,25 @@ 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.6 变更
516
+
517
+ | 编号 | 变更 | 影响 |
518
+ |------|------|------|
519
+ | D1 | `prepare_statement` 的 `params` 支持**参数行列表**(`list[dict]`):**原样返回该列表** → `session.execute(*prepare_statement(sql, params))` 即批量执行(executemany),与 `build_insert_vectors` 的返回形态直接对接;行键集不一致或行值含序列 → 本地可操作 `ValueError`(与 `execute` 批量形态**同一规则**) | 向后兼容:单字典 / `None` 形态行为不变;行列表形态**不做** `IN` 展开(与批量互斥) |
520
+ | D1 | 文档:修正「自有事务」示例——0.2.5 的示例把 `build_insert_vectors` 返回的**行列表**传给了单字典形态,照抄会抛 `AttributeError: 'list' object has no attribute 'items'` | **纯文档缺陷修正**(0.2.5 示例照抄即崩) |
521
+ | — | 文档:批量执行补充 `rowcount` 备注——忽略型写入(如 SQLite `INSERT OR IGNORE`)的冲突行不计入 `rowcount`(即 `rowcount` = 实际插入数;⚠ 仅 SQLite 实测) | 文档 |
522
+
523
+ #### 0.2.5 变更
524
+
525
+ | 编号 | 变更 | 影响 |
526
+ |------|------|------|
527
+ | — | 新增**模块级公共函数** `prepare_statement(sql, params=None) -> (语句, 归一化参数)`:供调用方在**自有事务**内执行原生 SQL(含 `IN :ids`),**调用方无须 import `sqlalchemy`**;与 `query` / `execute` 同一实现(单一事实源) | **纯新增**:`query` / `transaction` 的签名与行为**零变更**;共享方法数仍为 **24**(模块级函数不计入) |
528
+ | — | `execute` 的 `params` 支持**字典列表**(executemany,同一语句 + N 行参数一次往返);**空列表**不触库返回 `0` | 向后兼容:单字典 / `None` 形态行为不变;批量要求各行字典**键集一致**、行值为**标量**(与 `IN` 展开互斥),违规在本地抛可操作 `ValueError`(含行号),不触库 |
529
+ | — | 文档:自有事务示例改用 `prepare_statement`(不再需要 `from sqlalchemy import text`);补「批量执行」说明与约束 | 文档 |
530
+
531
+ > ⚠ `:name::type` 这类 PostgreSQL 风格 cast **不被 SQLAlchemy 的 `text()` 识别**(0.2.5 之前亦如此),
532
+ > 请改用 `CAST(:name AS type)`。
533
+
514
534
  #### 0.2.4 变更
515
535
 
516
536
  | 编号 | 变更 | 影响 |
@@ -541,15 +561,16 @@ rows = SQLiteKit.query("SELECT id, title FROM items WHERE id IN :ids",
541
561
  **自有事务内写向量(推荐:`build_insert_vectors` + `transaction()`)**:
542
562
 
543
563
  ```python
544
- from sqlalchemy import text
564
+ from kit.db import SQLiteKit, prepare_statement # 无须 import sqlalchemy
545
565
 
546
566
  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})
567
+ stmt, bound = prepare_statement(
568
+ "INSERT INTO docs_item (id, title) VALUES (:id, :t)", {"id": doc_id, "t": title})
569
+ session.execute(stmt, bound) # 第二个值必须一并传入
549
570
  for sql, params in SQLiteKit.build_insert_vectors("docs_doc_vectors", vectors,
550
571
  ids=row_ids, extra_data=metas,
551
572
  id_col="rowid"):
552
- session.execute(text(sql), params)
573
+ session.execute(*prepare_statement(sql, params))
553
574
  # 正常退出统一提交;异常自动回滚(多处写入保持同一事务)
554
575
  ```
555
576
 
@@ -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,25 @@ 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.6 变更
446
+
447
+ | 编号 | 变更 | 影响 |
448
+ |------|------|------|
449
+ | D1 | `prepare_statement` 的 `params` 支持**参数行列表**(`list[dict]`):**原样返回该列表** → `session.execute(*prepare_statement(sql, params))` 即批量执行(executemany),与 `build_insert_vectors` 的返回形态直接对接;行键集不一致或行值含序列 → 本地可操作 `ValueError`(与 `execute` 批量形态**同一规则**) | 向后兼容:单字典 / `None` 形态行为不变;行列表形态**不做** `IN` 展开(与批量互斥) |
450
+ | D1 | 文档:修正「自有事务」示例——0.2.5 的示例把 `build_insert_vectors` 返回的**行列表**传给了单字典形态,照抄会抛 `AttributeError: 'list' object has no attribute 'items'` | **纯文档缺陷修正**(0.2.5 示例照抄即崩) |
451
+ | — | 文档:批量执行补充 `rowcount` 备注——忽略型写入(如 SQLite `INSERT OR IGNORE`)的冲突行不计入 `rowcount`(即 `rowcount` = 实际插入数;⚠ 仅 SQLite 实测) | 文档 |
452
+
453
+ #### 0.2.5 变更
454
+
455
+ | 编号 | 变更 | 影响 |
456
+ |------|------|------|
457
+ | — | 新增**模块级公共函数** `prepare_statement(sql, params=None) -> (语句, 归一化参数)`:供调用方在**自有事务**内执行原生 SQL(含 `IN :ids`),**调用方无须 import `sqlalchemy`**;与 `query` / `execute` 同一实现(单一事实源) | **纯新增**:`query` / `transaction` 的签名与行为**零变更**;共享方法数仍为 **24**(模块级函数不计入) |
458
+ | — | `execute` 的 `params` 支持**字典列表**(executemany,同一语句 + N 行参数一次往返);**空列表**不触库返回 `0` | 向后兼容:单字典 / `None` 形态行为不变;批量要求各行字典**键集一致**、行值为**标量**(与 `IN` 展开互斥),违规在本地抛可操作 `ValueError`(含行号),不触库 |
459
+ | — | 文档:自有事务示例改用 `prepare_statement`(不再需要 `from sqlalchemy import text`);补「批量执行」说明与约束 | 文档 |
460
+
461
+ > ⚠ `:name::type` 这类 PostgreSQL 风格 cast **不被 SQLAlchemy 的 `text()` 识别**(0.2.5 之前亦如此),
462
+ > 请改用 `CAST(:name AS type)`。
463
+
444
464
  #### 0.2.4 变更
445
465
 
446
466
  | 编号 | 变更 | 影响 |
@@ -471,15 +491,16 @@ rows = SQLiteKit.query("SELECT id, title FROM items WHERE id IN :ids",
471
491
  **自有事务内写向量(推荐:`build_insert_vectors` + `transaction()`)**:
472
492
 
473
493
  ```python
474
- from sqlalchemy import text
494
+ from kit.db import SQLiteKit, prepare_statement # 无须 import sqlalchemy
475
495
 
476
496
  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})
497
+ stmt, bound = prepare_statement(
498
+ "INSERT INTO docs_item (id, title) VALUES (:id, :t)", {"id": doc_id, "t": title})
499
+ session.execute(stmt, bound) # 第二个值必须一并传入
479
500
  for sql, params in SQLiteKit.build_insert_vectors("docs_doc_vectors", vectors,
480
501
  ids=row_ids, extra_data=metas,
481
502
  id_col="rowid"):
482
- session.execute(text(sql), params)
503
+ session.execute(*prepare_statement(sql, params))
483
504
  # 正常退出统一提交;异常自动回滚(多处写入保持同一事务)
484
505
  ```
485
506
 
@@ -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.6"
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,24 +201,79 @@ 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
206
- ) -> tuple[TextClause, FieldData | None]:
207
- """把 SQL 文本 + 参数字典转为(可执行语句, 归一化后的参数)
208
-
209
- `params` 中值为**序列类型**(list / tuple / set / frozenset)的键按 SQLAlchemy
210
- **expanding** 语义绑定,使 ``WHERE id IN :ids`` 可直接书写(三库通用)。
211
-
212
- 三条实现约束:
213
-
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`` 与顺序无关,故归一化不改变语义。
204
+ def prepare_statement(
205
+ sql: str, params: FieldData | builtins.list[FieldData] | None = None
206
+ ) -> tuple[TextClause, FieldData | builtins.list[FieldData] | None]:
207
+ """把 SQL 文本 + 参数转为(可直接执行的语句, 参数)
208
+
209
+ 供调用方在**自有事务**(`*Kit.transaction()`)内执行原生 SQL,
210
+ 与 ``query`` / ``execute`` 使用**同一套参数规则**(单一事实源)——
211
+ 单字典形态下后两者等价于 ``session.execute(*prepare_statement(sql, params))``;
212
+ 本函数**不建连接、不改状态、不执行**。
213
+
214
+ 两种参数形态(与 ``execute`` 对称):
215
+
216
+ - **单字典**:值为 ``list`` / ``tuple`` / ``set`` / ``frozenset`` 的键按
217
+ SQLAlchemy **expanding** 语义绑定,可直接书写 ``WHERE id IN :ids``
218
+ (三库通用);第二项为归一化后的参数字典;
219
+ - **字典列表**(批量 / executemany):第二项**原样返回该列表**,语句为
220
+ 裸 ``text(sql)``(**不做** ``IN`` 展开——批量与展开参数互斥,与
221
+ ``execute`` 的批量约束一致);经 ``_validate_batch_rows`` 本地预校验
222
+ (各行键集一致、行值为标量)。
223
+
224
+ 典型用法(调用方**无须 import sqlalchemy**)::
225
+
226
+ from kit.db import SQLiteKit, prepare_statement
227
+
228
+ stmt, bound = prepare_statement(
229
+ "DELETE FROM t WHERE id IN :ids", {"ids": [1, 2, 3]}
230
+ )
231
+ with SQLiteKit.transaction() as session:
232
+ session.execute(stmt, bound) # 第二个值必须一并传入
233
+
234
+ stmt, rows = prepare_statement( # 批量(executemany)
235
+ "INSERT INTO t (a, b) VALUES (:a, :b)", [{"a": 1, "b": 2}]
236
+ )
237
+ with SQLiteKit.transaction() as session:
238
+ session.execute(stmt, rows)
239
+
240
+ 三条实现约束(**单字典形态**,与 ``query`` / ``execute`` 现有语义完全一致):
241
+
242
+ 1. 只对**确实出现在 SQL 文本中**的参数名做 expanding —— 否则传入"未使用的
243
+ 序列参数"会触发 SQLAlchemy 的 ``ArgumentError: This text() construct
244
+ doesn't define a bound parameter …``,该报错对使用方没有意义;
245
+ 2. 按类型白名单判断(见 `_EXPANDING_TYPES`),字符串不会被当作列表;
246
+ 3. ``set`` / ``frozenset`` 归一化为 ``list`` —— SQLAlchemy 的 expanding 只接受
247
+ **可下标序列**(直接传 set 会抛 ``TypeError: 'set' object is not
248
+ subscriptable``);``IN`` 与顺序无关,故归一化不改变语义。
249
+
250
+ Args:
251
+ sql: SQL 语句(使用命名参数 ``:param``)
252
+ params: **单个参数字典**(值为 ``list`` / ``tuple`` / ``set`` / ``frozenset``
253
+ 的键按 ``IN`` 展开,三库通用)或**参数字典的列表**(批量,行值须为标量)
254
+
255
+ Returns:
256
+ ``(语句, 参数)``:执行时**必须**把第二个值一并传给 ``session.execute``;
257
+ ``params`` 为 None 时第二项即 None,为列表时**原样返回该列表**
258
+
259
+ Note:
260
+ ``:name::type`` 这类 PostgreSQL 风格 cast **不被 SQLAlchemy 的 ``text()``
261
+ 识别**(该写法在支持本函数之前同样不可用),请改用 ``CAST(:name AS type)``。
262
+
263
+ Raises:
264
+ ValueError: 列表形态的行键集不一致或行值含序列类型(来自
265
+ ``_validate_batch_rows``,与 ``execute`` 批量形态同一规则)
266
+
267
+ See Also:
268
+ ``query`` / ``execute``:kit 自管事务的原生 SQL 入口(同样支持列表参数)。
221
269
  """
270
+ if isinstance(params, builtins.list):
271
+ # 批量形态:语句不做 IN 展开,参数行列表原样返回(由 execute/session
272
+ # 以 executemany 执行);空列表不校验、不报错(构造期无副作用)
273
+ if params:
274
+ _validate_batch_rows(params)
275
+ return text(sql), params
276
+
222
277
  statement = text(sql)
223
278
  if not params:
224
279
  return statement, params
@@ -230,6 +285,7 @@ def _prepare_statement(
230
285
  if isinstance(value, (set, frozenset)):
231
286
  value = list(value)
232
287
  normalized[name] = value
288
+ # 约束 1:只对确实出现在 SQL 文本中的参数名做 expanding
233
289
  if re.search(rf":{re.escape(name)}\b", sql):
234
290
  expanding.append(bindparam(name, expanding=True))
235
291
  else:
@@ -240,6 +296,46 @@ def _prepare_statement(
240
296
  return statement, normalized
241
297
 
242
298
 
299
+ def _validate_batch_rows(rows: builtins.list[FieldData]) -> None:
300
+ """批量执行(executemany)的本地预校验:键集一致 + 不含序列参数
301
+
302
+ 约束来自 DBAPI 的 executemany 语义(实测):
303
+
304
+ 1. 各行字典的**键集必须完全一致** —— 否则 SQLAlchemy 报
305
+ ``StatementError: A value is required for bind parameter 'xxx'``,
306
+ 不指明是哪一行,对使用方没有意义;
307
+ 2. 行值须为**标量** —— 批量与 ``IN`` 展开(expanding)**互斥**:对批量语句
308
+ 做 expanding 标注后按行执行会得到 ``OperationalError: near "?": syntax error``
309
+ 这类驱动级错误(三库通用行为)。
310
+
311
+ 在本地提前给出**可操作的** ``ValueError``(含行号与差异明细),
312
+ 避免把上述两类无意义报错暴露给使用方。
313
+
314
+ Args:
315
+ rows: 批量参数(非空)
316
+
317
+ Raises:
318
+ ValueError: 键集不一致,或行值中含序列类型
319
+ """
320
+ base = set(rows[0])
321
+ for index, row in enumerate(rows):
322
+ keys = set(row)
323
+ if keys != base:
324
+ raise ValueError(
325
+ f"批量参数第 {index} 行的键集与第 0 行不一致:"
326
+ f"缺少 {sorted(base - keys)},多出 {sorted(keys - base)};"
327
+ f"executemany 要求各行键集完全一致"
328
+ )
329
+ for name, value in row.items():
330
+ if isinstance(value, _EXPANDING_TYPES):
331
+ raise ValueError(
332
+ f"批量参数第 {index} 行的 {name!r} 是序列类型:"
333
+ f"批量执行(executemany)与 IN 展开(expanding)互斥;"
334
+ f"如需 IN 语义请改用单个参数字典(execute 的单字典形态),"
335
+ f"或逐行调用 execute"
336
+ )
337
+
338
+
243
339
  class BaseDbKit(ABC):
244
340
  """数据库工具类共享基类
245
341
 
@@ -606,7 +702,7 @@ class BaseDbKit(ABC):
606
702
  ``IN`` **不保证返回顺序**,需要顺序请自行按 id 重排;参数列表过大时
607
703
  (如 PG 单语句参数上限 65535)应分批查询。
608
704
  """
609
- statement, bound = _prepare_statement(sql, params)
705
+ statement, bound = prepare_statement(sql, params)
610
706
  with session_scope(cls.DB_TYPE, autocommit=False) as session:
611
707
  result = session.execute(statement, bound or {})
612
708
  rows = result.fetchall()
@@ -616,24 +712,66 @@ class BaseDbKit(ABC):
616
712
  return data
617
713
 
618
714
  @classmethod
619
- def execute(cls, sql: str, params: FieldData | None = None) -> int:
620
- """执行原生更新 SQL(INSERT/UPDATE/DELETE)
715
+ def execute(
716
+ cls, sql: str, params: FieldData | builtins.list[FieldData] | None = None
717
+ ) -> int:
718
+ """执行原生更新 SQL(INSERT/UPDATE/DELETE),支持批量(executemany)
621
719
 
622
720
  Args:
623
- sql: SQL 语句(使用命名参数 :param)
624
- params: 参数字典。值为 list / tuple / set / frozenset 的参数按 ``IN`` 展开
625
- (如 ``DELETE FROM t WHERE id IN :ids``,三库通用)
721
+ sql: SQL 语句(使用命名参数 ``:param``)
722
+ params: 两种形态:
723
+
724
+ - **None / 单个参数字典**:执行一次;值为 ``list`` / ``tuple`` /
725
+ ``set`` / ``frozenset`` 的参数按 ``IN`` 展开
726
+ (如 ``DELETE FROM t WHERE id IN :ids``,三库通用);
727
+ - **参数字典的列表**:批量执行(executemany,同一语句 + N 行参数,
728
+ 一次往返)。⚠ 与 ``IN`` 展开**互斥**(行值须为标量),
729
+ 各行字典的**键集必须完全一致**,**空列表**不触库直接返回 0。
626
730
 
627
731
  Returns:
628
- 受影响的行数
732
+ 受影响的行数(批量形态为各行累加;部分驱动对 executemany 不上报
733
+ 精确行数时可能为 ``-1``,请以数据为准)
734
+
735
+ Raises:
736
+ ValueError: 批量形态的行参数键集不一致,或行值中含序列类型
737
+ (批量与 ``IN`` 展开互斥)
629
738
  """
630
- statement, bound = _prepare_statement(sql, params)
739
+ if isinstance(params, builtins.list):
740
+ return cls._execute_batch(sql, params)
741
+ statement, bound = prepare_statement(sql, params)
631
742
  with session_scope(cls.DB_TYPE) as session:
632
743
  result = session.execute(statement, bound or {})
633
744
  affected = getattr(result, "rowcount", 0) or 0
634
745
  logger.info(f"✅ 执行更新 → 影响 {affected} 行")
635
746
  return affected
636
747
 
748
+ @classmethod
749
+ def _execute_batch(cls, sql: str, rows: builtins.list[FieldData]) -> int:
750
+ """批量执行(executemany):先本地预校验,再一次往返执行 N 行参数
751
+
752
+ 私有方法(不属于公共面):仅 ``execute`` 的列表形态会进入。
753
+
754
+ Args:
755
+ sql: SQL 语句(使用命名参数 ``:param``;**不传** ``params`` 构造语句,
756
+ 即不做 ``IN`` 展开——批量与展开参数互斥)
757
+ rows: 参数字典列表(非空;键集一致;值须为标量)
758
+
759
+ Returns:
760
+ 受影响的行数(各行累加;驱动不上报时为 ``-1``)
761
+ """
762
+ if not rows:
763
+ # 空列表语义:不触库、不报错,直接返回 0(DBAPI 对 executemany
764
+ # 传空列表会报 "A value is required for bind parameter")
765
+ logger.info("批量执行收到空参数列表 → 不执行任何语句")
766
+ return 0
767
+ _validate_batch_rows(rows)
768
+ statement, _ = prepare_statement(sql)
769
+ with session_scope(cls.DB_TYPE) as session:
770
+ result = session.execute(statement, rows)
771
+ affected = getattr(result, "rowcount", 0) or 0
772
+ logger.info(f"✅ 批量执行 → {len(rows)} 行参数,影响 {affected} 行")
773
+ return affected
774
+
637
775
  # ==================== 原生表操作 ====================
638
776
 
639
777
  @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