├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
│ ├── bug-report.yml
│ ├── config.yml
│ └── feature_request.yml
├── dependabot.yml
├── release-drafter.yml
└── workflows
│ ├── codecov.yml
│ ├── release-draft.yml
│ └── release.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .python-version
├── .vscode
└── launch.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── nonebot_plugin_datastore
├── __init__.py
├── compat.py
├── config.py
├── db.py
├── migrations
│ └── 0f8d23241fd7_.py
├── plugin.py
├── providers
│ ├── __init__.py
│ ├── database.py
│ ├── json.py
│ ├── toml.py
│ └── yaml.py
├── py.typed
├── script
│ ├── __init__.py
│ ├── cli.py
│ ├── command.py
│ ├── migration
│ │ └── script.py.mako
│ └── utils.py
└── utils.py
├── pyproject.toml
├── requirements-dev.lock
├── requirements.lock
└── tests
├── __init__.py
├── conftest.py
├── example
├── plugin1
│ ├── __init__.py
│ ├── migrations
│ │ └── bef062d23d1f_.py
│ └── models.py
├── plugin2
│ ├── __init__.py
│ └── models.py
├── plugin_migrate
│ ├── __init__.py
│ ├── migrations
│ │ └── bef062d23d1f_init.py
│ └── models.py
└── pre_db_init_error
│ ├── __init__.py
│ └── models.py
├── registry
├── plugin1
│ ├── __init__.py
│ ├── migrations
│ │ └── ff18b81ee1ca_init_db.py
│ └── models.py
├── plugin2
│ ├── __init__.py
│ ├── migrations
│ │ └── a1219e33400e_init_db.py
│ └── models.py
├── plugin3
│ ├── __init__.py
│ ├── migrations
│ │ └── 548b56a0ceca_.py
│ └── models.py
└── plugin3_plugin4
│ ├── __init__.py
│ ├── migrations
│ └── 2c2ebd61d932_init_db.py
│ └── models.py
├── test_cli.py
├── test_config.py
├── test_db.py
├── test_network_file.py
├── test_open.py
├── test_plugin.py
├── test_registry.py
├── test_singleton.py
└── utils.py
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Editor configuration, see http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | end_of_line = lf
7 | indent_style = space
8 | indent_size = 2
9 | insert_final_newline = true
10 | trim_trailing_whitespace = true
11 |
12 | [*.py]
13 | indent_size = 4
14 |
15 | [*.md]
16 | max_line_length = off
17 | insert_final_newline = false
18 | trim_trailing_whitespace = false
19 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug-report.yml:
--------------------------------------------------------------------------------
1 | name: Bug 反馈
2 | title: "Bug: 出现异常"
3 | description: 提交 Bug 反馈以帮助我们改进代码
4 | labels: ["bug"]
5 | body:
6 | - type: textarea
7 | id: describe
8 | attributes:
9 | label: 描述问题
10 | description: 清晰简洁地说明问题是什么
11 | validations:
12 | required: true
13 |
14 | - type: textarea
15 | id: reproduction
16 | attributes:
17 | label: 复现步骤
18 | description: 提供能复现此问题的详细操作步骤
19 | placeholder: |
20 | 1. 首先……
21 | 2. 然后……
22 | 3. 发生……
23 | validations:
24 | required: true
25 |
26 | - type: textarea
27 | id: expected
28 | attributes:
29 | label: 期望的结果
30 | description: 清晰简洁地描述你期望发生的事情
31 |
32 | - type: textarea
33 | id: logs
34 | attributes:
35 | label: 截图或日志
36 | description: 提供有助于诊断问题的任何日志和截图
37 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: 讨论区
4 | url: https://github.com/he0119/nonebot-plugin-datastore/discussions
5 | about: 使用中若遇到问题,请先在这里求助。
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: 功能建议
2 | title: "Feature: 功能描述"
3 | description: 提出关于项目新功能的想法
4 | labels: ["enhancement"]
5 | body:
6 | - type: textarea
7 | id: problem
8 | attributes:
9 | label: 希望能解决的问题
10 | description: 在使用中遇到什么问题而需要新的功能?
11 | validations:
12 | required: true
13 |
14 | - type: textarea
15 | id: feature
16 | attributes:
17 | label: 描述所需要的功能
18 | description: 请说明需要的功能或解决方法
19 | validations:
20 | required: true
21 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "pip"
4 | directory: "/"
5 | schedule:
6 | interval: "daily"
7 | groups:
8 | production-dependencies:
9 | dependency-type: "production"
10 | update-types:
11 | - "patch"
12 | - "minor"
13 | development-dependencies:
14 | dependency-type: "development"
15 | update-types:
16 | - "patch"
17 | - "minor"
18 | - "major"
19 | - package-ecosystem: "github-actions"
20 | directory: "/"
21 | schedule:
22 | interval: "daily"
23 | groups:
24 | actions-update:
25 | patterns:
26 | - "*"
27 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | template: $CHANGES
2 | name-template: "v$RESOLVED_VERSION"
3 | tag-template: "v$RESOLVED_VERSION"
4 | exclude-labels:
5 | - "dependencies"
6 | - "skip-changelog"
7 | autolabeler:
8 | - label: "bug"
9 | branch:
10 | - '/fix\/.+/'
11 | - label: "change"
12 | branch:
13 | - '/change\/.+/'
14 | - label: "enhancement"
15 | branch:
16 | - '/feature\/.+/'
17 | - '/feat\/.+/'
18 | - '/improve\/.+/'
19 | - label: "ci"
20 | files:
21 | - .github/**/*
22 | - label: "breaking-change"
23 | title:
24 | - "/.+!:.+/"
25 | categories:
26 | - title: 💥 破坏性变更
27 | labels:
28 | - breaking-change
29 | - title: 🚀 新功能
30 | labels:
31 | - enhancement
32 | - title: 🐛 Bug 修复
33 | labels:
34 | - bug
35 | - title: 💫 杂项
36 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)"
37 | change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
38 | version-resolver:
39 | major:
40 | labels:
41 | - "major"
42 | minor:
43 | labels:
44 | - "minor"
45 | patch:
46 | labels:
47 | - "patch"
48 | default: patch
49 |
--------------------------------------------------------------------------------
/.github/workflows/codecov.yml:
--------------------------------------------------------------------------------
1 | name: Code Coverage
2 |
3 | on:
4 | push:
5 | branches:
6 | - "main"
7 | pull_request:
8 |
9 | jobs:
10 | test:
11 | name: Test
12 | runs-on: ${{ matrix.os }}
13 | strategy:
14 | matrix:
15 | python-version: ["3.9", "3.10", "3.11", "3.12"]
16 | os: [ubuntu-latest, windows-latest, macos-latest]
17 | fail-fast: false
18 | env:
19 | OS: ${{ matrix.os }}
20 | PYTHON_VERSION: ${{ matrix.python-version }}
21 | steps:
22 | - name: Checkout
23 | uses: actions/checkout@v4
24 | - name: Setup rye
25 | uses: eifinger/setup-rye@d4c3ac7b15d8bf2e0b45e2d257c6b5cdbebc3643
26 | with:
27 | enable-cache: true
28 | cache-prefix: ${{ env.PYTHON_VERSION }}
29 | - name: Install prerequisites
30 | run: |
31 | rye pin ${{ env.PYTHON_VERSION }}
32 | rye sync
33 | - name: Run tests
34 | run: rye run test
35 | - name: Upload coverage to Codecov
36 | uses: codecov/codecov-action@v4
37 | with:
38 | token: ${{ secrets.CODECOV_TOKEN }}
39 | env_vars: OS,PYTHON_VERSION
40 | check:
41 | if: always()
42 | needs: test
43 | runs-on: ubuntu-latest
44 | steps:
45 | - name: Decide whether the needed jobs succeeded or failed
46 | uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe
47 | with:
48 | jobs: ${{ toJSON(needs) }}
49 |
--------------------------------------------------------------------------------
/.github/workflows/release-draft.yml:
--------------------------------------------------------------------------------
1 | name: Release Drafter
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | types: [opened, reopened, synchronize]
9 |
10 | jobs:
11 | update_release_draft:
12 | name: Update Release Draft
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: release-drafter/release-drafter@v6
16 | env:
17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 | release:
10 | name: Release
11 | runs-on: ubuntu-latest
12 | environment: release
13 | permissions:
14 | id-token: write
15 | contents: write
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v4
19 |
20 | - name: Setup rye
21 | uses: eifinger/setup-rye@d4c3ac7b15d8bf2e0b45e2d257c6b5cdbebc3643
22 | with:
23 | enable-cache: true
24 |
25 | - name: Install prerequisites
26 | run: rye sync
27 |
28 | - name: Get Version
29 | id: version
30 | run: |
31 | echo "VERSION=$(rye version)" >> $GITHUB_OUTPUT
32 | echo "TAG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
33 | echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
34 |
35 | - name: Check Version
36 | if: steps.version.outputs.VERSION != steps.version.outputs.TAG_VERSION
37 | run: exit 1
38 |
39 | - name: Build
40 | run: rye build
41 |
42 | - name: Publish a Python distribution to PyPI
43 | uses: pypa/gh-action-pypi-publish@release/v1
44 |
45 | - name: Upload Release Asset
46 | run: gh release upload --clobber ${{ steps.version.outputs.TAG_NAME }} dist/*.tar.gz dist/*.whl
47 | env:
48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/python
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python
3 |
4 | ### Python ###
5 | # Byte-compiled / optimized / DLL files
6 | __pycache__/
7 | *.py[cod]
8 | *$py.class
9 |
10 | # C extensions
11 | *.so
12 |
13 | # Distribution / packaging
14 | .Python
15 | build/
16 | develop-eggs/
17 | dist/
18 | downloads/
19 | eggs/
20 | .eggs/
21 | lib/
22 | lib64/
23 | parts/
24 | sdist/
25 | var/
26 | wheels/
27 | pip-wheel-metadata/
28 | share/python-wheels/
29 | *.egg-info/
30 | .installed.cfg
31 | *.egg
32 | MANIFEST
33 |
34 | # PyInstaller
35 | # Usually these files are written by a python script from a template
36 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
37 | *.manifest
38 | *.spec
39 |
40 | # Installer logs
41 | pip-log.txt
42 | pip-delete-this-directory.txt
43 |
44 | # Unit test / coverage reports
45 | htmlcov/
46 | .tox/
47 | .nox/
48 | .coverage
49 | .coverage.*
50 | .cache
51 | nosetests.xml
52 | coverage.xml
53 | *.cover
54 | *.py,cover
55 | .hypothesis/
56 | .pytest_cache/
57 | pytestdebug.log
58 |
59 | # Translations
60 | *.mo
61 | *.pot
62 |
63 | # Django stuff:
64 | *.log
65 | local_settings.py
66 | db.sqlite3
67 | db.sqlite3-journal
68 |
69 | # Flask stuff:
70 | instance/
71 | .webassets-cache
72 |
73 | # Scrapy stuff:
74 | .scrapy
75 |
76 | # Sphinx documentation
77 | docs/_build/
78 | doc/_build/
79 |
80 | # PyBuilder
81 | target/
82 |
83 | # Jupyter Notebook
84 | .ipynb_checkpoints
85 |
86 | # IPython
87 | profile_default/
88 | ipython_config.py
89 |
90 | # pyenv
91 | .python-version
92 |
93 | # pipenv
94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
97 | # install all needed dependencies.
98 | #Pipfile.lock
99 |
100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
101 | __pypackages__/
102 |
103 | # Celery stuff
104 | celerybeat-schedule
105 | celerybeat.pid
106 |
107 | # SageMath parsed files
108 | *.sage.py
109 |
110 | # Environments
111 | .env
112 | .venv
113 | env/
114 | venv/
115 | ENV/
116 | env.bak/
117 | venv.bak/
118 |
119 | # Spyder project settings
120 | .spyderproject
121 | .spyproject
122 |
123 | # Rope project settings
124 | .ropeproject
125 |
126 | # mkdocs documentation
127 | /site
128 |
129 | # mypy
130 | .mypy_cache/
131 | .dmypy.json
132 | dmypy.json
133 |
134 | # Pyre type checker
135 | .pyre/
136 |
137 | # pytype static type analyzer
138 | .pytype/
139 |
140 | # End of https://www.toptal.com/developers/gitignore/api/python
141 |
142 | # VSCode
143 | .vscode/*
144 | !.vscode/launch.json
145 |
146 | data/
147 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ci:
2 | autofix_commit_msg: "style: auto fix by pre-commit hooks"
3 | autofix_prs: true
4 | autoupdate_branch: main
5 | autoupdate_schedule: quarterly
6 | autoupdate_commit_msg: "chore: auto update by pre-commit hooks"
7 | repos:
8 | - repo: https://github.com/astral-sh/ruff-pre-commit
9 | rev: v0.5.0
10 | hooks:
11 | - id: ruff
12 | args: [--fix, --exit-non-zero-on-fix]
13 | stages: [commit]
14 | - id: ruff-format
15 |
16 | - repo: https://github.com/pre-commit/mirrors-prettier
17 | rev: v4.0.0-alpha.8
18 | hooks:
19 | - id: prettier
20 | types_or: [javascript, jsx, ts, tsx, markdown, yaml, json]
21 | stages: [commit]
22 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.12.3
2 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // 使用 IntelliSense 了解相关属性。
3 | // 悬停以查看现有属性的描述。
4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Run",
9 | "type": "python",
10 | "request": "launch",
11 | "module": "nb_cli",
12 | "args": ["run"],
13 | "justMyCode": false
14 | },
15 | {
16 | "name": "Pytest",
17 | "type": "python",
18 | "request": "launch",
19 | "module": "pytest",
20 | "justMyCode": false
21 | },
22 | {
23 | "name": "upgrade",
24 | "type": "python",
25 | "request": "launch",
26 | "module": "nb_cli",
27 | "args": ["datastore", "upgrade", "-n", "example"],
28 | "justMyCode": false
29 | },
30 | {
31 | "name": "revision",
32 | "type": "python",
33 | "request": "launch",
34 | "module": "nb_cli",
35 | "args": ["datastore", "revision", "--autogenerate"],
36 | "justMyCode": false
37 | },
38 | {
39 | "name": "Debug Unit Test",
40 | "type": "python",
41 | "request": "test",
42 | "justMyCode": false
43 | }
44 | ]
45 | }
46 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [1.3.0] - 2024-06-20
11 |
12 | ### Fixed
13 |
14 | - 限制 NoneBot 依赖版本至 2.3.0 以上
15 |
16 | ## [1.2.1] - 2024-06-13
17 |
18 | ### Fixed
19 |
20 | - 让自动获取插件名更稳定
21 |
22 | ## [1.2.0] - 2024-02-28
23 |
24 | ### Added
25 |
26 | - 适配 Pydantic V2
27 |
28 | ## [1.1.2] - 2023-09-15
29 |
30 | ### Added
31 |
32 | - 默认设置 naming_convention
33 |
34 | ## [1.1.1] - 2023-09-05
35 |
36 | ### Fixed
37 |
38 | - 修复意外导入 click 的错误
39 | - 修复无法正确生成迁移脚本的问题
40 |
41 | ## [1.1.0] - 2023-07-26
42 |
43 | ### Added
44 |
45 | - 支持使用全局 registry
46 |
47 | ## [1.0.0] - 2023-06-06
48 |
49 | ### Added
50 |
51 | - 添加插件元数据
52 |
53 | ## [0.6.3] - 2023-04-24
54 |
55 | ### Changed
56 |
57 | - 顺序执行钩子函数并统一命令行与初始化时的行为
58 | - 调整 sqlalchemy 依赖,使用 extras 获取 aiosqlite
59 |
60 | ## [0.6.2] - 2023-04-01
61 |
62 | ### Fixed
63 |
64 | - 修复在 NoneBot2 RC4 上报错的问题
65 |
66 | ## [0.6.1] - 2023-03-29
67 |
68 | ### Fixed
69 |
70 | - 修复在 Windows 上找不到迁移文件的问题
71 |
72 | ## [0.6.0] - 2023-03-20
73 |
74 | ### Added
75 |
76 | - 支持多种配置格式
77 |
78 | ### Changed
79 |
80 | - 切换到 SQLAlchemy 2.0
81 |
82 | ### Fixed
83 |
84 | - 修复 NetworkFile 在设置 cache=True 时无法缓存数据的问题
85 |
86 | ## [0.5.10] - 2023-02-26
87 |
88 | ### Added
89 |
90 | - 添加 engine_options 配置项
91 |
92 | ### Fixed
93 |
94 | - 修复自定义数据库连接可能会出现文件夹未创建的问题
95 |
96 | ## [0.5.9] - 2023-02-25
97 |
98 | ### Fixed
99 |
100 | - 修复运行中删除目录导致报错的问题
101 | - 升级数据库时只执行对应插件的 pre_db_init 函数
102 | - 修复运行中删除文件后写入配置时报错的问题
103 |
104 | ## [0.5.8] - 2023-02-10
105 |
106 | ### Added
107 |
108 | - 添加运行 cli 缺少依赖时的提示
109 |
110 | ### Fixed
111 |
112 | - 添加 py.typed 文件
113 |
114 | ## [0.5.7] - 2023-02-06
115 |
116 | ### Fixed
117 |
118 | - 修复缺少 nb_cli 模块的问题
119 | - 数据库初始化前执行的函数运行出错,则不继续执行后续初始化
120 | - 修复插件间模型定义冲突的问题
121 |
122 | ## [0.5.6] - 2023-01-26
123 |
124 | ### Added
125 |
126 | - 添加查看数据存储路径的命令
127 |
128 | ### Fixed
129 |
130 | - 修复运行升级命令时同时升级多个插件报错的问题
131 |
132 | ## [0.5.5] - 2023-01-21
133 |
134 | ### Added
135 |
136 | - 添加 history, current, heads, check 命令
137 | - 添加 migrate 命令
138 |
139 | ## [0.5.4] - 2023-01-16
140 |
141 | ### Changed
142 |
143 | - 默认开启 compare_type
144 | - 兼容以前不支持迁移的版本
145 |
146 | ## [0.5.3] - 2023-01-16
147 |
148 | ### Changed
149 |
150 | - 默认开启 render_as_batch
151 |
152 | ## [0.5.2] - 2023-01-15
153 |
154 | ### Added
155 |
156 | - 添加 pre_db_init 钩子
157 |
158 | ## [0.5.1] - 2023-01-15
159 |
160 | ### Fixed
161 |
162 | - 修复迁移文件夹名称为单数的问题
163 |
164 | ## [0.5.0] - 2023-01-15
165 |
166 | ### Added
167 |
168 | - 支持 Alembic
169 | - 添加 get_plugin_data 函数,在插件中调用会自动返回符合当前插件名的 PluginData 实例
170 |
171 | ## [0.4.0] - 2022-10-05
172 |
173 | ### Removed
174 |
175 | - 移除 Export 功能
176 | - 对照 NoneBot2 移除 Python 3.7 支持
177 |
178 | ## [0.3.4] - 2022-10-05
179 |
180 | ### Changed
181 |
182 | - 限制 NoneBot2 版本
183 |
184 | ## [0.3.3] - 2022-09-07
185 |
186 | ### Fixed
187 |
188 | - 再次升级 SQLModel 版本,修复问题
189 |
190 | ## [0.3.2] - 2022-08-28
191 |
192 | ### Fixed
193 |
194 | - 修复 SQLModel 与 SQLAlchemy 不兼容的问题
195 |
196 | ## [0.3.1] - 2022-05-23
197 |
198 | ### Added
199 |
200 | - 支持下载文件至本地
201 |
202 | ### Changed
203 |
204 | - 使用 require 确保插件加载再 import
205 | - 优化类型提示
206 |
207 | ### Fixed
208 |
209 | - 锁定 SQLAlchemy 版本以避免错误
210 |
211 | ## [0.3.0] - 2022-02-01
212 |
213 | ### Changed
214 |
215 | - 默认开启数据库
216 |
217 | ## [0.2.0] - 2022-01-26
218 |
219 | ### Added
220 |
221 | - 增加 `load_json` 与 `dump_json` 方法
222 | - 添加 `create_session` 函数
223 |
224 | ### Changed
225 |
226 | - 统一方法名称为 `dump`
227 | - `dump_pkl` 和 `load_pkl` 不再自动添加 pkl 后缀
228 |
229 | ## [0.1.1] - 2022-01-24
230 |
231 | ### Changed
232 |
233 | - 每个相同名称的插件数据只会返回同一个实例
234 |
235 | ## [0.1.0] - 2022-01-24
236 |
237 | ### Added
238 |
239 | - 可以使用的版本。
240 |
241 | [unreleased]: https://github.com/he0119/nonebot-plugin-datastore/compare/v1.3.0...HEAD
242 | [1.3.0]: https://github.com/he0119/nonebot-plugin-datastore/compare/v1.2.1...v1.3.0
243 | [1.2.1]: https://github.com/he0119/nonebot-plugin-datastore/compare/v1.2.0...v1.2.1
244 | [1.2.0]: https://github.com/he0119/nonebot-plugin-datastore/compare/v1.1.2...v1.2.0
245 | [1.1.2]: https://github.com/he0119/nonebot-plugin-datastore/compare/v1.1.1...v1.1.2
246 | [1.1.1]: https://github.com/he0119/nonebot-plugin-datastore/compare/v1.1.0...v1.1.1
247 | [1.1.0]: https://github.com/he0119/nonebot-plugin-datastore/compare/v1.0.0...v1.1.0
248 | [1.0.0]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.6.3...v1.0.0
249 | [0.6.3]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.6.2...v0.6.3
250 | [0.6.2]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.6.1...v0.6.2
251 | [0.6.1]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.6.0...v0.6.1
252 | [0.6.0]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.10...v0.6.0
253 | [0.5.10]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.9...v0.5.10
254 | [0.5.9]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.8...v0.5.9
255 | [0.5.8]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.7...v0.5.8
256 | [0.5.7]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.6...v0.5.7
257 | [0.5.6]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.5...v0.5.6
258 | [0.5.5]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.4...v0.5.5
259 | [0.5.4]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.3...v0.5.4
260 | [0.5.3]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.2...v0.5.3
261 | [0.5.2]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.1...v0.5.2
262 | [0.5.1]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.5.0...v0.5.1
263 | [0.5.0]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.4.0...v0.5.0
264 | [0.4.0]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.3.4...v0.4.0
265 | [0.3.4]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.3.3...v0.3.4
266 | [0.3.3]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.3.2...v0.3.3
267 | [0.3.2]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.3.1...v0.3.2
268 | [0.3.1]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.3.0...v0.3.1
269 | [0.3.0]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.2.0...v0.3.0
270 | [0.2.0]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.1.1...v0.2.0
271 | [0.1.1]: https://github.com/he0119/nonebot-plugin-datastore/compare/v0.1.0...v0.1.1
272 | [0.1.0]: https://github.com/he0119/nonebot-plugin-datastore/releases/tag/v0.1.0
273 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 hemengyang
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | # NoneBot Plugin DataStore
10 |
11 | _✨ NoneBot 数据存储插件 ✨_
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | ## 安装
32 |
33 | - 使用 nb-cli
34 |
35 | ```sh
36 | nb plugin install nonebot-plugin-datastore
37 | ```
38 |
39 | - 使用 pip
40 |
41 | ```sh
42 | pip install nonebot-plugin-datastore
43 | ```
44 |
45 | ## 使用方式
46 |
47 | 先在插件代码最前面声明依赖
48 |
49 | ```python
50 | from nonebot import require
51 | require("nonebot_plugin_datastore")
52 | ```
53 |
54 | ### 插件数据相关功能
55 |
56 | ```python
57 | from nonebot_plugin_datastore import get_plugin_data
58 |
59 | plugin_data = get_plugin_data()
60 |
61 | # 获取插件缓存目录
62 | plugin_data.cache_dir
63 | # 获取插件配置目录
64 | plugin_data.config_dir
65 | # 获取插件数据目录
66 | plugin_data.data_dir
67 |
68 | # 读取配置
69 | await plugin_data.config.get(key)
70 | # 存储配置
71 | await plugin_data.config.set(key, value)
72 | ```
73 |
74 | ### 数据库相关功能,详细用法见 [SQLAlchemy](https://docs.sqlalchemy.org/orm/quickstart.html)
75 |
76 | ```python
77 | from nonebot import on_command
78 | from nonebot.params import Depends
79 | from sqlalchemy.ext.asyncio.session import AsyncSession
80 | from sqlalchemy.orm import Mapped, mapped_column
81 |
82 | from nonebot_plugin_datastore import get_plugin_data, get_session
83 |
84 | # 定义模型
85 | Model = get_plugin_data().Model
86 |
87 | class Example(Model):
88 | """示例模型"""
89 |
90 | id: Mapped[int] = mapped_column(primary_key=True)
91 | message: Mapped[str]
92 |
93 | matcher = on_command("test")
94 |
95 | # 数据库相关操作
96 | @matcher.handle()
97 | async def handle(session: AsyncSession = Depends(get_session)):
98 | example = Example(message="matcher")
99 | session.add(example)
100 | await session.commit()
101 |
102 | # 因为 driver.on_startup 无法保证函数运行顺序
103 | # 如需在 NoneBot 启动时且数据库初始化后运行的函数
104 | # 请使用 post_db_init 而不是 Nonebot 的 on_startup
105 | from nonebot_plugin_datastore.db import post_db_init
106 |
107 |
108 | @post_db_init
109 | async def do_something():
110 | pass
111 | ```
112 |
113 | ### 命令行支持(需安装 [nb-cli 1.0+](https://github.com/nonebot/nb-cli))
114 |
115 | 如果使用 pipx 安装的 nb-cli,则需要运行 `pip install nonebot-plugin-datastore[cli]` 安装命令行所需依赖。
116 |
117 | #### 数据存储路径
118 |
119 | ```shell
120 | # 获取当前数据存储路径
121 | nb datastore dir
122 | # 获取指定插件的数据存储路径
123 | nb datastore dir --name plugin_name
124 | ```
125 |
126 | #### 数据库管理,详细用法见 [Alembic](https://alembic.sqlalchemy.org/en/latest/)
127 |
128 | 生成迁移文件
129 |
130 | ```shell
131 | # 生成项目内所有启用数据库插件的迁移文件(不包括 site-packages 中的插件)
132 | nb datastore migrate
133 | # 生成指定插件的迁移文件
134 | nb datastore migrate --name plugin_name -m example
135 | ```
136 |
137 | 升级插件数据库
138 |
139 | ```shell
140 | # 升级所有启用数据库插件的数据库
141 | nb datastore upgrade
142 | # 升级指定插件的数据库
143 | nb datastore upgrade --name plugin_name
144 | # 升级至指定版本
145 | nb datastore upgrade --name plugin_name revision
146 | ```
147 |
148 | 降级插件数据库
149 |
150 | ```shell
151 | # 降级所有启用数据库插件的数据库
152 | nb datastore downgrade
153 | # 降级指定插件的数据库
154 | nb datastore downgrade --name plugin_name
155 | # 降级至指定版本
156 | nb datastore downgrade --name plugin_name revision
157 | ```
158 |
159 | ## 注意
160 |
161 | ### 数据库迁移
162 |
163 | 推荐启动机器人前运行 `nb datastore upgrade` 升级数据库至最新版本。因为当前插件自动迁移依赖 `NoneBot` 的 `on_startup` 钩子,很容易受到其他插件影响。
164 |
165 | 这里推荐 [tiangolo/uvicorn-gunicorn](https://github.com/tiangolo/uvicorn-gunicorn-docker) 镜像,通过配置 `prestart.sh` 可确保启动机器人前运行迁移脚本。具体的例子可参考 [CoolQBot](https://github.com/he0119/CoolQBot/)。
166 |
167 | ### MySQL 数据库连接丢失
168 |
169 | 当使用 `MySQL` 时,你可能会遇到 `2013: lost connection to mysql server during query` 的报错。
170 |
171 | 如果遇到这种错误,可以尝试设置 `pool_recycle` 为一个小于数据库超时的值。或者设置 `pool_pre_ping` 为 `True`。
172 |
173 | ```env
174 | DATASTORE_ENGINE_OPTIONS={"pool_recycle": 3600}
175 | DATASTORE_ENGINE_OPTIONS={"pool_pre_ping": true}
176 | ```
177 |
178 | 详细介绍可查看 `SQLAlchemy` 文档的 [dealing-with-disconnects](https://docs.sqlalchemy.org/en/20/core/pooling.html#dealing-with-disconnects) 章节。
179 |
180 | ### SQLite 数据库已锁定
181 |
182 | 使用 `SQLite` 数据库时,如果在写入时遇到 `(sqlite3.OperationalError) database is locked` 错误。可尝试将 `poolclass` 设置为 `StaticPool`,保持有且仅有一个连接。不过这样设置之后,在程序运行期间,你的数据库文件都将被占用。
183 |
184 | ### 不同插件间表的关联关系
185 |
186 | datastore 默认会给每个插件的 Base 模型提供独立的 registry,所以不同插件间的表无法建立关联关系。如果你需要与其他插件的表建立关联关系,请在需要关联的两个插件中都调用 use_global_registry 函数使用全局 registry。
187 |
188 | ```python
189 | # 定义模型
190 | db = get_plugin_data()
191 | db.use_global_registry()
192 |
193 | class Example(db.Model):
194 | """实例函数"""
195 |
196 | id: Mapped[int] = mapped_column(primary_key=True)
197 | message: Mapped[str]
198 |
199 | tests: Mapped["Test"] = relationship(back_populates="example")
200 |
201 |
202 | class Test(db.Model):
203 | id: Mapped[int] = mapped_column(primary_key=True)
204 |
205 | example_id: Mapped[int] = mapped_column(ForeignKey("plugin_example.id"))
206 | example: Mapped[Example] = relationship(back_populates="tests")
207 |
208 | # 注意,为了避免不同插件的模型同名而报错,请一定要加上这一行,避免如下报错
209 | # sqlalchemy.exc.InvalidRequestError: Multiple classes found for path "Test" in the registry of this declarative base. Please use a fully module-qualified path.
210 | Example.tests = relationship(Test, back_populates="example")
211 | ```
212 |
213 | ## 配置项
214 |
215 | 配置方式:直接在 `NoneBot` 全局配置文件中添加以下配置项即可。
216 |
217 | ### datastore_cache_dir
218 |
219 | - 类型: `Path`
220 | - 默认:
221 | - macOS: ~/Library/Caches/nonebot2
222 | - Unix: ~/.cache/nonebot2 (XDG default)
223 | - Windows: C:\Users\\AppData\Local\nonebot2\Cache
224 | - 说明: 缓存目录
225 |
226 | ### datastore_config_dir
227 |
228 | - 类型: `Path`
229 | - 默认:
230 | - macOS: same as user_data_dir
231 | - Unix: ~/.config/nonebot2
232 | - Win XP (roaming): C:\Documents and Settings\\Local Settings\Application Data\nonebot2
233 | - Win 7 (roaming): C:\Users\\AppData\Roaming\nonebot2
234 | - 说明: 配置目录
235 |
236 | ### datastore_data_dir
237 |
238 | - 类型: `Path`
239 | - 默认:
240 | - macOS: ~/Library/Application Support/nonebot2
241 | - Unix: ~/.local/share/nonebot2 or in $XDG_DATA_HOME, if defined
242 | - Win XP (not roaming): C:\Documents and Settings\\Application Data\nonebot2
243 | - Win 7 (not roaming): C:\Users\\AppData\Local\nonebot2
244 | - 说明: 数据目录
245 |
246 | ### datastore_enable_database
247 |
248 | - 类型: `bool`
249 | - 默认: `True`
250 | - 说明: 是否启动数据库
251 |
252 | ### datastore_database_url
253 |
254 | - 类型: `str`
255 | - 默认: `sqlite+aiosqlite:///data_dir/data.db`
256 | - 说明: 数据库连接字符串,默认使用 SQLite 数据库
257 |
258 | ### datastore_database_echo
259 |
260 | - 类型: `bool`
261 | - 默认: `False`
262 | - 说明: `echo` 和 `echo_pool` 的默认值,是否显示数据库执行的语句与其参数列表,还有连接池的相关信息
263 |
264 | ### datastore_engine_options
265 |
266 | - 类型: `dict[str, Any]`
267 | - 默认: `{}`
268 | - 说明: 向 `sqlalchemy.ext.asyncio.create_async_engine()` 传递的参数
269 |
270 | ### datastore_config_provider
271 |
272 | - 类型: `str`
273 | - 默认: `~json`
274 | - 说明: 选择存放配置的类型,当前支持 json, yaml, toml, database 四种类型,也可设置为实现 `ConfigProvider` 的自定义类型。
275 |
276 | ## 鸣谢
277 |
278 | - [`NoneBot Plugin LocalStore`](https://github.com/nonebot/plugin-localstore): 提供了默认的文件存储位置
279 | - [`Flask-SQLAlchemy`](https://github.com/pallets-eco/flask-sqlalchemy/): 借鉴了数据库的实现思路
280 | - [`Flask-Alembic`](https://github.com/davidism/flask-alembic): 借鉴了命令行的实现思路
281 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import require
2 |
3 | require("nonebot_plugin_localstore")
4 |
5 | from nonebot.plugin import PluginMetadata
6 |
7 | from .config import Config
8 | from .db import create_session as create_session
9 | from .db import get_session as get_session
10 | from .plugin import PluginData as PluginData
11 | from .plugin import get_plugin_data as get_plugin_data
12 |
13 | __plugin_meta__ = PluginMetadata(
14 | name="数据存储",
15 | description="NoneBot 数据存储插件",
16 | usage="请参考文档",
17 | type="library",
18 | homepage="https://github.com/he0119/nonebot-plugin-datastore",
19 | config=Config,
20 | )
21 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/compat.py:
--------------------------------------------------------------------------------
1 | from typing import Literal, overload
2 |
3 | from nonebot.compat import PYDANTIC_V2
4 |
5 | __all__ = ("model_validator",)
6 |
7 |
8 | if PYDANTIC_V2:
9 | from pydantic import model_validator as model_validator
10 |
11 | else:
12 | from pydantic import root_validator
13 |
14 | @overload
15 | def model_validator(*, mode: Literal["before"]): ...
16 |
17 | @overload
18 | def model_validator(*, mode: Literal["after"]): ...
19 |
20 | def model_validator(*, mode: Literal["before", "after"]):
21 | return root_validator(pre=mode == "before", allow_reuse=True)
22 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/config.py:
--------------------------------------------------------------------------------
1 | """配置"""
2 |
3 | from pathlib import Path
4 | from typing import Any
5 |
6 | from nonebot import get_plugin_config
7 | from nonebot_plugin_localstore import get_cache_dir, get_config_dir, get_data_dir
8 | from pydantic import BaseModel
9 |
10 | from .compat import model_validator
11 |
12 |
13 | class Config(BaseModel):
14 | datastore_cache_dir: Path
15 | datastore_config_dir: Path
16 | datastore_data_dir: Path
17 | datastore_database_url: str
18 | """数据库连接字符串
19 |
20 | 默认使用 SQLite
21 | """
22 | datastore_enable_database: bool = True
23 | datastore_database_echo: bool = False
24 | datastore_engine_options: dict[str, Any] = {}
25 | datastore_config_provider: str = "~json"
26 |
27 | @model_validator(mode="before")
28 | def set_defaults(cls, values: dict):
29 | """设置默认值"""
30 | # 设置默认目录
31 | # 仅在未设置时调用 get_*_dir 函数,因为这些函数会自动创建目录
32 | values["datastore_cache_dir"] = (
33 | Path(cache_dir)
34 | if (cache_dir := values.get("datastore_cache_dir"))
35 | else Path(get_cache_dir(""))
36 | )
37 | values["datastore_config_dir"] = (
38 | Path(config_dir)
39 | if (config_dir := values.get("datastore_config_dir"))
40 | else Path(get_config_dir(""))
41 | )
42 | values["datastore_data_dir"] = (
43 | Path(data_dir)
44 | if (data_dir := values.get("datastore_data_dir"))
45 | else Path(get_data_dir(""))
46 | )
47 |
48 | # 设置默认数据库连接字符串
49 | if not values.get("datastore_database_url"):
50 | values["datastore_database_url"] = (
51 | f"sqlite+aiosqlite:///{values['datastore_data_dir'] / 'data.db'}"
52 | )
53 |
54 | return values
55 |
56 |
57 | plugin_config = get_plugin_config(Config)
58 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/db.py:
--------------------------------------------------------------------------------
1 | """数据库"""
2 |
3 | from collections.abc import AsyncGenerator
4 | from pathlib import Path
5 | from typing import TYPE_CHECKING, Callable
6 |
7 | from nonebot import get_driver
8 | from nonebot.log import logger
9 | from nonebot.utils import is_coroutine_callable, run_sync
10 | from sqlalchemy.engine import make_url
11 | from sqlalchemy.ext.asyncio import create_async_engine
12 | from sqlalchemy.ext.asyncio.session import AsyncSession
13 |
14 | from .config import plugin_config
15 | from .utils import get_caller_plugin_name
16 |
17 | if TYPE_CHECKING:
18 | from sqlalchemy.ext.asyncio.engine import AsyncEngine
19 |
20 |
21 | _engine = None
22 |
23 | _pre_db_init_funcs: dict[str, list] = {}
24 | _post_db_init_funcs = []
25 |
26 |
27 | def _make_engine() -> "AsyncEngine":
28 | """创建数据库引擎"""
29 | url = make_url(plugin_config.datastore_database_url)
30 | if (
31 | url.drivername.startswith("sqlite")
32 | and url.database is not None
33 | and url.database not in [":memory:", ""]
34 | ):
35 | # 创建数据文件夹,防止数据库创建失败
36 | database_path = Path(url.database)
37 | database_path.parent.mkdir(parents=True, exist_ok=True)
38 | logger.debug(f"创建数据库文件夹: {database_path.parent}")
39 | # 创建数据库引擎
40 | engine_options = {}
41 | engine_options.update(plugin_config.datastore_engine_options)
42 | engine_options.setdefault("echo", plugin_config.datastore_database_echo)
43 | engine_options.setdefault("echo_pool", plugin_config.datastore_database_echo)
44 | logger.debug(f"数据库连接地址: {plugin_config.datastore_database_url}")
45 | logger.debug(f"数据库引擎参数: {engine_options}")
46 | return create_async_engine(url, **engine_options)
47 |
48 |
49 | def get_engine() -> "AsyncEngine":
50 | if _engine is None:
51 | raise ValueError("数据库未启用")
52 | return _engine
53 |
54 |
55 | def pre_db_init(func: Callable) -> Callable:
56 | """数据库初始化前执行的函数"""
57 | name = get_caller_plugin_name()
58 | if name not in _pre_db_init_funcs:
59 | _pre_db_init_funcs[name] = []
60 | _pre_db_init_funcs[name].append(func)
61 | return func
62 |
63 |
64 | def post_db_init(func: Callable) -> Callable:
65 | """数据库初始化后执行的函数"""
66 | _post_db_init_funcs.append(func)
67 | return func
68 |
69 |
70 | async def run_funcs(funcs: list[Callable]) -> None:
71 | """运行所有函数"""
72 | for func in funcs:
73 | if is_coroutine_callable(func):
74 | await func()
75 | else:
76 | await run_sync(func)()
77 |
78 |
79 | async def run_pre_db_init_funcs(plugin: str) -> None:
80 | """运行数据库初始化前执行的函数"""
81 | funcs = _pre_db_init_funcs.get(plugin, [])
82 | if funcs:
83 | logger.debug(f"运行插件 {plugin} 的数据库初始化前执行的函数")
84 | await run_funcs(funcs)
85 |
86 |
87 | async def run_post_db_init_funcs() -> None:
88 | """运行数据库初始化后执行的函数"""
89 | if _post_db_init_funcs:
90 | logger.debug("运行数据库初始化后执行的函数")
91 | await run_funcs(_post_db_init_funcs)
92 |
93 |
94 | async def init_db():
95 | """初始化数据库"""
96 | from .script.command import upgrade
97 | from .script.utils import Config, get_plugins
98 |
99 | plugins = get_plugins()
100 | for plugin in plugins:
101 | # 执行数据库初始化前执行的函数
102 | await run_pre_db_init_funcs(plugin)
103 | # 初始化数据库,升级到最新版本
104 | logger.debug(f"初始化插件 {plugin} 的数据库")
105 | config = Config(plugin)
106 | await upgrade(config, "head")
107 |
108 | logger.info("数据库初始化完成")
109 |
110 | # 执行数据库初始化后执行的函数
111 | try:
112 | await run_post_db_init_funcs()
113 | except Exception as e:
114 | logger.error(f"数据库初始化后执行的函数出错: {e}")
115 |
116 |
117 | if plugin_config.datastore_enable_database:
118 | _engine = _make_engine()
119 | get_driver().on_startup(init_db)
120 |
121 |
122 | async def get_session() -> AsyncGenerator[AsyncSession, None]:
123 | """需配合 `Depends` 使用
124 |
125 | 例: `session: AsyncSession = Depends(get_session)`
126 | """
127 | async with AsyncSession(get_engine()) as session:
128 | yield session
129 |
130 |
131 | def create_session() -> AsyncSession:
132 | """创建一个新的 session"""
133 | return AsyncSession(get_engine())
134 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/migrations/0f8d23241fd7_.py:
--------------------------------------------------------------------------------
1 | """init db
2 |
3 | Revision ID: 0f8d23241fd7
4 | Revises:
5 | Create Date: 2023-03-01 14:18:54.876322
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "0f8d23241fd7"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "nonebot_plugin_datastore_configmodel",
23 | sa.Column("key", sa.String(), nullable=False),
24 | sa.Column("value", sa.JSON(), nullable=False),
25 | sa.PrimaryKeyConstraint("key"),
26 | )
27 | # ### end Alembic commands ###
28 |
29 |
30 | def downgrade() -> None:
31 | # ### commands auto generated by Alembic - please adjust! ###
32 | op.drop_table("nonebot_plugin_datastore_configmodel")
33 | # ### end Alembic commands ###
34 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/plugin.py:
--------------------------------------------------------------------------------
1 | """插件数据"""
2 |
3 | import json
4 | import pickle
5 | from pathlib import Path
6 | from typing import Any, Callable, Generic, Optional, TypeVar
7 |
8 | import httpx
9 | from nonebot import get_plugin
10 | from nonebot.log import logger
11 | from sqlalchemy import MetaData
12 | from sqlalchemy.orm import DeclarativeBase, declared_attr, registry
13 |
14 | from .config import plugin_config
15 | from .providers import ConfigProvider
16 | from .utils import get_caller_plugin_name, resolve_dot_notation
17 |
18 | T = TypeVar("T")
19 | R = TypeVar("R")
20 |
21 |
22 | class NetworkFile(Generic[T, R]):
23 | """从网络获取文件
24 |
25 | 暂时只支持 json 格式
26 | """
27 |
28 | def __init__(
29 | self,
30 | url: str,
31 | filename: str,
32 | plugin_data: "PluginData",
33 | process_data: Optional[Callable[[T], R]] = None,
34 | cache: bool = False,
35 | ) -> None:
36 | self._url = url
37 | self._filename = filename
38 | self._plugin_data = plugin_data
39 | self._process_data = process_data
40 | self._cache = cache
41 |
42 | self._data: Optional[R] = None
43 |
44 | async def load_from_network(self) -> T:
45 | """从网络加载文件"""
46 | logger.info("正在从网络获取数据")
47 | content = await self._plugin_data.download_file(
48 | self._url, self._filename, self._cache
49 | )
50 | rjson = json.loads(content)
51 | return rjson
52 |
53 | def load_from_local(self) -> T:
54 | """从本地获取数据"""
55 | logger.info("正在加载本地数据")
56 | data = self._plugin_data.load_json(self._filename, self._cache)
57 | return data
58 |
59 | @property
60 | async def data(self) -> R:
61 | """数据
62 |
63 | 先从本地加载,如果本地文件不存在则从网络加载
64 | """
65 | if self._data is None:
66 | if self._plugin_data.exists(self._filename, self._cache):
67 | data = self.load_from_local()
68 | else:
69 | data = await self.load_from_network()
70 | # 处理数据
71 | if self._process_data:
72 | self._data = self._process_data(data)
73 | else:
74 | self._data = data # type: ignore
75 | return self._data # type: ignore
76 |
77 | async def update(self) -> None:
78 | """从网络更新数据"""
79 | self._data = await self.load_from_network() # type: ignore
80 | if self._process_data:
81 | self._data = self._process_data(self._data)
82 |
83 |
84 | class Singleton(type):
85 | """单例
86 |
87 | 每个相同名称的插件数据只需要一个实例
88 | """
89 |
90 | _instances = {}
91 |
92 | def __call__(cls, name: str):
93 | if not cls._instances.get(name):
94 | cls._instances[name] = super().__call__(name)
95 | return cls._instances[name]
96 |
97 |
98 | # https://alembic.sqlalchemy.org/en/latest/naming.html
99 | # https://alembic.sqlalchemy.org/en/latest/naming.html#integration-of-naming-conventions-into-operations-autogenerate
100 | NAMING_CONVENTION: dict = {
101 | "ix": "ix_%(column_0_label)s",
102 | "uq": "uq_%(table_name)s_%(column_0_name)s",
103 | "ck": "ck_%(table_name)s_%(constraint_name)s",
104 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
105 | "pk": "pk_%(table_name)s",
106 | }
107 |
108 |
109 | class PluginData(metaclass=Singleton):
110 | """插件数据管理
111 |
112 | 将插件数据保存在 `data` 文件夹对应的目录下。
113 | 提供保存和读取文件/数据的方法。
114 | """
115 |
116 | global_registry = registry(metadata=MetaData(naming_convention=NAMING_CONVENTION))
117 |
118 | def __init__(self, name: str) -> None:
119 | # 插件名,用来确定插件的文件夹位置
120 | self.name = name
121 |
122 | # 插件配置
123 | self._config = None
124 |
125 | # 数据库
126 | self._metadata = None
127 | self._model = None
128 | self._migration_path = None
129 | self._use_global_registry = False
130 |
131 | @staticmethod
132 | def _ensure_dir(path: Path):
133 | """确保目录存在"""
134 | if not path.exists():
135 | path.mkdir(parents=True, exist_ok=True)
136 | elif not path.is_dir():
137 | raise RuntimeError(f"{path} 不是目录")
138 |
139 | @property
140 | def cache_dir(self) -> Path:
141 | """缓存目录"""
142 | directory = plugin_config.datastore_cache_dir / self.name
143 | # 每次调用都检查一下目录是否存在
144 | # 防止运行时有人删除目录
145 | self._ensure_dir(directory)
146 | return directory
147 |
148 | @property
149 | def config_dir(self) -> Path:
150 | """配置目录
151 |
152 | 配置都放置在统一的目录下
153 | """
154 | directory = plugin_config.datastore_config_dir
155 | self._ensure_dir(directory)
156 | return directory
157 |
158 | @property
159 | def data_dir(self) -> Path:
160 | """数据目录"""
161 | directory = plugin_config.datastore_data_dir / self.name
162 | self._ensure_dir(directory)
163 | return directory
164 |
165 | @property
166 | def config(self) -> ConfigProvider:
167 | """获取配置管理"""
168 | if not self._config:
169 | self._config = _ProviderClass(self)
170 | return self._config
171 |
172 | def dump_pkl(self, data: Any, filename: str, cache: bool = False, **kwargs) -> None:
173 | with self.open(filename, "wb", cache=cache) as f:
174 | pickle.dump(data, f, **kwargs) # type: ignore
175 |
176 | def load_pkl(self, filename: str, cache: bool = False, **kwargs) -> Any:
177 | with self.open(filename, "rb", cache=cache) as f:
178 | data = pickle.load(f, **kwargs)
179 | return data
180 |
181 | def dump_json(
182 | self,
183 | data: Any,
184 | filename: str,
185 | cache: bool = False,
186 | ensure_ascii: bool = False,
187 | **kwargs,
188 | ) -> None:
189 | with self.open(filename, "w", cache=cache, encoding="utf8") as f:
190 | json.dump(data, f, ensure_ascii=ensure_ascii, **kwargs)
191 |
192 | def load_json(self, filename: str, cache: bool = False, **kwargs) -> Any:
193 | with self.open(filename, "r", cache=cache, encoding="utf8") as f:
194 | data = json.load(f, **kwargs)
195 | return data
196 |
197 | def open(self, filename: str, mode: str = "r", cache: bool = False, **kwargs):
198 | """打开文件,默认打开数据文件夹下的文件"""
199 | if cache:
200 | path = self.cache_dir / filename
201 | else:
202 | path = self.data_dir / filename
203 | return open(path, mode, **kwargs)
204 |
205 | def exists(self, filename: str, cache: bool = False) -> bool:
206 | """判断文件是否存在,默认判断数据文件夹下的文件"""
207 | if cache:
208 | path = self.cache_dir / filename
209 | else:
210 | path = self.data_dir / filename
211 | return path.exists()
212 |
213 | async def download_file(
214 | self, url: str, filename: str, cache: bool = False, **kwargs
215 | ) -> bytes:
216 | """下载文件"""
217 | async with httpx.AsyncClient() as client:
218 | r = await client.get(url, **kwargs)
219 | content = r.content
220 | with self.open(filename, "wb", cache=cache) as f:
221 | f.write(content)
222 | logger.info(f"已下载文件 {url} -> {filename}")
223 | return content
224 |
225 | def network_file(
226 | self,
227 | url: str,
228 | filename: str,
229 | process_data: Optional[Callable[[T], R]] = None,
230 | cache: bool = False,
231 | ) -> NetworkFile[T, R]:
232 | """网络文件
233 |
234 | 从网络上获取数据,并缓存至本地,仅支持 json 格式
235 | 且可以在获取数据之后同时处理数据
236 | """
237 | return NetworkFile[T, R](url, filename, self, process_data, cache)
238 |
239 | @property
240 | def Model(self) -> type[DeclarativeBase]:
241 | """数据库模型"""
242 | if self._model is None:
243 | self._metadata = MetaData(
244 | info={"name": self.name},
245 | naming_convention=NAMING_CONVENTION,
246 | )
247 | if self._use_global_registry:
248 | plugin_registry = self.global_registry
249 | else:
250 | # 为每个插件创建一个独立的 registry
251 | plugin_registry = registry(metadata=self._metadata)
252 |
253 | class _Base(DeclarativeBase):
254 | registry = plugin_registry
255 |
256 | @declared_attr.directive
257 | def __tablename__(cls) -> str:
258 | """设置表名前缀,避免表名冲突
259 |
260 | 规则为:插件名_表名
261 | https://docs.sqlalchemy.org/en/20/orm/declarative_mixins.html#augmenting-the-base
262 | """
263 | table_name = f"{self.name}_{cls.__name__.lower()}"
264 | if self._use_global_registry:
265 | # 如果使用全局 registry,则需要在 metadata 中记录表名和插件名的对应关系 # noqa: E501
266 | # 因为所有插件共用一个 metadata,没法通过 metadata.name 指定插件名 # noqa: E501
267 | if plugin_name_map := cls.metadata.info.get("plugin_name_map"):
268 | plugin_name_map[table_name] = self.name
269 | else:
270 | cls.metadata.info["plugin_name_map"] = {
271 | table_name: self.name
272 | }
273 |
274 | return table_name
275 |
276 | self._model = _Base
277 | return self._model
278 |
279 | @property
280 | def metadata(self) -> Optional[MetaData]:
281 | """获取数据库元数据"""
282 | if self._use_global_registry:
283 | return self.global_registry.metadata
284 | return self._metadata
285 |
286 | @property
287 | def migration_dir(self) -> Optional[Path]:
288 | """数据库迁移文件夹"""
289 | if self._migration_path is None:
290 | plugin = get_plugin(self.name)
291 | if plugin and plugin.module.__file__ and PluginData(plugin.name).metadata:
292 | self._migration_path = (
293 | Path(plugin.module.__file__).parent / "migrations"
294 | )
295 | return self._migration_path
296 |
297 | def set_migration_dir(self, path: Path) -> None:
298 | """设置数据库迁移文件夹"""
299 | self._migration_path = path
300 |
301 | def use_global_registry(self):
302 | """使用全局的 registry
303 |
304 | 请在获取 Model 之前调用此方法
305 | 用于解决多个插件模型互相关联时的问题,请谨慎启用
306 | """
307 | self._use_global_registry = True
308 |
309 |
310 | def get_plugin_data(name: Optional[str] = None) -> PluginData:
311 | """获取插件数据
312 |
313 | 如果名称为空,则尝试自动获取调用者所在的插件名
314 | """
315 | name = name or get_caller_plugin_name()
316 |
317 | return PluginData(name)
318 |
319 |
320 | # 需要等到 PluginData 和 get_plugin_data 定义后才能导入对应的配置
321 | _ProviderClass = resolve_dot_notation(
322 | plugin_config.datastore_config_provider,
323 | default_attr="Config",
324 | default_prefix="nonebot_plugin_datastore.providers.",
325 | )
326 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/providers/__init__.py:
--------------------------------------------------------------------------------
1 | import abc
2 | from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
3 |
4 | if TYPE_CHECKING:
5 | from ..plugin import PluginData
6 |
7 | T = TypeVar("T")
8 | R = TypeVar("R")
9 |
10 |
11 | class KeyNotFoundError(Exception):
12 | """键值未找到"""
13 |
14 | def __init__(self, key: str) -> None:
15 | self.key = key
16 | super().__init__(f"Key {key} not found")
17 |
18 |
19 | class ConfigProvider(abc.ABC):
20 | """插件配置管理"""
21 |
22 | def __init__(self, plugin_data: "PluginData") -> None:
23 | self._plugin_data = plugin_data
24 |
25 | @abc.abstractmethod
26 | async def _get(self, key: str) -> Any:
27 | """获取配置键值"""
28 | raise NotImplementedError
29 |
30 | @abc.abstractmethod
31 | async def _set(self, key: str, value: Any) -> None:
32 | """异步设置配置键值"""
33 | raise NotImplementedError
34 |
35 | @overload
36 | async def get(self, __key: str) -> Union[Any, None]: ...
37 |
38 | @overload
39 | async def get(self, __key: str, __default: T) -> T: ...
40 |
41 | async def get(self, key, default=None):
42 | """获得配置
43 |
44 | 如果配置获取失败则使用 `default` 值并保存
45 | 如果不提供 `default` 默认返回 None
46 | """
47 | try:
48 | value = await self._get(key)
49 | except KeyNotFoundError:
50 | value = default
51 | # 保存默认配置
52 | await self.set(key, value)
53 | return value
54 |
55 | async def set(self, key: str, value: Any) -> None:
56 | """设置配置"""
57 | await self._set(key, value)
58 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/providers/database.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from sqlalchemy import JSON, select
4 | from sqlalchemy.exc import NoResultFound
5 | from sqlalchemy.orm import Mapped, mapped_column
6 |
7 | from ..db import create_session
8 | from ..plugin import get_plugin_data
9 | from . import ConfigProvider, KeyNotFoundError
10 |
11 | plugin_data = get_plugin_data("nonebot_plugin_datastore")
12 |
13 |
14 | class ConfigModel(plugin_data.Model):
15 | key: Mapped[str] = mapped_column(primary_key=True)
16 | value: Mapped[Any] = mapped_column(JSON)
17 |
18 |
19 | class Config(ConfigProvider):
20 | """数据库格式配置"""
21 |
22 | async def _get(self, key: str) -> Any:
23 | db_key = self._plugin_data.name + "_" + key
24 | try:
25 | async with create_session() as session:
26 | config = (
27 | await session.scalars(
28 | select(ConfigModel).where(ConfigModel.key == db_key)
29 | )
30 | ).one()
31 | return config.value
32 | except NoResultFound:
33 | raise KeyNotFoundError(key)
34 |
35 | async def _set(self, key: str, value: Any) -> None:
36 | db_key = self._plugin_data.name + "_" + key
37 | async with create_session() as session:
38 | await session.merge(ConfigModel(key=db_key, value=value))
39 | await session.commit()
40 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/providers/json.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 | from typing import TYPE_CHECKING, Any
4 |
5 | from . import ConfigProvider, KeyNotFoundError
6 |
7 | if TYPE_CHECKING:
8 | from ..plugin import PluginData
9 |
10 |
11 | class Config(ConfigProvider):
12 | """JSON 格式配置"""
13 |
14 | def __init__(self, plugin_data: "PluginData") -> None:
15 | super().__init__(plugin_data)
16 | self._data = {}
17 | self._load_config()
18 |
19 | @property
20 | def _path(self) -> Path:
21 | """配置文件路径"""
22 | return self._plugin_data.config_dir / f"{self._plugin_data.name}.json"
23 |
24 | def _ensure_config(self) -> None:
25 | """确保配置文件存在"""
26 | if not self._path.exists():
27 | with self._path.open("w", encoding="utf8") as f:
28 | json.dump(self._data, f, ensure_ascii=False, indent=2)
29 |
30 | def _load_config(self) -> None:
31 | """读取配置"""
32 | self._ensure_config()
33 | with self._path.open("r", encoding="utf8") as f:
34 | self._data = json.load(f)
35 |
36 | def _save_config(self) -> None:
37 | """保存配置"""
38 | self._ensure_config()
39 | with self._path.open("w", encoding="utf8") as f:
40 | json.dump(self._data, f, ensure_ascii=False, indent=2)
41 |
42 | async def _get(self, key: str) -> Any:
43 | if not self._data:
44 | self._load_config()
45 | try:
46 | return self._data[key]
47 | except KeyError:
48 | raise KeyNotFoundError(key)
49 |
50 | async def _set(self, key: str, value: Any) -> None:
51 | self._data[key] = value
52 | self._save_config()
53 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/providers/toml.py:
--------------------------------------------------------------------------------
1 | try:
2 | import rtoml
3 | except ImportError as e: # pragma: no cover
4 | raise ImportError(
5 | "请使用 `pip install nonebot-plugin-datastore[toml]` 安装所需依赖"
6 | ) from e
7 |
8 | from pathlib import Path
9 | from typing import TYPE_CHECKING, Any
10 |
11 | from . import ConfigProvider, KeyNotFoundError
12 |
13 | if TYPE_CHECKING:
14 | from ..plugin import PluginData
15 |
16 |
17 | class Config(ConfigProvider):
18 | """toml 格式配置"""
19 |
20 | def __init__(self, plugin_data: "PluginData") -> None:
21 | super().__init__(plugin_data)
22 | self._data = {}
23 | self._load_config()
24 |
25 | @property
26 | def _path(self) -> Path:
27 | """配置文件路径"""
28 | return self._plugin_data.config_dir / f"{self._plugin_data.name}.toml"
29 |
30 | def _ensure_config(self) -> None:
31 | """确保配置文件存在"""
32 | if not self._path.exists():
33 | with self._path.open("w", encoding="utf8") as f:
34 | rtoml.dump(self._data, f)
35 |
36 | def _load_config(self) -> None:
37 | """读取配置"""
38 | self._ensure_config()
39 | with self._path.open("r", encoding="utf8") as f:
40 | self._data = rtoml.load(f)
41 |
42 | def _save_config(self) -> None:
43 | """保存配置"""
44 | self._ensure_config()
45 | with self._path.open("w", encoding="utf8") as f:
46 | rtoml.dump(self._data, f)
47 |
48 | async def _get(self, key: str) -> Any:
49 | if not self._data:
50 | self._load_config()
51 | try:
52 | return self._data[key]
53 | except KeyError:
54 | raise KeyNotFoundError(key)
55 |
56 | async def _set(self, key: str, value: Any) -> None:
57 | self._data[key] = value
58 | self._save_config()
59 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/providers/yaml.py:
--------------------------------------------------------------------------------
1 | try:
2 | import yaml
3 | except ImportError as e: # pragma: no cover
4 | raise ImportError(
5 | "请使用 `pip install nonebot-plugin-datastore[yaml]` 安装所需依赖"
6 | ) from e
7 |
8 | from pathlib import Path
9 | from typing import TYPE_CHECKING, Any
10 |
11 | try:
12 | from yaml import CDumper as Dumper
13 | from yaml import CLoader as Loader
14 | except ImportError:
15 | from yaml import Loader, Dumper
16 |
17 | from . import ConfigProvider, KeyNotFoundError
18 |
19 | if TYPE_CHECKING:
20 | from ..plugin import PluginData
21 |
22 |
23 | class Config(ConfigProvider):
24 | """yaml 格式配置"""
25 |
26 | def __init__(self, plugin_data: "PluginData") -> None:
27 | super().__init__(plugin_data)
28 | self._data = {}
29 | self._load_config()
30 |
31 | @property
32 | def _path(self) -> Path:
33 | """配置文件路径"""
34 | return self._plugin_data.config_dir / f"{self._plugin_data.name}.yaml"
35 |
36 | def _ensure_config(self) -> None:
37 | """确保配置文件存在"""
38 | if not self._path.exists():
39 | with self._path.open("w", encoding="utf8") as f:
40 | yaml.dump(self._data, f, Dumper=Dumper)
41 |
42 | def _load_config(self) -> None:
43 | """读取配置"""
44 | self._ensure_config()
45 | with self._path.open("r", encoding="utf8") as f:
46 | self._data = yaml.load(f, Loader=Loader)
47 |
48 | def _save_config(self) -> None:
49 | """保存配置"""
50 | self._ensure_config()
51 | with self._path.open("w", encoding="utf8") as f:
52 | yaml.dump(self._data, f, Dumper=Dumper)
53 |
54 | async def _get(self, key: str) -> Any:
55 | if not self._data:
56 | self._load_config()
57 | try:
58 | return self._data[key]
59 | except KeyError:
60 | raise KeyNotFoundError(key)
61 |
62 | async def _set(self, key: str, value: Any) -> None:
63 | self._data[key] = value
64 | self._save_config()
65 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/py.typed:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/he0119/nonebot-plugin-datastore/7b88a14f8ae423e03962883d79295cd0aa869936/nonebot_plugin_datastore/py.typed
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/script/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/he0119/nonebot-plugin-datastore/7b88a14f8ae423e03962883d79295cd0aa869936/nonebot_plugin_datastore/script/__init__.py
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/script/cli.py:
--------------------------------------------------------------------------------
1 | try:
2 | import anyio
3 | import click
4 | from typing_extensions import ParamSpec
5 | except ImportError as e: # pragma: no cover
6 | raise ImportError(
7 | "请使用 `pip install nonebot-plugin-datastore[cli]` 安装所需依赖"
8 | ) from e
9 |
10 | from argparse import Namespace
11 | from collections.abc import Coroutine
12 | from functools import partial, wraps
13 | from typing import Any, Callable, Optional, TypeVar
14 |
15 | from nonebot.log import logger
16 |
17 | from ..config import plugin_config
18 | from ..db import run_pre_db_init_funcs
19 | from ..plugin import PluginData
20 | from . import command
21 | from .utils import Config, get_plugins
22 |
23 | P = ParamSpec("P")
24 | R = TypeVar("R")
25 |
26 |
27 | def run_sync(func: Callable[P, R]) -> Callable[P, Coroutine[Any, Any, R]]:
28 | @wraps(func)
29 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
30 | return await anyio.to_thread.run_sync(partial(func, *args, **kwargs))
31 |
32 | return wrapper
33 |
34 |
35 | def run_async(func: Callable[P, Coroutine[Any, Any, R]]) -> Callable[P, R]:
36 | @wraps(func)
37 | def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
38 | return anyio.from_thread.run(partial(func, *args, **kwargs))
39 |
40 | return wrapper
41 |
42 |
43 | def get_plugins_or_bad_parameter(
44 | name: Optional[str], exclude_others: bool = False
45 | ) -> list[str]:
46 | """获取使用了数据库的插件名,如果没找到则抛出 click 的 BadParameter 异常"""
47 | try:
48 | return get_plugins(name, exclude_others)
49 | except ValueError:
50 | raise click.BadParameter(message="未找到插件", param_hint="name")
51 |
52 |
53 | @click.group()
54 | def cli():
55 | """Datastore CLI"""
56 | pass
57 |
58 |
59 | @cli.command()
60 | @click.option("--name", "-n", default=None, help="插件名")
61 | @click.option("-m", "--message", default=None, help="Revision message")
62 | @click.option(
63 | "--autogenerate",
64 | is_flag=True,
65 | help=(
66 | "Populate revision script with candidate migration "
67 | "operations, based on comparison of database to model"
68 | ),
69 | )
70 | @run_async
71 | async def revision(name: Optional[str], message: Optional[str], autogenerate: bool):
72 | """创建迁移文件"""
73 | plugins = get_plugins_or_bad_parameter(name, True)
74 | for plugin in plugins:
75 | logger.info(f"尝试生成插件 {plugin} 的迁移文件")
76 | config = Config(plugin, cmd_opts=Namespace(autogenerate=autogenerate))
77 | await command.revision(config, message, autogenerate=autogenerate)
78 |
79 |
80 | @cli.command()
81 | @click.option("--name", "-n", default=None, help="插件名")
82 | @click.option("-m", "--message", default=None, help="Revision message")
83 | @run_async
84 | async def migrate(name: Optional[str], message: Optional[str]):
85 | """自动根据模型更改创建迁移文件"""
86 | plugins = get_plugins_or_bad_parameter(name, True)
87 | for plugin in plugins:
88 | logger.info(f"尝试生成插件 {plugin} 的迁移文件")
89 | config = Config(plugin, cmd_opts=Namespace(autogenerate=True))
90 | await command.revision(config, message, autogenerate=True)
91 |
92 |
93 | @cli.command()
94 | @click.option("--name", "-n", default=None, help="插件名")
95 | @click.argument("revision", default="head")
96 | @run_async
97 | async def upgrade(name: Optional[str], revision: str):
98 | """升级数据库版本"""
99 | # 执行数据库初始化前执行的函数
100 | # 比如 bison 需要在迁移之前把 alembic_version 表重命名
101 | plugins = get_plugins_or_bad_parameter(name)
102 | for plugin in plugins:
103 | await run_pre_db_init_funcs(plugin)
104 | logger.info(f"升级插件 {plugin} 的数据库")
105 | config = Config(plugin)
106 | await command.upgrade(config, revision)
107 |
108 |
109 | @cli.command()
110 | @click.option("--name", "-n", default=None, help="插件名")
111 | @click.argument("revision", default="-1")
112 | @run_async
113 | async def downgrade(name: Optional[str], revision: str):
114 | """降级数据库版本"""
115 | plugins = get_plugins_or_bad_parameter(name)
116 | for plugin in plugins:
117 | logger.info(f"降级插件 {plugin} 的数据库")
118 | config = Config(plugin)
119 | await command.downgrade(config, revision)
120 |
121 |
122 | @cli.command()
123 | @click.option("--name", "-n", default=None, help="插件名")
124 | @click.option("--rev-range", "-r", default=None, help="Revision range")
125 | @click.option("--verbose", "-v", is_flag=True, help="显示详细信息")
126 | @click.option(
127 | "--indicate-current",
128 | "-i",
129 | is_flag=True,
130 | help="Indicate current revisions with (head) and (current)",
131 | )
132 | @run_async
133 | async def history(
134 | name: Optional[str],
135 | rev_range: Optional[str],
136 | verbose: bool,
137 | indicate_current: bool,
138 | ):
139 | """数据库版本历史"""
140 | plugins = get_plugins_or_bad_parameter(name)
141 | for plugin in plugins:
142 | logger.info(f"查看插件 {plugin} 的数据库历史")
143 | config = Config(plugin)
144 | await command.history(config, rev_range, verbose, indicate_current)
145 |
146 |
147 | @cli.command()
148 | @click.option("--name", "-n", default=None, help="插件名")
149 | @click.option("--verbose", "-v", is_flag=True, help="显示详细信息")
150 | @run_async
151 | async def current(name: Optional[str], verbose: bool):
152 | """数据库当前版本"""
153 | plugins = get_plugins_or_bad_parameter(name)
154 | for plugin in plugins:
155 | logger.info(f"查看插件 {plugin} 的数据库当前版本")
156 | config = Config(plugin)
157 | await command.current(config, verbose)
158 |
159 |
160 | @cli.command()
161 | @click.option("--name", "-n", default=None, help="插件名")
162 | @click.option("--verbose", "-v", is_flag=True, help="显示详细信息")
163 | def heads(name: Optional[str], verbose: bool):
164 | """数据库最新版本"""
165 | plugins = get_plugins_or_bad_parameter(name)
166 | for plugin in plugins:
167 | logger.info(f"查看插件 {plugin} 的数据库当前可用的 heads")
168 | config = Config(plugin)
169 | command.heads(config, verbose)
170 |
171 |
172 | @cli.command()
173 | @click.option("--name", "-n", default=None, help="插件名")
174 | @run_async
175 | async def check(name: Optional[str]):
176 | """数据库是否需要升级"""
177 | plugins = get_plugins_or_bad_parameter(name, True)
178 | for plugin in plugins:
179 | logger.info(f"检查插件 {plugin} 的数据库是否需要新的迁移文件")
180 | config = Config(plugin)
181 | await command.check(config)
182 |
183 |
184 | @cli.command()
185 | @click.option("--name", "-n", default=None, help="插件名")
186 | def dir(name: Optional[str] = None):
187 | """数据存储路径"""
188 | if name is None:
189 | click.echo("当前存储路径:")
190 | click.echo(f"缓存目录: {plugin_config.datastore_cache_dir}")
191 | click.echo(f"配置目录: {plugin_config.datastore_config_dir}")
192 | click.echo(f"数据目录: {plugin_config.datastore_data_dir}")
193 | return
194 |
195 | plugins = get_plugins_or_bad_parameter(name)
196 | for plugin in plugins:
197 | plugin_data = PluginData(plugin)
198 | click.echo(f"插件 {plugin} 的存储路径:")
199 | click.echo(f"缓存目录: {plugin_data.cache_dir}")
200 | click.echo(f"配置目录: {plugin_data.config_dir}")
201 | click.echo(f"数据目录: {plugin_data.data_dir}")
202 |
203 |
204 | def main():
205 | anyio.run(run_sync(cli)) # pragma: no cover
206 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/script/command.py:
--------------------------------------------------------------------------------
1 | """直接将 Alembic 的代码抄过来,然后改成异步
2 |
3 | version: 1.9.2
4 | """
5 |
6 | from __future__ import annotations
7 |
8 | from typing import TYPE_CHECKING
9 |
10 | from alembic.autogenerate.api import RevisionContext
11 | from alembic.runtime.environment import EnvironmentContext
12 | from alembic.script import ScriptDirectory
13 | from alembic.util.exc import AutogenerateDiffsDetected, CommandError
14 | from alembic.util.messaging import obfuscate_url_pw
15 | from sqlalchemy.util.langhelpers import asbool
16 |
17 | from .utils import run_migration
18 |
19 | if TYPE_CHECKING:
20 | from alembic.config import Config
21 | from alembic.runtime.environment import ProcessRevisionDirectiveFn
22 | from alembic.script.base import Script
23 |
24 |
25 | async def revision(
26 | config: Config,
27 | message: str | None = None,
28 | autogenerate: bool = False,
29 | sql: bool = False,
30 | head: str = "head",
31 | splice: bool = False,
32 | branch_label: str | None = None,
33 | version_path: str | None = None,
34 | rev_id: str | None = None,
35 | depends_on: str | None = None,
36 | process_revision_directives: ProcessRevisionDirectiveFn | None = None,
37 | ) -> Script | None | list[Script | None]:
38 | """Create a new revision file.
39 |
40 | :param config: a :class:`.Config` object.
41 |
42 | :param message: string message to apply to the revision; this is the
43 | ``-m`` option to ``alembic revision``.
44 |
45 | :param autogenerate: whether or not to autogenerate the script from
46 | the database; this is the ``--autogenerate`` option to
47 | ``alembic revision``.
48 |
49 | :param sql: whether to dump the script out as a SQL string; when specified,
50 | the script is dumped to stdout. This is the ``--sql`` option to
51 | ``alembic revision``.
52 |
53 | :param head: head revision to build the new revision upon as a parent;
54 | this is the ``--head`` option to ``alembic revision``.
55 |
56 | :param splice: whether or not the new revision should be made into a
57 | new head of its own; is required when the given ``head`` is not itself
58 | a head. This is the ``--splice`` option to ``alembic revision``.
59 |
60 | :param branch_label: string label to apply to the branch; this is the
61 | ``--branch-label`` option to ``alembic revision``.
62 |
63 | :param version_path: string symbol identifying a specific version path
64 | from the configuration; this is the ``--version-path`` option to
65 | ``alembic revision``.
66 |
67 | :param rev_id: optional revision identifier to use instead of having
68 | one generated; this is the ``--rev-id`` option to ``alembic revision``.
69 |
70 | :param depends_on: optional list of "depends on" identifiers; this is the
71 | ``--depends-on`` option to ``alembic revision``.
72 |
73 | :param process_revision_directives: this is a callable that takes the
74 | same form as the callable described at
75 | :paramref:`.EnvironmentContext.configure.process_revision_directives`;
76 | will be applied to the structure generated by the revision process
77 | where it can be altered programmatically. Note that unlike all
78 | the other parameters, this option is only available via programmatic
79 | use of :func:`.command.revision`
80 |
81 | """
82 |
83 | script_directory = ScriptDirectory.from_config(config)
84 |
85 | command_args = {
86 | "message": message,
87 | "autogenerate": autogenerate,
88 | "sql": sql,
89 | "head": head,
90 | "splice": splice,
91 | "branch_label": branch_label,
92 | "version_path": version_path,
93 | "rev_id": rev_id,
94 | "depends_on": depends_on,
95 | }
96 | revision_context = RevisionContext(
97 | config,
98 | script_directory,
99 | command_args,
100 | process_revision_directives=process_revision_directives,
101 | )
102 |
103 | environment = asbool(config.get_main_option("revision_environment"))
104 |
105 | if autogenerate:
106 | environment = True
107 |
108 | if sql:
109 | raise CommandError(
110 | "Using --sql with --autogenerate does not make any sense"
111 | )
112 |
113 | def retrieve_migrations(rev, context):
114 | revision_context.run_autogenerate(rev, context)
115 | return []
116 |
117 | elif environment:
118 |
119 | def retrieve_migrations(rev, context):
120 | revision_context.run_no_autogenerate(rev, context)
121 | return []
122 |
123 | elif sql:
124 | raise CommandError(
125 | "Using --sql with the revision command when "
126 | "revision_environment is not configured does not make any sense"
127 | )
128 |
129 | if environment:
130 | with EnvironmentContext(
131 | config,
132 | script_directory,
133 | fn=retrieve_migrations, # type: ignore
134 | as_sql=sql,
135 | template_args=revision_context.template_args,
136 | revision_context=revision_context,
137 | ):
138 | await run_migration()
139 |
140 | # the revision_context now has MigrationScript structure(s) present.
141 | # these could theoretically be further processed / rewritten *here*,
142 | # in addition to the hooks present within each run_migrations() call,
143 | # or at the end of env.py run_migrations_online().
144 |
145 | scripts = list(revision_context.generate_scripts())
146 | if len(scripts) == 1:
147 | return scripts[0]
148 | else:
149 | return scripts
150 |
151 |
152 | async def check(
153 | config: Config,
154 | ) -> None:
155 | """Check if revision command with autogenerate has pending upgrade ops.
156 |
157 | :param config: a :class:`.Config` object.
158 |
159 | .. versionadded:: 1.9.0
160 |
161 | """
162 |
163 | script_directory = ScriptDirectory.from_config(config)
164 |
165 | command_args = {
166 | "message": None,
167 | "autogenerate": True,
168 | "sql": False,
169 | "head": "head",
170 | "splice": False,
171 | "branch_label": None,
172 | "version_path": None,
173 | "rev_id": None,
174 | "depends_on": None,
175 | }
176 | revision_context = RevisionContext(
177 | config,
178 | script_directory,
179 | command_args,
180 | )
181 |
182 | def retrieve_migrations(rev, context):
183 | revision_context.run_autogenerate(rev, context)
184 | return []
185 |
186 | with EnvironmentContext(
187 | config,
188 | script_directory,
189 | fn=retrieve_migrations,
190 | as_sql=False,
191 | template_args=revision_context.template_args,
192 | revision_context=revision_context,
193 | ):
194 | await run_migration()
195 |
196 | # the revision_context now has MigrationScript structure(s) present.
197 |
198 | migration_script = revision_context.generated_revisions[-1]
199 | diffs = migration_script.upgrade_ops.as_diffs() # type: ignore
200 | if diffs:
201 | raise AutogenerateDiffsDetected(f"New upgrade operations detected: {diffs}")
202 | else:
203 | config.print_stdout("No new upgrade operations detected.")
204 |
205 |
206 | async def upgrade(
207 | config: Config,
208 | revision: str,
209 | sql: bool = False,
210 | tag: str | None = None,
211 | ) -> None:
212 | """Upgrade to a later version.
213 |
214 | :param config: a :class:`.Config` instance.
215 |
216 | :param revision: string revision target or range for --sql mode
217 |
218 | :param sql: if True, use ``--sql`` mode
219 |
220 | :param tag: an arbitrary "tag" that can be intercepted by custom
221 | ``env.py`` scripts via the :meth:`.EnvironmentContext.get_tag_argument`
222 | method.
223 |
224 | """
225 |
226 | script = ScriptDirectory.from_config(config)
227 |
228 | starting_rev = None
229 | if ":" in revision:
230 | if not sql:
231 | raise CommandError("Range revision not allowed")
232 | starting_rev, revision = revision.split(":", 2)
233 |
234 | def upgrade(rev, context):
235 | return script._upgrade_revs(revision, rev)
236 |
237 | with EnvironmentContext(
238 | config,
239 | script,
240 | fn=upgrade,
241 | as_sql=sql,
242 | starting_rev=starting_rev,
243 | destination_rev=revision,
244 | tag=tag,
245 | ):
246 | await run_migration()
247 |
248 |
249 | async def downgrade(
250 | config: Config,
251 | revision: str,
252 | sql: bool = False,
253 | tag: str | None = None,
254 | ) -> None:
255 | """Revert to a previous version.
256 |
257 | :param config: a :class:`.Config` instance.
258 |
259 | :param revision: string revision target or range for --sql mode
260 |
261 | :param sql: if True, use ``--sql`` mode
262 |
263 | :param tag: an arbitrary "tag" that can be intercepted by custom
264 | ``env.py`` scripts via the :meth:`.EnvironmentContext.get_tag_argument`
265 | method.
266 |
267 | """
268 |
269 | script = ScriptDirectory.from_config(config)
270 | starting_rev = None
271 | if ":" in revision:
272 | if not sql:
273 | raise CommandError("Range revision not allowed")
274 | starting_rev, revision = revision.split(":", 2)
275 | elif sql:
276 | raise CommandError("downgrade with --sql requires :")
277 |
278 | def downgrade(rev, context):
279 | return script._downgrade_revs(revision, rev)
280 |
281 | with EnvironmentContext(
282 | config,
283 | script,
284 | fn=downgrade,
285 | as_sql=sql,
286 | starting_rev=starting_rev,
287 | destination_rev=revision,
288 | tag=tag,
289 | ):
290 | await run_migration()
291 |
292 |
293 | async def history(
294 | config: Config,
295 | rev_range: str | None = None,
296 | verbose: bool = False,
297 | indicate_current: bool = False,
298 | ) -> None:
299 | """List changeset scripts in chronological order.
300 |
301 | :param config: a :class:`.Config` instance.
302 |
303 | :param rev_range: string revision range
304 |
305 | :param verbose: output in verbose mode.
306 |
307 | :param indicate_current: indicate current revision.
308 |
309 | """
310 | base: str | None
311 | head: str | None
312 | script = ScriptDirectory.from_config(config)
313 | if rev_range is not None:
314 | if ":" not in rev_range:
315 | raise CommandError(
316 | "History range requires [start]:[end], " "[start]:, or :[end]"
317 | )
318 | base, head = rev_range.strip().split(":")
319 | else:
320 | base = head = None
321 |
322 | environment = (
323 | asbool(config.get_main_option("revision_environment")) or indicate_current
324 | )
325 |
326 | def _display_history(config, script, base, head, currents=()):
327 | for sc in script.walk_revisions(base=base or "base", head=head or "heads"):
328 | if indicate_current:
329 | sc._db_current_indicator = sc.revision in currents
330 |
331 | config.print_stdout(
332 | sc.cmd_format(
333 | verbose=verbose,
334 | include_branches=True,
335 | include_doc=True,
336 | include_parents=True,
337 | )
338 | )
339 |
340 | async def _display_history_w_current(config, script, base, head):
341 | def _display_current_history(rev, context):
342 | if head == "current":
343 | _display_history(config, script, base, rev, rev)
344 | elif base == "current":
345 | _display_history(config, script, rev, head, rev)
346 | else:
347 | _display_history(config, script, base, head, rev)
348 | return []
349 |
350 | with EnvironmentContext(config, script, fn=_display_current_history):
351 | await run_migration()
352 |
353 | if base == "current" or head == "current" or environment:
354 | await _display_history_w_current(config, script, base, head)
355 | else:
356 | _display_history(config, script, base, head)
357 |
358 |
359 | def heads(config, verbose=False, resolve_dependencies=False):
360 | """Show current available heads in the script directory.
361 |
362 | :param config: a :class:`.Config` instance.
363 |
364 | :param verbose: output in verbose mode.
365 |
366 | :param resolve_dependencies: treat dependency version as down revisions.
367 |
368 | """
369 |
370 | script = ScriptDirectory.from_config(config)
371 | if resolve_dependencies:
372 | heads = script.get_revisions("heads")
373 | else:
374 | heads = script.get_revisions(script.get_heads())
375 |
376 | for rev in heads:
377 | config.print_stdout(
378 | rev.cmd_format(verbose, include_branches=True, tree_indicators=False) # type: ignore
379 | )
380 |
381 |
382 | async def current(config: Config, verbose: bool = False) -> None:
383 | """Display the current revision for a database.
384 |
385 | :param config: a :class:`.Config` instance.
386 |
387 | :param verbose: output in verbose mode.
388 |
389 | """
390 |
391 | script = ScriptDirectory.from_config(config)
392 |
393 | def display_version(rev, context):
394 | if verbose:
395 | config.print_stdout(
396 | "Current revision(s) for %s:",
397 | obfuscate_url_pw(context.connection.engine.url),
398 | )
399 | for rev in script.get_all_current(rev):
400 | config.print_stdout(rev.cmd_format(verbose)) # type: ignore
401 |
402 | return []
403 |
404 | with EnvironmentContext(config, script, fn=display_version, dont_mutate=True):
405 | await run_migration()
406 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/script/migration/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade() -> None:
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade() -> None:
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/script/utils.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from typing import TYPE_CHECKING, Optional
3 |
4 | from alembic import context
5 | from alembic.config import Config as AlembicConfig
6 | from nonebot import get_loaded_plugins, get_plugin
7 | from nonebot.log import logger
8 |
9 | from ..db import get_engine
10 | from ..plugin import PluginData
11 |
12 | if TYPE_CHECKING:
13 | from nonebot.plugin import Plugin
14 |
15 | SCRIPT_LOCATION = Path(__file__).parent / "migration"
16 |
17 |
18 | def get_plugins(name: Optional[str] = None, exclude_others: bool = False) -> list[str]:
19 | """获取使用了数据库的插件名"""
20 |
21 | def _should_include(plugin: "Plugin") -> bool:
22 | # 使用了数据库
23 | if not PluginData(plugin.name).metadata:
24 | return False
25 |
26 | # 有文件
27 | if not plugin.module.__file__:
28 | return False # pragma: no cover
29 |
30 | # 是否排除当前项目外的插件
31 | if exclude_others:
32 | # 排除 site-packages 中的插件
33 | if "site-packages" in plugin.module.__file__:
34 | return False # pragma: no cover
35 | # 在当前项目目录中
36 | if Path.cwd() not in Path(plugin.module.__file__).parents:
37 | return False
38 |
39 | return True
40 |
41 | if name is None:
42 | return [
43 | plugin.name for plugin in get_loaded_plugins() if _should_include(plugin)
44 | ]
45 |
46 | if (plugin := get_plugin(name)) and _should_include(plugin):
47 | return [plugin.name]
48 |
49 | raise ValueError(f"未找到插件: {name}")
50 |
51 |
52 | class Config(AlembicConfig):
53 | def __init__(self, plugin_name, *args, **kwargs):
54 | super().__init__(*args, **kwargs)
55 | self.set_main_option("plugin_name", plugin_name)
56 | self.set_main_option("script_location", str(SCRIPT_LOCATION))
57 | self.set_main_option(
58 | "version_locations", str(PluginData(plugin_name).migration_dir)
59 | )
60 | self.set_main_option("version_path_separator", "os")
61 |
62 |
63 | def do_run_migrations(connection, plugin_name: Optional[str] = None):
64 | config = context.config
65 |
66 | if plugin_name is None:
67 | plugin_name = config.get_main_option("plugin_name")
68 |
69 | if not plugin_name:
70 | raise ValueError("未指定插件名称") # pragma: no cover
71 |
72 | target_metadata = PluginData(plugin_name).metadata
73 |
74 | # 不生成空的迁移文件
75 | # https://alembic.sqlalchemy.org/en/latest/cookbook.html#don-t-generate-empty-migrations-with-autogenerate
76 | def process_revision_directives(context, revision, directives):
77 | if config.cmd_opts and config.cmd_opts.autogenerate:
78 | script = directives[0]
79 | if script.upgrade_ops.is_empty():
80 | logger.info("模型未发生变化,已跳过生成迁移文件")
81 | directives[:] = []
82 |
83 | def include_object(object, name, type_, reflected, compare_to):
84 | if type_ != "table":
85 | return True
86 |
87 | table_info_name = object.metadata.info.get("name")
88 | # 因为所有插件共用一个 metadata
89 | # 通过存放在 metadata.info 中的 plugin_name_map 判断是否为当前插件的表
90 | if (
91 | table_info_name is None
92 | and target_metadata
93 | and target_metadata.info.get("plugin_name_map", {}).get(object.fullname)
94 | == plugin_name
95 | ):
96 | return True
97 |
98 | if table_info_name == plugin_name:
99 | return True
100 |
101 | return False
102 |
103 | context.configure(
104 | connection=connection,
105 | target_metadata=target_metadata,
106 | version_table=f"{plugin_name}_alembic_version",
107 | include_object=include_object,
108 | process_revision_directives=process_revision_directives,
109 | render_as_batch=True,
110 | compare_type=True,
111 | )
112 |
113 | with context.begin_transaction():
114 | context.run_migrations()
115 |
116 |
117 | async def run_migration(plugin_name: Optional[str] = None):
118 | """运行迁移"""
119 | connectable = get_engine()
120 |
121 | async with connectable.connect() as connection:
122 | await connection.run_sync(do_run_migrations, plugin_name)
123 |
--------------------------------------------------------------------------------
/nonebot_plugin_datastore/utils.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | import inspect
3 | from typing import Any, Optional
4 |
5 | from nonebot import get_plugin_by_module_name
6 |
7 |
8 | def get_caller_plugin_name() -> str:
9 | """获取当前函数调用者所在的插件名
10 |
11 | 尝试自动获取调用者所在的插件名
12 | """
13 | frame = inspect.currentframe()
14 | if frame is None:
15 | raise ValueError("无法获取当前栈帧") # pragma: no cover
16 |
17 | while frame := frame.f_back:
18 | module_name = (module := inspect.getmodule(frame)) and module.__name__
19 | if not module_name:
20 | raise ValueError("无法找到调用者")
21 |
22 | if module_name.split(".", maxsplit=1)[0] == "nonebot_plugin_datastore":
23 | continue
24 |
25 | plugin = get_plugin_by_module_name(module_name)
26 | if plugin and plugin.id_ != "nonebot_plugin_datastore":
27 | return plugin.name
28 |
29 | raise ValueError("自动获取插件名失败") # pragma: no cover
30 |
31 |
32 | def resolve_dot_notation(
33 | obj_str: str, default_attr: str, default_prefix: Optional[str] = None
34 | ) -> Any:
35 | """解析并导入点分表示法的对象"""
36 | modulename, _, cls = obj_str.partition(":")
37 | if default_prefix is not None and modulename.startswith("~"):
38 | modulename = default_prefix + modulename[1:]
39 | module = importlib.import_module(modulename)
40 | if not cls:
41 | return getattr(module, default_attr)
42 | instance = module
43 | for attr_str in cls.split("."):
44 | instance = getattr(instance, attr_str)
45 | return instance
46 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "nonebot-plugin-datastore"
3 | version = "1.3.0"
4 | description = "适用于 Nonebot2 的数据存储插件"
5 | authors = [{ name = "uy_sun", email = "hmy0119@gmail.com" }]
6 | dependencies = [
7 | "nonebot2[httpx]>=2.3.0",
8 | "nonebot-plugin-localstore>=0.7.0",
9 | "sqlalchemy[aiosqlite]>=2.0.0",
10 | "alembic>=1.9.1",
11 | ]
12 | readme = "README.md"
13 | license = { file = "LICENSE" }
14 | requires-python = ">= 3.9"
15 |
16 | [project.urls]
17 | Homepage = "https://github.com/he0119/nonebot-plugin-datastore"
18 | Repository = "https://github.com/he0119/nonebot-plugin-datastore.git"
19 | Issues = "https://github.com/he0119/nonebot-plugin-datastore/issues"
20 | Changelog = "https://github.com/he0119/nonebot-plugin-datastore/blob/main/CHANGELOG.md"
21 |
22 | [project.optional-dependencies]
23 | cli = ["anyio>=3.6.0", "click>=8.0.0", "typing-extensions>=4.4.0"]
24 | toml = ["rtoml>=0.9.0"]
25 | yaml = ["pyyaml>=6.0.0"]
26 | all = [
27 | "anyio>=3.6.0",
28 | "click>=8.0.0",
29 | "typing-extensions>=4.4.0",
30 | "rtoml>=0.9.0",
31 | "pyyaml>=6.0.0",
32 | ]
33 |
34 | [project.entry-points.nb_scripts]
35 | datastore = "nonebot_plugin_datastore.script.cli:main"
36 |
37 | [build-system]
38 | requires = ["hatchling"]
39 | build-backend = "hatchling.build"
40 |
41 | [tool.hatch.metadata]
42 | allow-direct-references = true
43 |
44 | [tool.hatch.build.targets.wheel]
45 | packages = ["nonebot_plugin_datastore"]
46 |
47 | [tool.hatch.build.targets.sdist]
48 | only-include = ["nonebot_plugin_datastore"]
49 |
50 | [tool.rye]
51 | managed = true
52 | universal = true
53 | dev-dependencies = [
54 | "nonebug>=0.3.7",
55 | "pytest-cov>=5.0.0",
56 | "pytest-xdist>=3.6.1",
57 | "pytest-mock>=3.14.0",
58 | "pytest-asyncio>=0.23.7",
59 | "nb-cli>=1.4.1",
60 | "nonebot2[httpx,fastapi]>=2.3.2",
61 | ]
62 |
63 | [tool.rye.scripts]
64 | test = "pytest --cov=nonebot_plugin_datastore --cov-report xml -n auto"
65 |
66 | [tool.pyright]
67 | pythonVersion = "3.9"
68 | pythonPlatform = "All"
69 | typeCheckingMode = "standard"
70 | defineConstant = { PYDANTIC_V2 = true }
71 |
72 | [tool.ruff]
73 | line-length = 88
74 | target-version = "py39"
75 |
76 | [tool.ruff.lint]
77 | select = [
78 | "W", # pycodestyle warnings
79 | "E", # pycodestyle errors
80 | "F", # pyflakes
81 | "UP", # pyupgrade
82 | "C4", # flake8-comprehensions
83 | "T10", # flake8-debugger
84 | "T20", # flake8-print
85 | "PYI", # flake8-pyi
86 | "PT", # flake8-pytest-style
87 | "Q", # flake8-quotes
88 | ]
89 | ignore = [
90 | "E402", # module-import-not-at-top-of-file
91 | ]
92 |
93 | [tool.nonebot]
94 | plugins = ["nonebot_plugin_datastore"]
95 | plugin_dirs = []
96 |
97 | [tool.coverage.report]
98 | exclude_lines = [
99 | "pragma: no cover",
100 | "raise NotImplementedError",
101 | "if __name__ == .__main__.:",
102 | "if TYPE_CHECKING:",
103 | "@overload",
104 | "except ImportError:",
105 | ]
106 | omit = ["*/script/command.py", "*/migrations/*", "*/compat.py"]
107 |
108 | [tool.pytest.ini_options]
109 | addopts = ["--import-mode=importlib"]
110 | asyncio_mode = "auto"
111 |
--------------------------------------------------------------------------------
/requirements-dev.lock:
--------------------------------------------------------------------------------
1 | # generated by rye
2 | # use `rye lock` or `rye sync` to update this lockfile
3 | #
4 | # last locked with the following flags:
5 | # pre: false
6 | # features: []
7 | # all-features: true
8 | # with-sources: false
9 | # generate-hashes: false
10 | # universal: true
11 |
12 | -e file:.
13 | aiosqlite==0.20.0
14 | # via sqlalchemy
15 | alembic==1.13.2
16 | # via nonebot-plugin-datastore
17 | annotated-types==0.7.0
18 | # via pydantic
19 | anyio==4.4.0
20 | # via httpx
21 | # via nb-cli
22 | # via nonebot-plugin-datastore
23 | # via starlette
24 | # via watchfiles
25 | arrow==1.3.0
26 | # via cookiecutter
27 | asgiref==3.8.1
28 | # via nonebug
29 | async-asgi-testclient==1.4.11
30 | # via nonebug
31 | binaryornot==0.4.4
32 | # via cookiecutter
33 | cashews==7.1.0
34 | # via nb-cli
35 | certifi==2024.7.4
36 | # via httpcore
37 | # via httpx
38 | # via requests
39 | chardet==5.2.0
40 | # via binaryornot
41 | charset-normalizer==3.3.2
42 | # via requests
43 | click==8.1.7
44 | # via cookiecutter
45 | # via nb-cli
46 | # via nonebot-plugin-datastore
47 | # via typer
48 | # via uvicorn
49 | colorama==0.4.6 ; platform_system == 'Windows' or sys_platform == 'win32'
50 | # via click
51 | # via loguru
52 | # via pytest
53 | # via uvicorn
54 | cookiecutter==2.6.0
55 | # via nb-cli
56 | coverage==7.6.0
57 | # via pytest-cov
58 | distlib==0.3.8
59 | # via virtualenv
60 | dnspython==2.6.1
61 | # via email-validator
62 | email-validator==2.2.0
63 | # via fastapi
64 | execnet==2.1.1
65 | # via pytest-xdist
66 | fastapi==0.111.0
67 | # via nonebot2
68 | fastapi-cli==0.0.4
69 | # via fastapi
70 | filelock==3.15.4
71 | # via virtualenv
72 | greenlet==3.0.3
73 | # via sqlalchemy
74 | h11==0.14.0
75 | # via httpcore
76 | # via uvicorn
77 | h2==4.1.0
78 | # via httpx
79 | hpack==4.0.0
80 | # via h2
81 | httpcore==1.0.5
82 | # via httpx
83 | httptools==0.6.1
84 | # via uvicorn
85 | httpx==0.27.0
86 | # via fastapi
87 | # via nb-cli
88 | # via nonebot2
89 | hyperframe==6.0.1
90 | # via h2
91 | idna==3.7
92 | # via anyio
93 | # via email-validator
94 | # via httpx
95 | # via requests
96 | # via yarl
97 | iniconfig==2.0.0
98 | # via pytest
99 | jinja2==3.1.4
100 | # via cookiecutter
101 | # via fastapi
102 | # via nb-cli
103 | loguru==0.7.2
104 | # via nonebot2
105 | mako==1.3.5
106 | # via alembic
107 | markdown-it-py==3.0.0
108 | # via rich
109 | markupsafe==2.1.5
110 | # via jinja2
111 | # via mako
112 | mdurl==0.1.2
113 | # via markdown-it-py
114 | multidict==6.0.5
115 | # via async-asgi-testclient
116 | # via yarl
117 | nb-cli==1.4.1
118 | nonebot-plugin-localstore==0.7.0
119 | # via nonebot-plugin-datastore
120 | nonebot2==2.3.2
121 | # via nonebot-plugin-datastore
122 | # via nonebot-plugin-localstore
123 | # via nonebug
124 | nonebug==0.3.7
125 | noneprompt==0.1.9
126 | # via nb-cli
127 | orjson==3.10.6
128 | # via fastapi
129 | packaging==24.1
130 | # via pytest
131 | platformdirs==4.2.2
132 | # via virtualenv
133 | pluggy==1.5.0
134 | # via pytest
135 | prompt-toolkit==3.0.47
136 | # via noneprompt
137 | pydantic==2.8.2
138 | # via fastapi
139 | # via nb-cli
140 | # via nonebot-plugin-localstore
141 | # via nonebot2
142 | pydantic-core==2.20.1
143 | # via pydantic
144 | pyfiglet==1.0.2
145 | # via nb-cli
146 | pygments==2.18.0
147 | # via rich
148 | pygtrie==2.5.0
149 | # via nonebot2
150 | pytest==8.2.2
151 | # via nonebug
152 | # via pytest-asyncio
153 | # via pytest-cov
154 | # via pytest-mock
155 | # via pytest-xdist
156 | pytest-asyncio==0.23.7
157 | pytest-cov==5.0.0
158 | pytest-mock==3.14.0
159 | pytest-xdist==3.6.1
160 | python-dateutil==2.9.0.post0
161 | # via arrow
162 | python-dotenv==1.0.1
163 | # via nonebot2
164 | # via uvicorn
165 | python-multipart==0.0.9
166 | # via fastapi
167 | python-slugify==8.0.4
168 | # via cookiecutter
169 | pyyaml==6.0.1
170 | # via cookiecutter
171 | # via nonebot-plugin-datastore
172 | # via uvicorn
173 | requests==2.32.3
174 | # via async-asgi-testclient
175 | # via cookiecutter
176 | rich==13.7.1
177 | # via cookiecutter
178 | # via typer
179 | rtoml==0.11.0
180 | # via nonebot-plugin-datastore
181 | shellingham==1.5.4
182 | # via typer
183 | six==1.16.0
184 | # via python-dateutil
185 | sniffio==1.3.1
186 | # via anyio
187 | # via httpx
188 | sqlalchemy==2.0.31
189 | # via alembic
190 | # via nonebot-plugin-datastore
191 | starlette==0.37.2
192 | # via fastapi
193 | text-unidecode==1.3
194 | # via python-slugify
195 | tomlkit==0.13.0
196 | # via nb-cli
197 | typer==0.12.3
198 | # via fastapi-cli
199 | types-python-dateutil==2.9.0.20240316
200 | # via arrow
201 | typing-extensions==4.12.2
202 | # via aiosqlite
203 | # via alembic
204 | # via fastapi
205 | # via nb-cli
206 | # via nonebot-plugin-datastore
207 | # via nonebot-plugin-localstore
208 | # via nonebot2
209 | # via nonebug
210 | # via pydantic
211 | # via pydantic-core
212 | # via sqlalchemy
213 | # via typer
214 | ujson==5.10.0
215 | # via fastapi
216 | urllib3==2.2.2
217 | # via requests
218 | uvicorn==0.30.1
219 | # via fastapi
220 | # via nonebot2
221 | uvloop==0.19.0 ; sys_platform != 'win32' and (platform_python_implementation != 'PyPy' and sys_platform != 'cygwin')
222 | # via uvicorn
223 | virtualenv==20.26.3
224 | # via nb-cli
225 | watchfiles==0.22.0
226 | # via nb-cli
227 | # via uvicorn
228 | wcwidth==0.2.13
229 | # via nb-cli
230 | # via prompt-toolkit
231 | websockets==12.0
232 | # via uvicorn
233 | win32-setctime==1.1.0 ; sys_platform == 'win32'
234 | # via loguru
235 | yarl==1.9.4
236 | # via nonebot2
237 |
--------------------------------------------------------------------------------
/requirements.lock:
--------------------------------------------------------------------------------
1 | # generated by rye
2 | # use `rye lock` or `rye sync` to update this lockfile
3 | #
4 | # last locked with the following flags:
5 | # pre: false
6 | # features: []
7 | # all-features: true
8 | # with-sources: false
9 | # generate-hashes: false
10 | # universal: true
11 |
12 | -e file:.
13 | aiosqlite==0.20.0
14 | # via sqlalchemy
15 | alembic==1.13.2
16 | # via nonebot-plugin-datastore
17 | annotated-types==0.7.0
18 | # via pydantic
19 | anyio==4.4.0
20 | # via httpx
21 | # via nonebot-plugin-datastore
22 | certifi==2024.7.4
23 | # via httpcore
24 | # via httpx
25 | click==8.1.7
26 | # via nonebot-plugin-datastore
27 | colorama==0.4.6 ; platform_system == 'Windows' or sys_platform == 'win32'
28 | # via click
29 | # via loguru
30 | greenlet==3.0.3
31 | # via sqlalchemy
32 | h11==0.14.0
33 | # via httpcore
34 | h2==4.1.0
35 | # via httpx
36 | hpack==4.0.0
37 | # via h2
38 | httpcore==1.0.5
39 | # via httpx
40 | httpx==0.27.0
41 | # via nonebot2
42 | hyperframe==6.0.1
43 | # via h2
44 | idna==3.7
45 | # via anyio
46 | # via httpx
47 | # via yarl
48 | loguru==0.7.2
49 | # via nonebot2
50 | mako==1.3.5
51 | # via alembic
52 | markupsafe==2.1.5
53 | # via mako
54 | multidict==6.0.5
55 | # via yarl
56 | nonebot-plugin-localstore==0.7.0
57 | # via nonebot-plugin-datastore
58 | nonebot2==2.3.2
59 | # via nonebot-plugin-datastore
60 | # via nonebot-plugin-localstore
61 | pydantic==2.8.2
62 | # via nonebot-plugin-localstore
63 | # via nonebot2
64 | pydantic-core==2.20.1
65 | # via pydantic
66 | pygtrie==2.5.0
67 | # via nonebot2
68 | python-dotenv==1.0.1
69 | # via nonebot2
70 | pyyaml==6.0.1
71 | # via nonebot-plugin-datastore
72 | rtoml==0.11.0
73 | # via nonebot-plugin-datastore
74 | sniffio==1.3.1
75 | # via anyio
76 | # via httpx
77 | sqlalchemy==2.0.31
78 | # via alembic
79 | # via nonebot-plugin-datastore
80 | typing-extensions==4.12.2
81 | # via aiosqlite
82 | # via alembic
83 | # via nonebot-plugin-datastore
84 | # via nonebot-plugin-localstore
85 | # via nonebot2
86 | # via pydantic
87 | # via pydantic-core
88 | # via sqlalchemy
89 | win32-setctime==1.1.0 ; sys_platform == 'win32'
90 | # via loguru
91 | yarl==1.9.4
92 | # via nonebot2
93 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/he0119/nonebot-plugin-datastore/7b88a14f8ae423e03962883d79295cd0aa869936/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | import pytest
4 | from nonebug.app import App
5 |
6 | from .utils import clear_plugins
7 |
8 |
9 | @pytest.fixture()
10 | def anyio_backend():
11 | """https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on"""
12 | return "asyncio"
13 |
14 |
15 | @pytest.fixture()
16 | def app(nonebug_init: None, tmp_path: Path, request):
17 | import nonebot
18 |
19 | config = nonebot.get_driver().config
20 | # 插件数据目录
21 | config.datastore_cache_dir = tmp_path / "cache"
22 | config.datastore_config_dir = tmp_path / "config"
23 | config.datastore_data_dir = tmp_path / "data"
24 | # 设置配置
25 | if param := getattr(request, "param", {}):
26 | for k, v in param.items():
27 | setattr(config, k, v)
28 |
29 | clear_plugins()
30 |
31 | # 加载插件
32 | nonebot.load_plugin("nonebot_plugin_datastore")
33 |
34 | yield App()
35 |
36 | # 清除之前设置的配置
37 | delattr(config, "datastore_cache_dir")
38 | delattr(config, "datastore_config_dir")
39 | delattr(config, "datastore_data_dir")
40 | if param := getattr(request, "param", {}):
41 | for k in param.keys():
42 | delattr(config, k)
43 |
--------------------------------------------------------------------------------
/tests/example/plugin1/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import on_command
2 | from nonebot.params import Depends
3 |
4 | from nonebot_plugin_datastore import get_session
5 | from nonebot_plugin_datastore.db import AsyncSession, create_session, post_db_init
6 |
7 | from .models import Example
8 |
9 |
10 | @post_db_init
11 | async def _():
12 | async with create_session() as session:
13 | example = Example(message="post")
14 | session.add(example)
15 | await session.commit()
16 |
17 |
18 | test = on_command("test")
19 |
20 |
21 | @test.handle()
22 | async def test_handle(session: AsyncSession = Depends(get_session)):
23 | example = Example(message="matcher")
24 | session.add(example)
25 | await session.commit()
26 |
--------------------------------------------------------------------------------
/tests/example/plugin1/migrations/bef062d23d1f_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: bef062d23d1f
4 | Revises:
5 | Create Date: 2023-01-14 18:03:01.886658
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "b6475c9488b6"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "plugin1_example",
23 | sa.Column("id", sa.Integer(), nullable=False),
24 | sa.Column("message", sa.String(), nullable=False),
25 | sa.PrimaryKeyConstraint("id"),
26 | )
27 | # ### end Alembic commands ###
28 |
29 |
30 | def downgrade() -> None:
31 | # ### commands auto generated by Alembic - please adjust! ###
32 | op.drop_table("plugin1_example")
33 | # ### end Alembic commands ###
34 |
--------------------------------------------------------------------------------
/tests/example/plugin1/models.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.orm import Mapped, mapped_column
2 |
3 | from nonebot_plugin_datastore import get_plugin_data
4 |
5 | Model = get_plugin_data().Model
6 |
7 |
8 | class Example(Model):
9 | """测试一下"""
10 |
11 | id: Mapped[int] = mapped_column(primary_key=True)
12 | message: Mapped[str]
13 |
--------------------------------------------------------------------------------
/tests/example/plugin2/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import on_command
2 | from nonebot.params import Depends
3 |
4 | from nonebot_plugin_datastore import get_session
5 | from nonebot_plugin_datastore.db import AsyncSession
6 |
7 | from .models import Example2
8 |
9 | test2 = on_command("test2")
10 |
11 |
12 | @test2.handle()
13 | async def test_handle(session: AsyncSession = Depends(get_session)):
14 | example = Example2(message2="matcher")
15 | session.add(example)
16 | await session.commit()
17 |
--------------------------------------------------------------------------------
/tests/example/plugin2/models.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from sqlalchemy.orm import Mapped, mapped_column
4 |
5 | from nonebot_plugin_datastore import get_plugin_data
6 |
7 | DATA = get_plugin_data("plugin2")
8 |
9 | DATA.set_migration_dir(Path(__file__).parent / "test-migration")
10 |
11 |
12 | class Example2(DATA.Model):
13 | """测试一下"""
14 |
15 | id: Mapped[int] = mapped_column(primary_key=True)
16 | message: Mapped[str]
17 |
--------------------------------------------------------------------------------
/tests/example/plugin_migrate/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import on_command
2 | from nonebot.params import Depends
3 |
4 | from nonebot_plugin_datastore import get_session
5 | from nonebot_plugin_datastore.db import AsyncSession, create_session, post_db_init
6 |
7 | from .models import Example
8 |
9 |
10 | @post_db_init
11 | async def _():
12 | async with create_session() as session:
13 | example = Example(message="post")
14 | session.add(example)
15 | await session.commit()
16 |
17 |
18 | test = on_command("test")
19 |
20 |
21 | @test.handle()
22 | async def test_handle(session: AsyncSession = Depends(get_session)):
23 | example = Example(message="matcher")
24 | session.add(example)
25 | await session.commit()
26 |
--------------------------------------------------------------------------------
/tests/example/plugin_migrate/migrations/bef062d23d1f_init.py:
--------------------------------------------------------------------------------
1 | """init
2 |
3 | Revision ID: bef062d23d1f
4 | Revises:
5 | Create Date: 2023-01-14 18:03:01.886658
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "b6475c9488b6"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "plugin_migrate_example",
23 | sa.Column("id", sa.Integer(), nullable=False),
24 | sa.Column("message", sa.String(), nullable=False),
25 | sa.PrimaryKeyConstraint("id"),
26 | )
27 | # ### end Alembic commands ###
28 |
29 |
30 | def downgrade() -> None:
31 | # ### commands auto generated by Alembic - please adjust! ###
32 | op.drop_table("plugin_migrate_example")
33 | # ### end Alembic commands ###
34 |
--------------------------------------------------------------------------------
/tests/example/plugin_migrate/models.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.orm import Mapped, mapped_column
2 |
3 | from nonebot_plugin_datastore import get_plugin_data
4 |
5 | Model = get_plugin_data().Model
6 |
7 |
8 | class Example(Model):
9 | """测试一下"""
10 |
11 | id: Mapped[int] = mapped_column(primary_key=True)
12 | message: Mapped[str]
13 | test: Mapped[str]
14 |
--------------------------------------------------------------------------------
/tests/example/pre_db_init_error/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import on_command
2 | from nonebot.params import Depends
3 |
4 | from nonebot_plugin_datastore import get_session
5 | from nonebot_plugin_datastore.db import AsyncSession, create_session, pre_db_init
6 |
7 | from .models import Example
8 |
9 |
10 | @pre_db_init
11 | def _():
12 | pass
13 |
14 |
15 | @pre_db_init
16 | async def _():
17 | async with create_session() as session:
18 | example = Example(message="pre")
19 | session.add(example)
20 | await session.commit()
21 |
22 |
23 | test = on_command("test3")
24 |
25 |
26 | @test.handle()
27 | async def test_handle(session: AsyncSession = Depends(get_session)):
28 | example = Example(message2="matcher")
29 | session.add(example)
30 | await session.commit()
31 |
--------------------------------------------------------------------------------
/tests/example/pre_db_init_error/models.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.orm import Mapped, mapped_column
2 |
3 | from nonebot_plugin_datastore import get_plugin_data
4 |
5 | db = get_plugin_data()
6 |
7 |
8 | class Example(db.Model):
9 | """测试一下"""
10 |
11 | id: Mapped[int] = mapped_column(primary_key=True)
12 | message: Mapped[str]
13 |
--------------------------------------------------------------------------------
/tests/registry/plugin1/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import on_command
2 | from nonebot.params import Depends
3 |
4 | from nonebot_plugin_datastore import get_session
5 | from nonebot_plugin_datastore.db import AsyncSession
6 |
7 | from .models import Example
8 |
9 | test = on_command("test")
10 |
11 |
12 | @test.handle()
13 | async def _(session: AsyncSession = Depends(get_session)):
14 | session.add(Example(message="matcher"))
15 | await session.commit()
16 |
--------------------------------------------------------------------------------
/tests/registry/plugin1/migrations/ff18b81ee1ca_init_db.py:
--------------------------------------------------------------------------------
1 | """init db
2 |
3 | Revision ID: ff18b81ee1ca
4 | Revises:
5 | Create Date: 2023-02-26 16:29:31.412836
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "ff18b81ee1ca"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "plugin1_example",
23 | sa.Column("id", sa.Integer(), nullable=False),
24 | sa.Column("message", sa.String(), nullable=False),
25 | sa.PrimaryKeyConstraint("id"),
26 | )
27 | # ### end Alembic commands ###
28 |
29 |
30 | def downgrade() -> None:
31 | # ### commands auto generated by Alembic - please adjust! ###
32 | op.drop_table("plugin1_example")
33 | # ### end Alembic commands ###
34 |
--------------------------------------------------------------------------------
/tests/registry/plugin1/models.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import ForeignKey
2 | from sqlalchemy.orm import Mapped, mapped_column, relationship
3 |
4 | from nonebot_plugin_datastore import get_plugin_data
5 |
6 | Model = get_plugin_data().Model
7 |
8 |
9 | class Example(Model):
10 | """测试一下"""
11 |
12 | id: Mapped[int] = mapped_column(primary_key=True)
13 | message: Mapped[str]
14 |
15 | tests: Mapped["Test"] = relationship(back_populates="example")
16 |
17 |
18 | class Test(Model):
19 | id: Mapped[int] = mapped_column(primary_key=True)
20 |
21 | example_id: Mapped[int] = mapped_column(ForeignKey("plugin1_example.id"))
22 | example: Mapped[Example] = relationship(back_populates="tests")
23 |
--------------------------------------------------------------------------------
/tests/registry/plugin2/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import on_command
2 | from nonebot.params import Depends
3 |
4 | from nonebot_plugin_datastore import get_session
5 | from nonebot_plugin_datastore.db import AsyncSession
6 |
7 | from .models import Example, Test
8 |
9 | test = on_command("test")
10 |
11 |
12 | @test.handle()
13 | async def _(session: AsyncSession = Depends(get_session)):
14 | example = Example(message="matcher")
15 | test = Test(example=example)
16 | session.add(example)
17 | session.add(test)
18 | await session.commit()
19 |
--------------------------------------------------------------------------------
/tests/registry/plugin2/migrations/a1219e33400e_init_db.py:
--------------------------------------------------------------------------------
1 | """init db
2 |
3 | Revision ID: a1219e33400e
4 | Revises:
5 | Create Date: 2023-02-26 16:29:31.475062
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "a1219e33400e"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "plugin2_example",
23 | sa.Column("id", sa.Integer(), nullable=False),
24 | sa.Column("message", sa.String(), nullable=False),
25 | sa.PrimaryKeyConstraint("id"),
26 | )
27 | op.create_table(
28 | "plugin2_test",
29 | sa.Column("id", sa.Integer(), nullable=False),
30 | sa.Column("example_id", sa.Integer(), nullable=True),
31 | sa.ForeignKeyConstraint(
32 | ["example_id"],
33 | ["plugin2_example.id"],
34 | ),
35 | sa.PrimaryKeyConstraint("id"),
36 | )
37 | # ### end Alembic commands ###
38 |
39 |
40 | def downgrade() -> None:
41 | # ### commands auto generated by Alembic - please adjust! ###
42 | op.drop_table("plugin2_test")
43 | op.drop_table("plugin2_example")
44 | # ### end Alembic commands ###
45 |
--------------------------------------------------------------------------------
/tests/registry/plugin2/models.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from sqlalchemy import ForeignKey
4 | from sqlalchemy.orm import Mapped, mapped_column, relationship
5 |
6 | from nonebot_plugin_datastore import get_plugin_data
7 |
8 | Model = get_plugin_data().Model
9 |
10 |
11 | class Example(Model):
12 | """测试一下"""
13 |
14 | id: Mapped[int] = mapped_column(primary_key=True)
15 | message: Mapped[str]
16 |
17 | tests: Mapped["Test"] = relationship(back_populates="example")
18 |
19 |
20 | class Test(Model):
21 | id: Mapped[int] = mapped_column(primary_key=True)
22 |
23 | example_id: Mapped[Optional[int]] = mapped_column(ForeignKey("plugin2_example.id"))
24 | example: Mapped[Optional[Example]] = relationship(back_populates="tests")
25 |
--------------------------------------------------------------------------------
/tests/registry/plugin3/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import on_command
2 | from nonebot.params import Depends
3 |
4 | from nonebot_plugin_datastore import get_session
5 | from nonebot_plugin_datastore.db import AsyncSession
6 |
7 | from .models import Example
8 |
9 | test = on_command("test")
10 |
11 |
12 | @test.handle()
13 | async def _(session: AsyncSession = Depends(get_session)):
14 | session.add(Example(message="matcher"))
15 | await session.commit()
16 |
--------------------------------------------------------------------------------
/tests/registry/plugin3/migrations/548b56a0ceca_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: 548b56a0ceca
4 | Revises:
5 | Create Date: 2023-07-26 14:02:29.829043
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "548b56a0ceca"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "plugin3_example",
23 | sa.Column("id", sa.Integer(), nullable=False),
24 | sa.Column("message", sa.String(), nullable=False),
25 | sa.PrimaryKeyConstraint("id"),
26 | )
27 | op.create_table(
28 | "plugin3_test",
29 | sa.Column("id", sa.Integer(), nullable=False),
30 | sa.Column("example_id", sa.Integer(), nullable=False),
31 | sa.ForeignKeyConstraint(
32 | ["example_id"],
33 | ["plugin3_example.id"],
34 | ),
35 | sa.PrimaryKeyConstraint("id"),
36 | )
37 | # ### end Alembic commands ###
38 |
39 |
40 | def downgrade() -> None:
41 | # ### commands auto generated by Alembic - please adjust! ###
42 | op.drop_table("plugin3_test")
43 | op.drop_table("plugin3_example")
44 | # ### end Alembic commands ###
45 |
--------------------------------------------------------------------------------
/tests/registry/plugin3/models.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import ForeignKey
2 | from sqlalchemy.orm import Mapped, mapped_column, relationship
3 |
4 | from nonebot_plugin_datastore import get_plugin_data
5 |
6 | db = get_plugin_data()
7 | db.use_global_registry()
8 |
9 | Model = db.Model
10 |
11 |
12 | class Example(Model):
13 | """测试一下"""
14 |
15 | id: Mapped[int] = mapped_column(primary_key=True)
16 | message: Mapped[str]
17 |
18 | tests: Mapped["Test"] = relationship(back_populates="example")
19 |
20 |
21 | class Test(Model):
22 | id: Mapped[int] = mapped_column(primary_key=True)
23 |
24 | example_id: Mapped[int] = mapped_column(ForeignKey("plugin3_example.id"))
25 | example: Mapped[Example] = relationship(back_populates="tests")
26 |
27 |
28 | Example.tests = relationship(Test, back_populates="example")
29 |
--------------------------------------------------------------------------------
/tests/registry/plugin3_plugin4/__init__.py:
--------------------------------------------------------------------------------
1 | from nonebot import on_command, require
2 |
3 | require("tests.registry.plugin3")
4 | from nonebot.params import Depends
5 |
6 | from nonebot_plugin_datastore import get_session
7 | from nonebot_plugin_datastore.db import AsyncSession
8 |
9 | from .models import Example, Test
10 |
11 | test = on_command("test")
12 |
13 |
14 | @test.handle()
15 | async def _(session: AsyncSession = Depends(get_session)):
16 | example = Example(message="matcher")
17 | test = Test(example=example)
18 | session.add(example)
19 | session.add(test)
20 | await session.commit()
21 |
--------------------------------------------------------------------------------
/tests/registry/plugin3_plugin4/migrations/2c2ebd61d932_init_db.py:
--------------------------------------------------------------------------------
1 | """init db
2 |
3 | Revision ID: 2c2ebd61d932
4 | Revises:
5 | Create Date: 2023-07-26 16:29:04.779418
6 |
7 | """
8 |
9 | import sqlalchemy as sa
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "2c2ebd61d932"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade() -> None:
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "plugin3_plugin4_test",
23 | sa.Column("id", sa.Integer(), nullable=False),
24 | sa.Column("example_id", sa.Integer(), nullable=True),
25 | sa.ForeignKeyConstraint(
26 | ["example_id"],
27 | ["plugin3_example.id"],
28 | ),
29 | sa.PrimaryKeyConstraint("id"),
30 | )
31 | # ### end Alembic commands ###
32 |
33 |
34 | def downgrade() -> None:
35 | # ### commands auto generated by Alembic - please adjust! ###
36 | op.drop_table("plugin3_plugin4_test")
37 | # ### end Alembic commands ###
38 |
--------------------------------------------------------------------------------
/tests/registry/plugin3_plugin4/models.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from sqlalchemy import ForeignKey
4 | from sqlalchemy.orm import Mapped, mapped_column, relationship
5 |
6 | from nonebot_plugin_datastore import get_plugin_data
7 | from tests.registry.plugin3.models import Example
8 |
9 | db = get_plugin_data()
10 | db.use_global_registry()
11 |
12 | Model = db.Model
13 |
14 |
15 | class Test(Model):
16 | id: Mapped[int] = mapped_column(primary_key=True)
17 |
18 | example_id: Mapped[Optional[int]] = mapped_column(ForeignKey("plugin3_example.id"))
19 | example: Mapped[Optional[Example]] = relationship(back_populates="tests4")
20 |
21 |
22 | Example.tests4 = relationship(Test, back_populates="example")
23 |
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | import shutil
2 | from pathlib import Path
3 |
4 | import pytest
5 | from click.testing import CliRunner
6 | from nonebug import App
7 |
8 |
9 | def test_cli_help(app: App):
10 | from nonebot_plugin_datastore.script.cli import cli
11 |
12 | runner = CliRunner()
13 | result = runner.invoke(cli, ["--help"])
14 | assert result.exit_code == 0
15 | assert "Show this message and exit." in result.output
16 | assert "revision" in result.output
17 | assert "upgrade" in result.output
18 | assert "downgrade" in result.output
19 |
20 | result = runner.invoke(cli, ["revision", "--help"])
21 | assert result.exit_code == 0
22 | assert "--name" in result.output
23 | assert "--autogenerate" in result.output
24 | assert "--message" in result.output
25 |
26 | result = runner.invoke(cli, ["upgrade", "--help"])
27 | assert result.exit_code == 0
28 | assert "--name" in result.output
29 | assert "[REVISION]" in result.output
30 |
31 | result = runner.invoke(cli, ["downgrade", "--help"])
32 | assert result.exit_code == 0
33 | assert "--name" in result.output
34 | assert "[REVISION]" in result.output
35 |
36 |
37 | @pytest.mark.anyio()
38 | async def test_revision(app: App, tmp_path: Path):
39 | from nonebot import require
40 |
41 | from nonebot_plugin_datastore import PluginData
42 | from nonebot_plugin_datastore.db import init_db
43 | from nonebot_plugin_datastore.script.cli import cli, run_sync
44 |
45 | require("tests.example.plugin1")
46 | require("tests.example.plugin2")
47 | await init_db()
48 |
49 | runner = CliRunner()
50 |
51 | # 测试跳过生成迁移文件
52 | result = await run_sync(runner.invoke)(
53 | cli, ["revision", "--autogenerate", "--name", "plugin1"]
54 | )
55 | assert result.exit_code == 0
56 | assert result.output == ""
57 |
58 | # 手动设置迁移文件目录
59 | PluginData("plugin2").set_migration_dir(tmp_path / "revision")
60 |
61 | # 测试生成迁移文件
62 | migration_dir = tmp_path / "revision"
63 | assert migration_dir
64 | assert not migration_dir.exists()
65 |
66 | result = await run_sync(runner.invoke)(
67 | cli, ["revision", "--autogenerate", "--name", "plugin2", "-m", "test"]
68 | )
69 | assert result.exit_code == 0
70 | assert "Generating" in result.output
71 | assert "test.py" in result.output
72 |
73 | assert migration_dir.exists()
74 |
75 | # 测试插件如果不在项目目录下,会报错
76 | with runner.isolated_filesystem(temp_dir=tmp_path):
77 | result = await run_sync(runner.invoke)(cli, ["revision", "--name", "plugin2"])
78 | assert result.exit_code == 2
79 | assert "未找到插件" in result.output
80 |
81 |
82 | @pytest.mark.anyio()
83 | async def test_migrate(app: App, tmp_path: Path):
84 | from nonebot import require
85 |
86 | from nonebot_plugin_datastore import PluginData
87 | from nonebot_plugin_datastore.db import init_db
88 | from nonebot_plugin_datastore.script.cli import cli, run_sync
89 |
90 | require("tests.example.plugin1")
91 | require("tests.example.plugin2")
92 | await init_db()
93 |
94 | runner = CliRunner()
95 |
96 | # 测试跳过生成迁移文件
97 | result = await run_sync(runner.invoke)(cli, ["migrate", "--name", "plugin1"])
98 | assert result.exit_code == 0
99 | assert result.output == ""
100 |
101 | # 手动设置迁移文件目录
102 | PluginData("plugin2").set_migration_dir(tmp_path / "revision")
103 |
104 | # 测试生成迁移文件
105 | migration_dir = tmp_path / "revision"
106 | assert migration_dir
107 | assert not migration_dir.exists()
108 |
109 | result = await run_sync(runner.invoke)(
110 | cli, ["migrate", "--name", "plugin2", "-m", "test"]
111 | )
112 | assert result.exit_code == 0
113 | assert "Generating" in result.output
114 | assert "test.py" in result.output
115 |
116 | assert migration_dir.exists()
117 |
118 |
119 | @pytest.mark.anyio()
120 | async def test_migrate_change(app: App, tmp_path: Path):
121 | from nonebot import require
122 |
123 | from nonebot_plugin_datastore import PluginData
124 | from nonebot_plugin_datastore.db import init_db
125 | from nonebot_plugin_datastore.script.cli import cli, run_sync
126 |
127 | require("tests.example.plugin_migrate")
128 | await init_db()
129 |
130 | runner = CliRunner()
131 |
132 | migration_dir = tmp_path / "migrations"
133 | # 手动设置迁移文件目录
134 | PluginData("plugin_migrate").set_migration_dir(migration_dir)
135 | shutil.copytree(
136 | Path(__file__).parent / "example" / "plugin_migrate" / "migrations",
137 | migration_dir,
138 | )
139 | assert migration_dir.exists()
140 |
141 | # 测试生成迁移文件报错
142 | result = await run_sync(runner.invoke)(cli, ["migrate", "-m", "test"])
143 | assert result.exit_code == 0
144 | assert "Generating" in result.output
145 | assert "test.py" in result.output
146 |
147 | assert migration_dir.exists()
148 |
149 |
150 | @pytest.mark.anyio()
151 | async def test_upgrade(app: App):
152 | from nonebot import require
153 |
154 | from nonebot_plugin_datastore.script.cli import cli, run_sync
155 |
156 | require("tests.example.plugin1")
157 |
158 | runner = CliRunner()
159 | result = await run_sync(runner.invoke)(cli, ["upgrade"])
160 | assert result.exit_code == 0
161 | assert result.output == ""
162 |
163 | require("tests.example.pre_db_init_error")
164 | result = await run_sync(runner.invoke)(cli, ["upgrade"])
165 | assert result.exit_code == 1
166 | assert result.output == ""
167 |
168 |
169 | @pytest.mark.anyio()
170 | async def test_upgrade_single_plugin(app: App):
171 | """测试单独升级某个插件"""
172 | from nonebot import require
173 |
174 | from nonebot_plugin_datastore.script.cli import cli, run_sync
175 |
176 | require("tests.example.plugin1")
177 | require("tests.example.pre_db_init_error")
178 |
179 | runner = CliRunner()
180 | result = await run_sync(runner.invoke)(cli, ["upgrade", "--name", "plugin1"])
181 | assert result.exit_code == 0
182 | assert result.output == ""
183 |
184 | result = await run_sync(runner.invoke)(
185 | cli, ["upgrade", "--name", "pre_db_init_error"]
186 | )
187 | assert result.exit_code == 1
188 | assert result.output == ""
189 |
190 |
191 | @pytest.mark.anyio()
192 | async def test_downgrade(app: App):
193 | from nonebot import require
194 |
195 | from nonebot_plugin_datastore.db import init_db
196 | from nonebot_plugin_datastore.script.cli import cli, run_sync
197 |
198 | require("tests.example.plugin1")
199 | await init_db()
200 |
201 | runner = CliRunner()
202 | result = await run_sync(runner.invoke)(cli, ["downgrade"])
203 | assert result.exit_code == 0
204 | assert result.output == ""
205 |
206 |
207 | @pytest.mark.anyio()
208 | async def test_other_commands(app: App):
209 | from nonebot import require
210 |
211 | from nonebot_plugin_datastore.db import init_db
212 | from nonebot_plugin_datastore.script.cli import cli, run_sync
213 |
214 | require("tests.example.plugin1")
215 | require("tests.example.plugin2")
216 |
217 | runner = CliRunner()
218 | result = await run_sync(runner.invoke)(cli, ["history"])
219 | assert result.exit_code == 0
220 | assert result.output == ""
221 |
222 | result = await run_sync(runner.invoke)(cli, ["current"])
223 | assert result.exit_code == 0
224 | assert result.output == ""
225 |
226 | result = await run_sync(runner.invoke)(cli, ["heads"])
227 | assert result.exit_code == 0
228 | assert result.output == ""
229 |
230 | result = await run_sync(runner.invoke)(cli, ["check"])
231 | assert result.exit_code == 1
232 | assert result.output == ""
233 |
234 | await init_db()
235 |
236 | result = await run_sync(runner.invoke)(cli, ["check", "--name", "plugin1"])
237 | assert result.exit_code == 0
238 | assert result.output == ""
239 |
240 | result = await run_sync(runner.invoke)(cli, ["dir"])
241 | assert result.exit_code == 0
242 | assert "当前存储路径:" in result.output
243 |
244 | result = await run_sync(runner.invoke)(cli, ["dir", "--name", "plugin1"])
245 | assert result.exit_code == 0
246 | assert "插件 plugin1 的存储路径:" in result.output
247 |
248 |
249 | @pytest.mark.anyio()
250 | async def test_revision_path_with_space(app: App, tmp_path: Path):
251 | """测试迁移文件目录路径中包含空格时的情况"""
252 | from nonebot import require
253 |
254 | from nonebot_plugin_datastore import PluginData
255 | from nonebot_plugin_datastore.script.cli import cli, run_sync
256 |
257 | require("tests.example.plugin2")
258 |
259 | runner = CliRunner()
260 |
261 | # 手动设置迁移文件目录
262 | migration_dir = tmp_path / "revision test"
263 | PluginData("plugin2").set_migration_dir(migration_dir)
264 |
265 | # 测试生成迁移文件
266 | assert migration_dir
267 | assert not migration_dir.exists()
268 |
269 | result = await run_sync(runner.invoke)(
270 | cli, ["revision", "--autogenerate", "--name", "plugin2", "-m", "test"]
271 | )
272 | assert result.exit_code == 0
273 | assert "Generating" in result.output
274 | assert "test.py" in result.output
275 |
276 | assert migration_dir.exists()
277 |
278 |
279 | @pytest.mark.anyio()
280 | async def test_migrate_global_registry(app: App, tmp_path: Path):
281 | """测试使用全局注册表的插件迁移
282 |
283 | 确保可以识别到对应插件的模型
284 | """
285 | from nonebot import require
286 |
287 | from nonebot_plugin_datastore.db import init_db
288 | from nonebot_plugin_datastore.script.cli import cli, run_sync
289 |
290 | require("tests.registry.plugin3")
291 | require("tests.registry.plugin3_plugin4")
292 | await init_db()
293 |
294 | runner = CliRunner()
295 |
296 | # 测试跳过生成迁移文件
297 | result = await run_sync(runner.invoke)(cli, ["migrate", "--name", "plugin3"])
298 | assert result.exit_code == 0
299 | assert result.output == ""
300 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import pytest
4 | from nonebug import App
5 |
6 |
7 | async def test_read_config(app: App):
8 | """测试读取配置"""
9 | from nonebot_plugin_datastore import PluginData
10 | from nonebot_plugin_datastore.config import plugin_config
11 |
12 | plugin_config.datastore_config_dir.mkdir(exist_ok=True)
13 |
14 | config_file = plugin_config.datastore_config_dir / "test.json"
15 | with open(config_file, "w", encoding="utf8") as f:
16 | json.dump({"test": 1}, f)
17 |
18 | data = PluginData("test")
19 | assert await data.config.get("test") == 1
20 |
21 | assert await data.config.get("test1") is None
22 |
23 |
24 | async def test_write_config(app: App):
25 | """测试写入配置"""
26 | from nonebot_plugin_datastore import PluginData
27 | from nonebot_plugin_datastore.config import plugin_config
28 |
29 | config_file = plugin_config.datastore_config_dir / "test.json"
30 | assert config_file.exists() is False
31 |
32 | data = PluginData("test")
33 | await data.config.set("test", 1)
34 |
35 | with open(config_file, encoding="utf8") as f:
36 | data = json.load(f)
37 | assert data["test"] == 1
38 |
39 |
40 | async def test_write_config_while_folder_deleted(app: App):
41 | """测试删除文件夹后写入配置"""
42 | from nonebot_plugin_datastore import PluginData
43 | from nonebot_plugin_datastore.config import plugin_config
44 |
45 | config_file = plugin_config.datastore_config_dir / "test.json"
46 | assert config_file.exists() is False
47 |
48 | data = PluginData("test")
49 | await data.config.set("test", 1)
50 |
51 | assert config_file.exists() is True
52 |
53 | config_file.unlink()
54 | plugin_config.datastore_config_dir.rmdir()
55 |
56 | await data.config.set("test", 1)
57 |
58 |
59 | @pytest.mark.parametrize(
60 | "app",
61 | [
62 | pytest.param({"datastore_config_provider": "~json"}, id="json"),
63 | pytest.param({"datastore_config_provider": "~database"}, id="database"),
64 | pytest.param({"datastore_config_provider": "~toml"}, id="toml"),
65 | pytest.param({"datastore_config_provider": "~yaml"}, id="yaml"),
66 | pytest.param({"datastore_config_provider": "~yaml:Config"}, id="yaml_config"),
67 | ],
68 | indirect=True,
69 | )
70 | async def test_read_write_config(app: App):
71 | """测试读写配置"""
72 | from nonebot_plugin_datastore import PluginData
73 | from nonebot_plugin_datastore.db import init_db
74 |
75 | data = PluginData("test")
76 |
77 | await init_db()
78 |
79 | assert await data.config.get("test") is None
80 | assert await data.config.get("test", "test") is None
81 | assert await data.config.get("other", "test") == "test"
82 |
83 | simple = 1
84 | await data.config.set("test", simple)
85 | assert await data.config.get("test") == simple
86 |
87 | complex = {"a": 1, "b": [1, 2, 3], "c": {"d": 1}}
88 | await data.config.set("test", complex)
89 | assert await data.config.get("test") == complex
90 |
91 | # 两个插件的配置不会相互影响
92 | data1 = PluginData("test1")
93 | assert await data1.config.get("test") is None
94 |
--------------------------------------------------------------------------------
/tests/test_db.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | import pytest
4 | from nonebot import require
5 | from nonebug import App
6 | from sqlalchemy.exc import OperationalError
7 | from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool
8 |
9 | from .utils import clear_plugins, make_fake_event, make_fake_message
10 |
11 |
12 | async def test_db(app: App):
13 | """测试数据库"""
14 | from sqlalchemy import select
15 |
16 | from nonebot_plugin_datastore.db import create_session, init_db
17 |
18 | require("tests.example.plugin1")
19 | from .example.plugin1 import Example, test
20 |
21 | await init_db()
22 |
23 | async with create_session() as session:
24 | statement = select(Example)
25 | example = (await session.scalars(statement)).one()
26 | assert example.message == "post"
27 |
28 | async with create_session() as session:
29 | session.add(Example(message="test"))
30 | await session.commit()
31 |
32 | async with create_session() as session:
33 | statement = select(Example)
34 | examples = (await session.scalars(statement)).all()
35 | assert len(examples) == 2
36 | assert examples[1].message == "test"
37 |
38 | message = make_fake_message()("/test")
39 | event = make_fake_event(_message=message)()
40 |
41 | async with app.test_matcher(test) as ctx:
42 | bot = ctx.create_bot()
43 |
44 | ctx.receive_event(bot, event)
45 |
46 | async with create_session() as session:
47 | statement = select(Example)
48 | examples = (await session.scalars(statement)).all()
49 | assert len(examples) == 3
50 | assert examples[2].message == "matcher"
51 |
52 |
53 | @pytest.mark.parametrize(
54 | "app",
55 | [pytest.param({"datastore_enable_database": "false"}, id="disable_db")],
56 | indirect=True,
57 | )
58 | async def test_disable_db(app: App):
59 | """测试禁用数据库"""
60 | from nonebot_plugin_datastore import create_session
61 |
62 | with pytest.raises(ValueError, match="数据库未启用"):
63 | create_session()
64 |
65 |
66 | async def test_default_db_url(nonebug_init: None):
67 | """测试默认数据库地址"""
68 | import nonebot
69 |
70 | clear_plugins()
71 |
72 | # 加载插件
73 | nonebot.load_plugin("nonebot_plugin_datastore")
74 |
75 | from nonebot_plugin_datastore.config import plugin_config
76 |
77 | assert (
78 | plugin_config.datastore_database_url
79 | == f"sqlite+aiosqlite:///{plugin_config.datastore_data_dir / 'data.db'}"
80 | )
81 |
82 |
83 | async def test_post_db_init_error(app: App):
84 | """数据库初始化后执行函数错误"""
85 | from nonebot_plugin_datastore.db import init_db, post_db_init
86 |
87 | @post_db_init
88 | async def _():
89 | raise Exception("test")
90 |
91 | await init_db()
92 |
93 |
94 | async def test_pre_db_init_error(app: App):
95 | """数据库初始化前执行函数错误"""
96 | from nonebot_plugin_datastore.db import init_db
97 |
98 | require("tests.example.pre_db_init_error")
99 |
100 | with pytest.raises(OperationalError):
101 | await init_db()
102 |
103 |
104 | @pytest.mark.parametrize(
105 | "app",
106 | [pytest.param({"datastore_engine_options": {"pool_recycle": 7200}}, id="options")],
107 | indirect=True,
108 | )
109 | async def test_engine_options(app: App):
110 | """测试引擎配置"""
111 | from nonebot_plugin_datastore.config import plugin_config
112 | from nonebot_plugin_datastore.db import get_engine
113 |
114 | assert plugin_config.datastore_engine_options == {"pool_recycle": 7200}
115 |
116 | engine = get_engine()
117 | # 默认值为 -1
118 | assert engine.pool._recycle == 7200 # type: ignore
119 | assert isinstance(engine.pool, NullPool)
120 |
121 |
122 | @pytest.mark.parametrize(
123 | "app",
124 | [
125 | pytest.param(
126 | {"datastore_engine_options": {"poolclass": AsyncAdaptedQueuePool}},
127 | id="options",
128 | )
129 | ],
130 | indirect=True,
131 | )
132 | async def test_engine_options_poolclass(app: App):
133 | """测试设置引擎连接池"""
134 | from nonebot_plugin_datastore.db import get_engine
135 |
136 | engine = get_engine()
137 | assert isinstance(engine.pool, AsyncAdaptedQueuePool)
138 |
139 |
140 | @pytest.mark.parametrize(
141 | "app",
142 | [
143 | pytest.param(
144 | {"datastore_database_url": "sqlite+aiosqlite:///data/test/test.db"},
145 | id="url",
146 | )
147 | ],
148 | indirect=True,
149 | )
150 | async def test_create_db(app: App):
151 | """测试创建数据库"""
152 | from nonebot_plugin_datastore.db import init_db
153 |
154 | require("tests.example.plugin1")
155 |
156 | database_path = Path("data/test/test.db")
157 |
158 | assert not database_path.exists()
159 | await init_db()
160 | assert database_path.exists()
161 |
162 | database_path.unlink()
163 | database_path.parent.rmdir()
164 |
--------------------------------------------------------------------------------
/tests/test_network_file.py:
--------------------------------------------------------------------------------
1 | import json
2 | from typing import Optional
3 |
4 | from nonebug import App
5 | from pytest_mock import MockerFixture
6 |
7 |
8 | async def mocked_get(url: str, **kwargs):
9 | class MockResponse:
10 | def __init__(self, json: Optional[dict] = None):
11 | self._json = json
12 |
13 | def json(self):
14 | return self._json
15 |
16 | @property
17 | def content(self):
18 | if self._json:
19 | return json.dumps(self._json).encode("utf-8")
20 |
21 | if url == "http://example.com":
22 | return MockResponse(json={"key": "值"})
23 |
24 | return MockResponse({})
25 |
26 |
27 | async def test_cache_network_file(app: App, mocker: MockerFixture):
28 | """测试缓存网络文件至本地"""
29 | from nonebot_plugin_datastore import PluginData
30 |
31 | get = mocker.patch("httpx.AsyncClient.get", side_effect=mocked_get)
32 |
33 | plugin_data = PluginData("test")
34 |
35 | file = plugin_data.network_file("http://example.com", "test")
36 |
37 | data = await file.data
38 | assert data == {"key": "值"}
39 |
40 | # 确认文件是否缓存成功
41 | assert plugin_data.exists("test") is True
42 | assert plugin_data.load_json("test") == {"key": "值"}
43 |
44 | data = await file.data
45 | assert data == {"key": "值"}
46 |
47 | # 访问两次数据,因为缓存,所以不会请求网络两次
48 | get.assert_called_once_with("http://example.com")
49 |
50 |
51 | async def test_load_local_file(app: App, mocker: MockerFixture):
52 | """测试读取本地文件"""
53 | from nonebot_plugin_datastore import PluginData
54 |
55 | get = mocker.patch("httpx.AsyncClient.get", side_effect=mocked_get)
56 |
57 | plugin_data = PluginData("test")
58 | plugin_data.dump_json({"key": "值"}, "test")
59 |
60 | file = plugin_data.network_file("http://example.com", "test")
61 |
62 | data = await file.data
63 | assert data == {"key": "值"}
64 |
65 | # 访问数据,因为会读取本地文件,所以不会请求网络
66 | get.assert_not_called()
67 |
68 |
69 | async def test_process_data(app: App, mocker: MockerFixture):
70 | """测试处理数据"""
71 | from nonebot_plugin_datastore import PluginData
72 |
73 | get = mocker.patch("httpx.AsyncClient.get", side_effect=mocked_get)
74 |
75 | plugin_data = PluginData("test")
76 | plugin_data.dump_json({"key": "值"}, "test")
77 |
78 | def test_process(data: dict):
79 | data["key2"] = "值2"
80 | return data
81 |
82 | file = plugin_data.network_file(
83 | "http://example.com", "test", process_data=test_process
84 | )
85 |
86 | data = await file.data
87 | assert data == {"key": "值", "key2": "值2"}
88 |
89 | data = await file.data
90 | assert data == {"key": "值", "key2": "值2"}
91 |
92 | # 访问数据,因为会读取本地文件,所以不会请求网络
93 | get.assert_not_called()
94 |
95 |
96 | async def test_update_data(app: App, mocker: MockerFixture):
97 | """测试更新数据"""
98 | from nonebot_plugin_datastore import PluginData
99 |
100 | get = mocker.patch("httpx.AsyncClient.get", side_effect=mocked_get)
101 |
102 | plugin_data = PluginData("test")
103 | plugin_data.dump_json({"key": "old"}, "test")
104 |
105 | def test_process(data: dict):
106 | data["key2"] = "值2"
107 | return data
108 |
109 | file = plugin_data.network_file("http://example.com", "test", test_process)
110 |
111 | # 读取的本地文件
112 | data = await file.data
113 | assert data == {"key": "old", "key2": "值2"}
114 |
115 | await file.update()
116 |
117 | # 更新之后,就是从服务器获取的最新数据
118 | data = await file.data
119 | assert data == {"key": "值", "key2": "值2"}
120 |
121 | get.assert_called_once_with("http://example.com")
122 |
123 |
124 | async def test_download_file(app: App, mocker: MockerFixture):
125 | """测试下载文件"""
126 | from nonebot_plugin_datastore import PluginData
127 |
128 | get = mocker.patch("httpx.AsyncClient.get", side_effect=mocked_get)
129 |
130 | plugin_data = PluginData("test")
131 |
132 | file = await plugin_data.download_file("http://example.com", "test")
133 |
134 | with plugin_data.open("test", "rb") as f:
135 | data = f.read()
136 |
137 | assert file == data
138 |
139 | get.assert_called_once_with("http://example.com")
140 |
141 |
142 | async def test_download_file_with_kwargs(app: App, mocker: MockerFixture):
143 | """测试下载文件,附带参数"""
144 | from nonebot_plugin_datastore import PluginData
145 |
146 | get = mocker.patch("httpx.AsyncClient.get", side_effect=mocked_get)
147 |
148 | plugin_data = PluginData("test")
149 |
150 | data = await plugin_data.download_file("http://example.com", "test", timeout=30)
151 |
152 | with plugin_data.open("test", "rb") as f:
153 | assert data == f.read()
154 |
155 | get.assert_called_once_with("http://example.com", timeout=30)
156 |
157 |
158 | async def test_load_local_file_in_cache_dir(app: App, mocker: MockerFixture):
159 | """测试读取本地文件,放在缓存目录中"""
160 | from nonebot_plugin_datastore import PluginData
161 |
162 | get = mocker.patch("httpx.AsyncClient.get", side_effect=mocked_get)
163 |
164 | plugin_data = PluginData("test")
165 | plugin_data.dump_json({"key": "值"}, "test", cache=True)
166 |
167 | file = plugin_data.network_file("http://example.com", "test", cache=True)
168 |
169 | data = await file.data
170 | assert data == {"key": "值"}
171 |
172 | # 访问数据,因为会读取本地文件,所以不会请求网络
173 | get.assert_not_called()
174 |
--------------------------------------------------------------------------------
/tests/test_open.py:
--------------------------------------------------------------------------------
1 | from nonebug import App
2 |
3 |
4 | async def test_exists(app: App):
5 | """测试文件是否存在"""
6 | from nonebot_plugin_datastore import PluginData
7 | from nonebot_plugin_datastore.config import plugin_config
8 |
9 | data = PluginData("test")
10 |
11 | test_file = plugin_config.datastore_data_dir / "test" / "data"
12 | assert test_file.exists() is False
13 | assert data.exists("data") is False
14 | test_file.touch()
15 | assert test_file.exists() is True
16 | assert data.exists("data") is True
17 |
18 | test_file = plugin_config.datastore_cache_dir / "test" / "cache"
19 | assert test_file.exists() is False
20 | assert data.exists("cache", cache=True) is False
21 | test_file.touch()
22 | assert test_file.exists() is True
23 | assert data.exists("cache", cache=True) is True
24 |
25 |
26 | async def test_open_file(app: App):
27 | """测试打开文件"""
28 | from nonebot_plugin_datastore import PluginData
29 |
30 | data = PluginData("test")
31 |
32 | test_file = data.data_dir / "test.txt"
33 | assert test_file.exists() is False
34 | test_file.write_text("test")
35 |
36 | with data.open("test.txt", "r") as f:
37 | assert f.read() == "test"
38 |
39 | test_file = data.cache_dir / "test.txt"
40 | assert test_file.exists() is False
41 | test_file.write_text("test")
42 |
43 | with data.open("test.txt", "r", cache=True) as f:
44 | assert f.read() == "test"
45 |
46 |
47 | async def test_dump_load_data(app: App):
48 | """测试 dump 和 load 数据"""
49 | from nonebot_plugin_datastore import PluginData
50 |
51 | data = PluginData("test")
52 |
53 | test = {"test": 1}
54 | data.dump_pkl(test, "test.pkl")
55 | assert data.exists("test.pkl") is True
56 | assert data.load_pkl("test.pkl") == test
57 |
58 | test = {"test": 2}
59 | data.dump_pkl(test, "test.pkl", cache=True)
60 | assert data.exists("test.pkl", cache=True) is True
61 | assert data.load_pkl("test.pkl", cache=True) == test
62 |
63 | test = {"test": 3}
64 | data.dump_json(test, "test.json")
65 | assert data.exists("test.json") is True
66 | assert data.load_json("test.json") == test
67 |
68 | test = {"test": 4}
69 | data.dump_json(test, "test.json", cache=True)
70 | assert data.exists("test.json", cache=True) is True
71 | assert data.load_json("test.json", cache=True) == test
72 |
--------------------------------------------------------------------------------
/tests/test_plugin.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from nonebug import App
3 |
4 |
5 | async def test_get_plugin_data_failed(app: App):
6 | """获取插件数据失败"""
7 | from nonebot_plugin_datastore import get_plugin_data
8 |
9 | # 不在插件中调用
10 | # 挺奇妙的,如果用 pytest 跑会报无法找到调用者
11 | # 但是 vscode 调试中跑就会报自动获取插件名失败
12 | with pytest.raises(ValueError, match=r"无法找到调用者|自动获取插件名失败"):
13 | get_plugin_data()
14 |
15 | # 没有加载插件直接使用
16 | with pytest.raises(ValueError, match="无法找到调用者"):
17 | import tests.example.plugin1 # noqa: F401
18 |
19 |
20 | async def test_plugin_dir_is_file(app: App):
21 | """插件数据文件夹已经存在且为文件"""
22 | from nonebot_plugin_datastore import PluginData
23 | from nonebot_plugin_datastore.config import plugin_config
24 |
25 | plugin_config.datastore_data_dir.mkdir(parents=True, exist_ok=True)
26 | plugin_dir = plugin_config.datastore_data_dir / "test"
27 | plugin_dir.touch()
28 | assert plugin_dir.is_file()
29 |
30 | data = PluginData("test")
31 | with pytest.raises(RuntimeError):
32 | data.data_dir
33 |
--------------------------------------------------------------------------------
/tests/test_registry.py:
--------------------------------------------------------------------------------
1 | from nonebug import App
2 |
3 |
4 | async def test_registry(app: App):
5 | """测试注册"""
6 | from nonebot import require
7 |
8 | from nonebot_plugin_datastore.db import init_db
9 |
10 | require("tests.registry.plugin1")
11 | require("tests.registry.plugin2")
12 |
13 | from tests.registry.plugin1 import Example
14 |
15 | await init_db()
16 |
17 | Example(message="matcher")
18 |
19 |
20 | async def test_global_registry(app: App):
21 | """测试全局注册"""
22 | from nonebot import require
23 |
24 | from nonebot_plugin_datastore.db import init_db
25 |
26 | require("tests.registry.plugin3")
27 | require("tests.registry.plugin3_plugin4")
28 |
29 | from tests.registry.plugin3_plugin4 import Example
30 |
31 | await init_db()
32 |
33 | Example(message="matcher")
34 |
--------------------------------------------------------------------------------
/tests/test_singleton.py:
--------------------------------------------------------------------------------
1 | from nonebug import App
2 |
3 |
4 | async def test_singleton(app: App):
5 | """测试单例"""
6 | from nonebot_plugin_datastore import PluginData
7 |
8 | data1 = PluginData("test")
9 | data2 = PluginData("test")
10 | assert data1 is data2
11 | await data1.config.set("test", 1)
12 | assert await data2.config.get("test") == 1
13 |
14 |
15 | async def test_singleton_keyword(app: App):
16 | """测试单例
17 |
18 | 一个使用位置形参,一个使用关键字形参
19 | """
20 | from nonebot_plugin_datastore import PluginData
21 |
22 | data1 = PluginData("test")
23 | data2 = PluginData(name="test")
24 | assert data1 is data2
25 | await data1.config.set("test", 1)
26 | assert await data2.config.get("test") == 1
27 |
28 |
29 | async def test_singleton_different(app: App):
30 | """测试单例
31 |
32 | 不同名称的情况
33 | """
34 | from nonebot_plugin_datastore import PluginData
35 |
36 | data1 = PluginData("test")
37 | data2 = PluginData("test2")
38 | assert data1 is not data2
39 | await data1.config.set("test", 1)
40 | assert await data2.config.get("test") is None
41 |
--------------------------------------------------------------------------------
/tests/utils.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from typing import TYPE_CHECKING, Optional
3 |
4 | from pydantic import create_model
5 |
6 | if TYPE_CHECKING:
7 | from nonebot.adapters import Event, Message
8 |
9 |
10 | def make_fake_message() -> type["Message"]:
11 | from nonebot.adapters import Message, MessageSegment
12 |
13 | class FakeMessageSegment(MessageSegment):
14 | @classmethod
15 | def get_message_class(cls):
16 | return FakeMessage
17 |
18 | def __str__(self) -> str:
19 | return self.data["text"] if self.type == "text" else f"[fake:{self.type}]"
20 |
21 | @classmethod
22 | def text(cls, text: str):
23 | return cls("text", {"text": text})
24 |
25 | @classmethod
26 | def image(cls, url: str):
27 | return cls("image", {"url": url})
28 |
29 | def is_text(self) -> bool:
30 | return self.type == "text"
31 |
32 | class FakeMessage(Message):
33 | @classmethod
34 | def get_segment_class(cls):
35 | return FakeMessageSegment
36 |
37 | @staticmethod
38 | def _construct(msg: str):
39 | yield FakeMessageSegment.text(msg)
40 |
41 | return FakeMessage
42 |
43 |
44 | def make_fake_event(
45 | _type: str = "message",
46 | _name: str = "test",
47 | _description: str = "test",
48 | _user_id: str = "test",
49 | _session_id: str = "test",
50 | _message: Optional["Message"] = None,
51 | _to_me: bool = True,
52 | **fields,
53 | ) -> type["Event"]:
54 | from nonebot.adapters import Event
55 |
56 | _Fake = create_model("_Fake", __base__=Event, **fields)
57 |
58 | class FakeEvent(_Fake):
59 | def get_type(self) -> str:
60 | return _type
61 |
62 | def get_event_name(self) -> str:
63 | return _name
64 |
65 | def get_event_description(self) -> str:
66 | return _description
67 |
68 | def get_user_id(self) -> str:
69 | return _user_id
70 |
71 | def get_session_id(self) -> str:
72 | return _session_id
73 |
74 | def get_message(self) -> "Message":
75 | if _message is not None:
76 | return _message
77 | raise NotImplementedError
78 |
79 | def is_tome(self) -> bool:
80 | return _to_me
81 |
82 | return FakeEvent
83 |
84 |
85 | def clear_plugins() -> None:
86 | from nonebot.plugin import _managers, _plugins
87 |
88 | for plugin in _plugins.values():
89 | keys = [key for key in sys.modules if key.startswith(plugin.module_name)]
90 | for key in keys:
91 | del sys.modules[key]
92 | _plugins.clear()
93 | _managers.clear()
94 |
--------------------------------------------------------------------------------