mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 13:45:42 +08:00
ci(4.0-P7): CI 运行示例插件测试,release 附带 SDK 轮子与插件作者 skill
- test 作业新增示例插件步骤 - release 作业构建 autobangumi-sdk 轮子并压缩 skills/autobangumi-plugin,随 beta 与稳定版一起上传 - 新增 skills/autobangumi-plugin:插件作者 agent skill - beta tag 原本就创建预发布 release,分类脚本与 Docker 标签不变,新增测试固定该行为
This commit is contained in:
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -34,6 +34,10 @@ jobs:
|
||||
run: |
|
||||
mkdir -p backend/config
|
||||
cd backend && uv run pytest src/test -v -m "not e2e"
|
||||
# 示例插件:校验清单并跑各自的契约 / 行为测试(与插件作者在示例目录里
|
||||
# `uv run pytest` 一致)
|
||||
- name: Example plugins
|
||||
run: scripts/test_example_plugins.sh
|
||||
|
||||
webui-test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -311,6 +315,16 @@ jobs:
|
||||
cd backend && zip -r app-v${{ needs.version-info.outputs.version }}.zip \
|
||||
src pyproject.toml uv.lock requirements.txt
|
||||
|
||||
# 插件作者用的两个附件:autobangumi-sdk 轮子(ab_sdk + ab-plugin,不发 PyPI,
|
||||
# 作者从 release 下载安装)和 autobangumi-plugin agent skill 的 zip。
|
||||
# beta(预发布)与稳定版 release 都会带上它们。
|
||||
- name: Build plugin SDK wheel
|
||||
run: uv build --wheel backend/sdk --out-dir sdk-dist
|
||||
|
||||
- name: Zip plugin-author skill
|
||||
run: |
|
||||
cd skills && zip -r "../autobangumi-plugin-skill-${{ needs.version-info.outputs.version }}.zip" autobangumi-plugin
|
||||
|
||||
# 在线自动更新 bundle:module 源码树 + pyproject/uv.lock + 前端 dist + manifest。
|
||||
# 同时产出 .sha256(客户端下载后据此校验完整性)。min_image_version 是能应用
|
||||
# 此 bundle 布局的最低镜像版本(引入在线更新的首个版本)。
|
||||
@@ -398,6 +412,8 @@ jobs:
|
||||
update-bundle-${{ needs.version-info.outputs.version }}.zip
|
||||
update-bundle-${{ needs.version-info.outputs.version }}.zip.sha256
|
||||
update-bundle-${{ needs.version-info.outputs.version }}.zip.sig
|
||||
sdk-dist/*.whl
|
||||
autobangumi-plugin-skill-${{ needs.version-info.outputs.version }}.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
|
||||
@@ -108,3 +108,17 @@ def test_pr_title_is_never_a_version_source(pr_title):
|
||||
|
||||
assert result["version"] == "Test"
|
||||
assert result["release"] == "0"
|
||||
|
||||
|
||||
def test_release_job_marks_dev_tags_prerelease_and_attaches_plugin_assets():
|
||||
"""beta 与稳定版共用 release 作业;dev=1 即预发布,两者都带 SDK 轮子与 skill。"""
|
||||
workflow = SCRIPT_PATH.parents[1] / ".github" / "workflows" / "build.yml"
|
||||
source = workflow.read_text(encoding="utf-8")
|
||||
release = source[source.index("\n release:\n") : source.index("\n telegram:\n")]
|
||||
assert "needs.version-info.outputs.release == 1" in release
|
||||
assert "pre_release=true" in release
|
||||
assert "sdk-dist/*.whl" in release
|
||||
assert "autobangumi-plugin-skill-" in release
|
||||
assert (
|
||||
SCRIPT_PATH.parents[1] / "skills" / "autobangumi-plugin" / "SKILL.md"
|
||||
).is_file()
|
||||
|
||||
85
skills/autobangumi-plugin/SKILL.md
Normal file
85
skills/autobangumi-plugin/SKILL.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: autobangumi-plugin
|
||||
description: Write, test, pack and debug AutoBangumi (AB) 4.0 plugins with the ab_sdk Python SDK and the ab-plugin CLI. Use when the user asks to "write an AutoBangumi plugin", "add a downloader / notifier / search site / rename strategy / RSS site to AutoBangumi", "hook into AutoBangumi", mentions ab_sdk, ab-plugin, plugin.toml, @provider, @hook, @subscribe, secret_field, or wants a custom rename template, webhook on torrent.organized, nfo writer, private tracker Cookie, or a plugin web component for the AutoBangumi WebUI.
|
||||
---
|
||||
|
||||
# AutoBangumi plugin authoring
|
||||
|
||||
AB plugins are Python packages (optional Web Component UI) that run inside the AB process. They import only `ab_sdk`, never `module.*`. SDK is 0.x during 4.0: pin `sdk = ">=0.5,<1"` in the manifest.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Pick the extension point (table below). If unsure, read `references/extension-points.md`.
|
||||
2. Scaffold: `ab-plugin new <id> --kind rename|notifier|search` (ids: lowercase, digits, hyphens; `core` and `local` are reserved). For other points, copy the closest example from `examples/plugins/` in the AB repo.
|
||||
3. Write the plugin: one `Plugin[Options]` subclass, `config_model = Options`, decorated methods.
|
||||
4. Test first: `uv run pytest`. Provider points have contract suites in `ab_sdk.testing` (subclass, implement `create()`). Other behavior: `create_plugin(Cls, options)` plus `asyncio.run`.
|
||||
5. `ab-plugin validate .` then `ab-plugin dev .` (links into `config/plugins/local/`, enables hot reload; restart AB once) then `ab-plugin pack .`.
|
||||
|
||||
Install the tooling from the GitHub release wheel (`autobangumi_sdk-<ver>-py3-none-any.whl`), not PyPI.
|
||||
|
||||
## Skeleton
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from ab_sdk import Plugin, points, provider, hook, subscribe, secret_field
|
||||
|
||||
class Options(BaseModel):
|
||||
url: str = Field("", title="URL") # title/description become the WebUI form labels
|
||||
token: str = secret_field(title="Token") # password box; masked on read
|
||||
|
||||
class MyPlugin(Plugin[Options]):
|
||||
config_model = Options # self.config is a validated Options
|
||||
|
||||
@provider(points.NOTIFIER, id="my-push") # Provider: the thing the user selects by id
|
||||
def channel(self): ...
|
||||
|
||||
@hook(points.TORRENT_FILTER, priority=50) # Hook: runs in the AB pipeline
|
||||
def check(self, torrent, release, bangumi): ...
|
||||
|
||||
@subscribe("torrent.organized", timeout=600) # Event subscriber ("*" = all events)
|
||||
async def on_organized(self, event): ...
|
||||
```
|
||||
|
||||
`self.ctx`: `plugin_id`, `config`, `log`, `kv` (async get/set/delete, JSON values), `data_dir`, `bus` (`publish`, `subscribe`).
|
||||
|
||||
## Extension points
|
||||
|
||||
| Point constant | Kind | Returns / signature |
|
||||
| --- | --- | --- |
|
||||
| `DOWNLOADER` | provider | `DownloaderFactory`: `(DownloaderConnection) -> client` with `capabilities`, `auth`, `logout`, `add_torrents` |
|
||||
| `NOTIFIER` | provider | `NotifierFactory`: `(NotifierSettings) -> Notifier` (`send -> bool`, `test -> (bool, str)`) |
|
||||
| `LLM_PROVIDER` | provider | `LLMProviderAdapter` subclass; id must equal `info.id` |
|
||||
| `SEARCH_SITE` | provider | `SearchSite(url_with_%s, parser="tmdb"\|"mikan")` |
|
||||
| `SCHEDULED_TASK` | provider | `ScheduledTask(run, interval, initial_delay, enabled)` |
|
||||
| `METADATA_PROVIDER` | provider | object with `async enrich(MetadataRequest) -> Metadata \| None` |
|
||||
| `RENAME_STRATEGY` | provider | object with `target_name(RenameInput) -> str`; raise `RenameSkipped` for bad input |
|
||||
| `MEDIA_FILES` | provider | `classify(path) -> "media"\|"subtitle"\|"ignore"` |
|
||||
| `CONFLICT_POLICY` | provider | `decide(ConflictRequest) -> ConflictDecision("hold"\|"replace", reason)` |
|
||||
| `API_ROUTER` | provider | `fastapi.APIRouter`, mounted at `/api/v1/plugins/<id>/`, login required |
|
||||
| `MCP_TOOL` / `MCP_RESOURCE` | provider | `McpTool` / `McpResource` |
|
||||
| `TORRENT_FILTER` | filter hook | `(torrent, release, bangumi) -> Verdict` |
|
||||
| `TITLE_PARSED` | transform hook | `(release) -> release \| None` |
|
||||
| `TORRENT_ADDING` | transform hook | `(AddRequest) -> AddRequest \| None` |
|
||||
| `HTTP_REQUEST` | transform hook | `(HttpRequest) -> HttpRequest \| None` (headers only) |
|
||||
| `MESSAGE_TEMPLATE` | transform hook | `(RenderedMessage, event, channel) -> RenderedMessage \| None` |
|
||||
|
||||
Details, imports and field lists: `references/extension-points.md`.
|
||||
|
||||
## Rules that prevent most bugs
|
||||
|
||||
- Hooks get frozen snapshots. Return `dataclasses.replace(obj, ...)`; return `None` for "no change". A wrong return type counts as a failure.
|
||||
- Five consecutive failures trip the circuit breaker and disable the plugin. `RenameSkipped` is not a failure. Never fall back to another naming method after a failure.
|
||||
- Filter hooks fail open (a crash accepts the torrent). Notifier `send` returns `False` on a backend refusal; it does not raise.
|
||||
- Events: plugin event `kind` must start with `<plugin-id>.`. Subclass `SystemEvent` to make a notifiable event. `torrent.organized` is delivered at least once: make handlers idempotent. Event paths are downloader-side paths; map them (`path_from` / `path_to` options) before touching the disk.
|
||||
- Blocking work (file IO, copies) goes in `asyncio.to_thread`; `target_name` is sync with no timeout, so no IO there.
|
||||
- Validate config deeply in `field_validator` so bad values give HTTP 422 at save time.
|
||||
- No native extensions (`.so`, `.pyd`, `.dylib`, `.dll`). Third-party pure-Python deps go in `vendor/`; AB does not run pip for catalog or local plugins.
|
||||
- Plugin routes cannot use the paths `web/`, `install`, `catalog`.
|
||||
- Local and pip plugins are unsigned: the user must turn on "Allow unsigned plugins". Only maintainers sign the catalog (`scripts/build_plugin_catalog.py`).
|
||||
|
||||
## References
|
||||
|
||||
- `references/extension-points.md`: imports, dataclass fields and gotchas per extension point.
|
||||
- `references/testing.md`: contract suites, `create_plugin`, `ab-plugin` commands, the manifest.
|
||||
- `references/frontend.md`: `[[plugin.ui]]`, slots, `AbHost`, CSP, build template.
|
||||
- Examples (in the AB repo): `examples/plugins/` has `webhook-on-event`, `custom-rss-site`, `template-rename`, `nfo-writer`, `ntfy-notifier`, `manual-pick`.
|
||||
75
skills/autobangumi-plugin/references/extension-points.md
Normal file
75
skills/autobangumi-plugin/references/extension-points.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Extension points
|
||||
|
||||
All imports come from `ab_sdk`. Constants live in `ab_sdk.points`.
|
||||
|
||||
## Providers
|
||||
|
||||
### downloader (`points.DOWNLOADER`)
|
||||
```python
|
||||
from ab_sdk.downloader import AddResult, DownloaderCapabilities, DownloaderConnection
|
||||
```
|
||||
- Factory is called with `DownloaderConnection(host, username, password, ssl)`; return a client object. Provider `id` is the instance type the user picks (`plugins.instances[].provider`).
|
||||
- Minimum (`CoreDownloaderClient`): class attribute `capabilities = DownloaderCapabilities(can_query, can_rename, can_manage, can_rss_rules)`, `async auth(retry=3) -> bool`, `async logout()`, `async add_torrents(torrent_urls, torrent_files, save_path, category, tags=None) -> AddResult` (`ADDED` / `DUPLICATE` / `FAILED`).
|
||||
- A declared capability requires its methods: `can_query` -> `torrents_info`, `torrent_exists`, `torrents_files`; `can_rename` -> `torrents_rename_file` (returns `RenameResult`); `can_manage` -> delete/pause/resume/move/category/tag methods. Undeclared operations are skipped and logged.
|
||||
- Test: subclass `DownloaderContract`; set `behavioral = True` only when `create()` returns a client with a working backend or stub.
|
||||
|
||||
### notifier (`points.NOTIFIER`)
|
||||
```python
|
||||
from ab_sdk.notify import NotificationMessage, NotifierFactory, NotifierSettings
|
||||
```
|
||||
- `NotificationMessage(kind="episode"|"event", title, body, official_title, season, episode, poster_url)`.
|
||||
- `NotifierSettings(template, extra)`. Put credentials in the plugin `config_model`, not in channel entries.
|
||||
- Non-ASCII header values: pass `bytes` to httpx (`str` headers must be ASCII).
|
||||
- Test: `NotifierContract`; implement `create_failing()` for the backend-refusal case.
|
||||
|
||||
### llm_provider (`points.LLM_PROVIDER`)
|
||||
`ab_sdk.llm.LLMProviderAdapter` subclass with `info = ProviderInfo(id=..., display_name=..., auth_kind="api_key"|"oauth"|"device_code", ...)`. `@provider(..., id=)` must equal `info.id`. Raise `AuthExpiredError` when credentials are dead and refresh failed.
|
||||
|
||||
### search_site (`points.SEARCH_SITE`)
|
||||
`SearchSite(url="https://x/rss?q=%s", parser="tmdb"|"mikan")`. Exactly one `%s`. User-defined site of the same name wins. Test: `SearchSiteContract`.
|
||||
|
||||
### scheduled_task (`points.SCHEDULED_TASK`)
|
||||
`ScheduledTask(run, interval, initial_delay=0.0, enabled=None)`. `interval` and `enabled` may be callables re-read each round. Interval counts from the end of the previous run.
|
||||
|
||||
### metadata_provider (`points.METADATA_PROVIDER`)
|
||||
Object with `async enrich(MetadataRequest) -> Metadata | None`. `MetadataRequest(kind, torrent, language, episode_type, current)`; return `replace(request.current, ...)`. The id appears in the subscription "parser" list.
|
||||
|
||||
### rename_strategy (`points.RENAME_STRATEGY`)
|
||||
```python
|
||||
from ab_sdk.rename import RenameInput, RenameSkipped, pad
|
||||
```
|
||||
- `RenameInput(kind, media_path, title, bangumi_name, season, episode, suffix, episode_type, language, group)`.
|
||||
- Return the new relative path inside the torrent (add suffix and subtitle `.language` yourself). Return `f.media_path` for "no rename". `pad(9.5) == "09.5"`: keep half-episode fractions.
|
||||
- `RenameSkipped(reason)`: file keeps its name, user is notified, not a breaker failure. Other exceptions, empty or non-str results count as failures.
|
||||
- User picks the id at `plugins.slots.rename_strategy`; unregistered id acts as `none`. Test: `RenameStrategyContract` (override `samples()` for narrow strategies).
|
||||
|
||||
### media_files / conflict_policy
|
||||
- `MediaFiles.classify(path) -> "media"|"subtitle"|"ignore"`; slot `plugins.slots.media_files`, default `default`.
|
||||
- `ConflictPolicy.decide(ConflictRequest(target_path, incoming, owners, strict_upgrade)) -> ConflictDecision(action, reason)`. `replace` deletes the old task and files; the host runs it only for a single owner, single-file torrents and complete identities. Slot `plugins.slots.conflict_policy`, default `hold`.
|
||||
|
||||
### api_router (`points.API_ROUTER`)
|
||||
Return `fastapi.APIRouter`. Mounted at `/api/v1/plugins/<id>/`. Always login-protected. `HTTPException` passes through; other exceptions give 500 and count as failures. Not in `/docs`.
|
||||
|
||||
### mcp_tool / mcp_resource
|
||||
`McpTool(description, handler(args)->json, input_schema, timeout=60)`; public name `<plugin-id>__<id>`. `McpResource(name, handler()->str|json, description, mime_type)`; URI `autobangumi://plugins/<plugin-id>/<id>`.
|
||||
|
||||
## Hooks
|
||||
|
||||
- `torrent.filter`: `check(torrent: TorrentInfo, release: Release | None, bangumi: BangumiInfo) -> Verdict` (`Verdict.ok()` / `Verdict.reject(reason)`; bool also works).
|
||||
- `title.parsed`: `fix(release)`; readable fields `raw, title_en, title_zh, title_jp, group, season, episode, resolution, source, subtitle, year`.
|
||||
- `torrent.adding`: `adjust(AddRequest(bangumi, torrents, save_path, category, tags)) -> AddRequest | None`. Keep the `<name>/Season N` save-path shape; keep category `Bangumi` or AB stops organizing the torrent; the `ab:<id>` tag is restored if removed.
|
||||
- `http.request`: `adjust(HttpRequest(method, url, headers))`; only header changes apply. Called for every GET: keep it cheap, no network inside.
|
||||
- `message_template`: `(RenderedMessage(title, body), event, channel) -> RenderedMessage | None`; external push only; "new episode" notices are not covered.
|
||||
|
||||
## Events
|
||||
|
||||
`@subscribe(kind)` handlers get the event dataclass. System kinds: `rss_failure`, `download_failure`, `offset_review`, `downloader_unavailable`, `update_available`, `update_applied`, `update_failed`, `llm_auth_failure`, `llm_plugin_install_failed`, `rename_conflict`, `rename_skipped`, `plugin.loaded`, `plugin.disabled`. Organize kinds: `file.renamed` (`bangumi_id, old_path, new_path, file_kind, downloader_id`), `torrent.organized` (`torrent_hash, bangumi_id, files: tuple[OrganizedFile(path, kind)], downloader_id`).
|
||||
|
||||
Custom event:
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Done(Event): # SystemEvent for a notifiable event (add describe())
|
||||
kind: ClassVar[str] = "my-plugin.done"
|
||||
count: int
|
||||
```
|
||||
Publish with `self.ctx.bus.publish(Done(count=1))`.
|
||||
34
skills/autobangumi-plugin/references/frontend.md
Normal file
34
skills/autobangumi-plugin/references/frontend.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Frontend (Web Component) slots
|
||||
|
||||
Plugin UI is a custom element defined by an ES module under `web/`. AB sets `element.host` (AbHost) and `element.context` before inserting it.
|
||||
|
||||
## Manifest
|
||||
|
||||
```toml
|
||||
[[plugin.ui]]
|
||||
slot = "bangumi.detail.tab" # settings.section | bangumi.detail.tab | bangumi.card.action | page | dashboard.widget
|
||||
element = "ab-plugin-<plugin-id>" # must equal or start with ab-plugin-<plugin-id>-
|
||||
entry = "web/index.js" # under web/
|
||||
title = { zh-CN = "…", en-US = "…" } # at least one language
|
||||
```
|
||||
|
||||
`context`: `{ bangumiId }` for `bangumi.detail.tab` and `bangumi.card.action`; `{}` otherwise.
|
||||
|
||||
## AbHost
|
||||
|
||||
- `host.pluginId`
|
||||
- `host.api.get/post/put/delete(path, ...)`: JSON in/out, rejects on non-2xx. Relative path = `/api/v1/plugins/<id>/…` (the plugin's own `api_router`). Paths starting `/api/v1/` are host APIs, GET only.
|
||||
- `host.i18n.locale`, `host.i18n.t(key)`
|
||||
- `host.theme.mode`, `host.theme.tokens` (`--ab-*`)
|
||||
- `host.toast(message, "info" | "error")`
|
||||
- `host.events.on(kind, cb) -> unsubscribe` (event bus; callback gets the event fields)
|
||||
|
||||
## Rules
|
||||
|
||||
- CSP is `script-src 'self'`: no inline scripts, `eval` or CDN scripts; bundle everything into the module. Images, styles and fetches are free.
|
||||
- Use a Shadow DOM and `var(--ab-color-primary)`, `--ab-color-surface`, `--ab-color-text`, `--ab-radius-sm`, `--ab-font-mono` for theme-aware styling.
|
||||
- Clean up in `disconnectedCallback` (call the unsubscribe function).
|
||||
- Static files are served at `/api/v1/plugins/<id>/web/…` (login required, enabled plugins only). Do not use `web/` as an `api_router` path.
|
||||
- An import failure or uncaught error shows "plugin component failed to load" for that plugin only.
|
||||
- Build template: `webui/packages/plugin-ui/template/` in the AB repo (Vite library mode, one self-contained ES module to `web/index.js`). Types: `@autobangumi/plugin-ui` (`AbHost`, `AbPluginElement`, `AbSlotContext`); a workspace package, not on npm.
|
||||
- Reference implementation: `examples/plugins/manual-pick/` (no build step).
|
||||
56
skills/autobangumi-plugin/references/testing.md
Normal file
56
skills/autobangumi-plugin/references/testing.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Testing, CLI and manifest
|
||||
|
||||
## Manifest (`plugin.toml`)
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
id = "my-plugin" # ^[a-z0-9]+(-[a-z0-9]+)*$ ; not core/local
|
||||
name = "My plugin"
|
||||
version = "0.1.0" # PEP 440
|
||||
sdk = ">=0.5,<1" # must include the installed SDK version
|
||||
entry = "my_plugin:MyPlugin" # module:Class, relative to the plugin dir
|
||||
description = ""
|
||||
authors = []
|
||||
permissions = ["network"] # display only
|
||||
extension_points = ["notifier"] # display only (catalog)
|
||||
|
||||
[[plugin.ui]] # optional, see frontend.md
|
||||
slot = "page"
|
||||
element = "ab-plugin-my-plugin"
|
||||
entry = "web/index.js"
|
||||
title = { en-US = "My plugin" }
|
||||
```
|
||||
|
||||
Layout: `plugin.toml`, `my_plugin/__init__.py` (or `my_plugin.py`), optional `web/`, `vendor/`, `tests/`, dev-only `pyproject.toml` (not packed).
|
||||
|
||||
## ab-plugin CLI
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `ab-plugin new <id> [--kind rename\|notifier\|search] [--dir .]` | Skeleton with a contract test |
|
||||
| `ab-plugin validate [path]` | Manifest, sdk range, entry module, web entry, native-file check; exit 1 on problems |
|
||||
| `ab-plugin pack [path] [-o dist]` | Deterministic `dist/<id>-<version>.zip` (content at zip root; excludes tests, pyproject, caches) |
|
||||
| `ab-plugin dev [path] [--config-dir config]` | Symlink to `config/plugins/local/<id>`, write `dev_mode`, `allow_unsigned`, `enabled.<id>`; needs an existing `config.json`/`config_dev.json`; restart AB once, then file changes reload the plugin |
|
||||
|
||||
## Tests
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from ab_sdk.testing import create_plugin
|
||||
|
||||
def test_x(tmp_path):
|
||||
plugin, ctx = create_plugin(MyPlugin, {"url": "https://x"}, data_dir=tmp_path)
|
||||
asyncio.run(plugin.on_event(event)) # call decorated methods directly
|
||||
assert ctx.bus.published == [] # events the plugin published
|
||||
```
|
||||
|
||||
- `create_plugin` validates options through `config_model`, builds the plugin, does not call `setup()`. `ctx.kv` is an in-memory store; `await ctx.bus.deliver(event)` feeds the plugin's own subscriptions.
|
||||
- Keep tests synchronous with `asyncio.run` so plain pytest works (no pytest-asyncio needed).
|
||||
- Network: replace the HTTP client with `httpx.MockTransport` (expose a `client_factory` attribute on the plugin). Cover the backend-refusal path.
|
||||
- Contract suites (subclass, implement `create()`): `DownloaderContract`, `RenameStrategyContract`, `NotifierContract` (+ `create_failing()`), `SearchSiteContract`. Run them through the real provider method, e.g. `create_plugin(Cls)[0].strategy()`.
|
||||
- Write the failing test first; a contract failure usually points to a real bug (e.g. non-ASCII header values).
|
||||
|
||||
## Packaging and signing
|
||||
|
||||
- `ab-plugin pack` output is the unit for the signed catalog. Maintainers run `scripts/build_plugin_catalog.py` (ed25519 `.sig` for `catalog.json` and each zip) and upload to the `plugins` release. Authors cannot self-sign into the official catalog.
|
||||
- pip distribution: entry point group `autobangumi.plugins`, with `plugin.toml` in the top-level package.
|
||||
Reference in New Issue
Block a user