├── .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 | nonebot 5 |

6 | 7 |
8 | 9 | # NoneBot Plugin DataStore 10 | 11 | _✨ NoneBot 数据存储插件 ✨_ 12 | 13 |
14 | 15 |

16 | 17 | license 18 | 19 | 20 | pypi 21 | 22 | python 23 | 24 | python 25 | 26 | 27 | QQ Chat Group 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 | --------------------------------------------------------------------------------