builder & dev tools

This commit is contained in:
lyuksovannyy committed 2026-09-12 03:57:17 +02:00
1 parent c0ad0d49f2
commit ae63f8b9a3
26 files changed
+1504 -1

No files matched your search

+3
View File
@@ -174,3 +174,6 @@ cython_debug/
# PyPI configuration file
.pypirc
# exteraGram plugin toolchain
/stubs/
/.venv/
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+34
View File
@@ -0,0 +1,34 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyStringConversionWithoutDunderMethodInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredTypes">
<list>
<option value="types.NoneType" />
<option value="_io.TextIOWrapper" />
<option value="str" />
<option value="int" />
<option value="float" />
<option value="complex" />
<option value="set" />
<option value="frozenset" />
<option value="bytes" />
<option value="bytearray" />
<option value="memoryview" />
<option value="slice" />
<option value="list" />
<option value="dict" />
<option value="bool" />
<option value="range" />
<option value="tuple" />
<option value="pathlib.PurePath" />
<option value="uuid.UUID" />
<option value="decimal.Decimal" />
<option value="fractions.Fraction" />
<option value="schemas.token.JWTToken" />
</list>
</option>
</inspection_tool>
</profile>
</component>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
<option name="customApplicationId" value="" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/exteraGramPluginBase.iml" filepath="$PROJECT_DIR$/.idea/exteraGramPluginBase.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "exteraGram plugin: attach debugpy",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 5678
},
"justMyCode": false
}
]
}
+100 -1
View File
@@ -1,2 +1,101 @@
# pluginBase
# exteraGram Plugin Base
A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
## How exteraGram plugins actually work
A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
`/data/user/0/com.exteragram.messenger/files/plugins/<plugin_id>.py`. The distribution
convention names the same content `<id>.plugin`.
The app reads plugin metadata **statically via AST** — it looks for top-level constant
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
file. Only `__id__` and `__name__` are required.
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
`on_app_event`) are all called by the host; hooks must be registered inside
`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
`HookResult()` to pass through. Settings rows returned from `create_settings` come from
`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
This repo does not use the official Elyx/ElyxBuilder archive format — see
`docs/sdk-notes.md` for why.
## Repo layout
```
plugin.json single source of truth for plugin metadata
src/ the plugin, written as an ordinary Python package
main.py entry module — exactly one BasePlugin subclass, absolute imports only
greeting/ sample package (proves relative + cross-module imports survive bundling)
textutil.py sample top-level sibling module
tools/
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
scripts/ bash automation for the ADB debug loop (see below)
docs/sdk-notes.md condensed SDK API surface gathered from the official docs
```
## The three commands that matter
```bash
python3 tools/bundle.py --release # compile src/ -> dist/<id>.plugin
python3 tools/verify_bundle.py dist/<id>.plugin # offline proof the bundle works, no device needed
bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
```
`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
a private import hook (scoped to a per-module `__builtins__` dict, never touching
`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
## Device prerequisites
- exteraGram 12.5.1+.
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
- USB debugging enabled, device authorized (`adb devices`).
Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
socket forwarded with `adb forward`, and the app writes the file into its own storage.
## ADB automation
All scripts live in `scripts/`, are runnable from any working directory, and honor the
standard `ANDROID_SERIAL` env var for device selection.
- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
`tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
plugin to the device, optionally enables it, reloads it.
- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
(uses `inotifywait` if available, otherwise polls).
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
`plugin.json` if it's missing.
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
for editor autocomplete.
## Writing your own plugin
1. Edit `plugin.json` (id, name, description, etc.).
2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
only — it's executed at plugin-module top level, not inside a package).
3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
(substitute your `id` from `plugin.json`).
4. `bash scripts/deploy.sh --enable` with a device connected.
+114
View File
@@ -0,0 +1,114 @@
# exteraGram plugin SDK — condensed notes
Gathered from <https://plugins.exteragram.app/docs>, the SDK type stubs (`stubs.zip` from
`exteraSquad/plugins-pysdk-builds`), the official `exteragram-utils` PyPI package
(`exteragram_utils/dev_client.py`), and the app's `gradle.properties`. Not exhaustive — see the
full docs site for anything not covered here.
## Plugin identity and metadata
Read statically via AST from top-level constant assignments in the plugin's single `.py` file
— the app never executes the file just to read metadata. Docs:
<https://plugins.exteragram.app/docs/metadata>.
| Constant | Required | Notes |
|---|---|---|
| `__id__` | yes | 2–32 chars, starts with a letter, only `A-Za-z0-9_-` |
| `__name__` | yes | display name |
| `__description__` | no | |
| `__author__` | no | |
| `__version__` | no | |
| `__icon__` | no | e.g. `"exteraPlugins/1"` |
| `__app_version__` | no | e.g. `">=12.5.1"`; legacy alias `__min_version__` |
| `__sdk_version__` | no | e.g. `">=1.4.4.3"` |
| `__requirements__` | no | list of pip package specs the host installs before loading |
## `BasePlugin` lifecycle and hooks
<https://plugins.exteragram.app/docs/base-plugin>
- `on_plugin_load(self)` — called once when the plugin is enabled/loaded. Hooks must be
registered here (e.g. `self.add_on_send_message_hook()`); registering later has no effect.
- `on_plugin_unload(self)` — called on disable/removal; release resources here.
- `create_settings(self)` — returns a list of `ui.settings` rows rendered in the plugin's
settings screen.
- Hooks: `on_send_message_hook(self, account, params)`, `pre_request_hook(self, ...)`,
`post_request_hook(self, ...)`, `on_update_hook(s)(self, ...)`, `on_app_event(self, ...)`.
Each returns a `HookResult`.
- `HookResult(strategy=HookStrategy.MODIFY, params=params)` applies the mutated `params` back
to the host; `HookStrategy.DEFAULT` (a bare `HookResult()`) passes through unchanged;
`HookStrategy.CANCEL` drops the action; `HookStrategy.MODIFY_FINAL` applies changes and
short-circuits any later hooks.
- Settings persistence: `self.get_setting(key, default)` / `self.set_setting(key, value)`.
## `ui.settings` row types
<https://plugins.exteragram.app/docs/ui-settings>
`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider` — plain kwargs-holding row
descriptors consumed by the host's settings renderer; construct and return them from
`create_settings`.
## Logging
<https://plugins.exteragram.app/docs/logging>
`android_utils.log(...)` writes to logcat. Chaquopy additionally redirects
`sys.stdout`/`sys.stderr` to the logcat tags `python.stdout` / `python.stderr` — useful when a
dependency logs with plain `print()`.
## Background work
<https://plugins.exteragram.app/docs/client-utils>
`client_utils.run_on_queue(...)` schedules work off the UI thread; use it for anything that
shouldn't block message handling.
## Reflection / private API access
<https://plugins.exteragram.app/docs/hook-utils>
`hook_utils.find_class(...)` and related helpers locate app classes and private fields for
plugins that need to reach beyond the public SDK surface.
## UI helpers
- `ui.alert.AlertDialogBuilder` — native alert dialogs. <https://plugins.exteragram.app/docs/ui-alert>
- `ui.bulletin` — transient bottom bulletins/toasts. <https://plugins.exteragram.app/docs/ui-bulletin>
- `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**.
<https://plugins.exteragram.app/docs/intents>
## On-device dev server protocol
<https://plugins.exteragram.app/docs/dev-server>
TCP `127.0.0.1:42690`, reachable from a PC via `adb forward tcp:42690 tcp:42690`. Wire format
is consecutive UTF-8 JSON objects with **no length framing** — parse with
`json.JSONDecoder().raw_decode` over an accumulating buffer, never assume one JSON object per
`recv()`. Requests: `{"@": <command>, "#": <id>, ...}`; responses echo `"#"`.
Commands used by this repo's `tools/devclient.py`: `ping`, `get_plugins`, `enable_plugin`,
`disable_plugin`, `reload_plugin`, `remove_plugin` (field `plugin_id`), `write_plugin`
(`plugin_id`, `content` — installs or updates), `start_debugger`/`stop_debugger` (`host`,
`port`, `platform` ∈ `vscode`|`pycharm`). The official `exteragram-utils` client (PyPI) uses
integer request ids from 1, a 30 s response timeout, a 1.0 s socket timeout, a
`write_plugin` → sleep 0.3 s → `reload_plugin` deploy sequence, and `adb forward` (not
`reverse`) for both the dev-server port and the debugger port. `elyx_*` commands exist for the
Elyx engine and are out of scope here.
## Runtime
Chaquopy 16 / Python 3.11. Anything shipped in a bundle must be 3.11-compatible.
## Why not Elyx/ElyxBuilder
The official structured plugin format (Elyx/ElyxBuilder) is a real alternative to a single
`.plugin` file, but it:
- requires the separate Elyx engine to be installed and enabled on the device;
- ships archives, not the `.plugin` files the base plugin engine understands;
- needs a manual first install before live reload works.
This repo's bundler instead compiles a normal multi-file `src/` project down to the one
`.plugin` file the base engine already knows how to load, so the dev-server write/reload loop
works from the first deploy with no extra on-device engine.
+11
View File
@@ -0,0 +1,11 @@
{
"id": "hello_bundle",
"name": "Hello Bundle",
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
"author": "@yourhandle",
"version": "1.0.0",
"icon": "exteraPlugins/1",
"app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3",
"requirements": []
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$ADB" "$PY"
DEBUG=0
for arg in "$@"; do
case "$arg" in
--debug) DEBUG=1 ;;
*) die "unknown argument: $arg" ;;
esac
done
"$ADB" start-server
timeout 60 "$ADB" wait-for-device || die "timed out waiting for a device (ANDROID_SERIAL=${ANDROID_SERIAL:-<unset>})"
"$ADB" forward --remove "tcp:$DEV_PORT" >/dev/null 2>&1 || true
"$ADB" forward "tcp:$DEV_PORT" "tcp:$DEV_PORT"
if [[ "$DEBUG" -eq 1 ]]; then
"$ADB" forward --remove "tcp:$DEBUG_PORT" >/dev/null 2>&1 || true
"$ADB" forward "tcp:$DEBUG_PORT" "tcp:$DEBUG_PORT"
fi
if "$PY" tools/devclient.py ping >/dev/null 2>&1; then
echo "connected"
else
die "ping failed — is the plugin engine + developer mode enabled in exteraGram Preferences > Plugins?"
fi
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$ADB" "$PY"
usage() {
cat >&2 <<'EOF'
usage: scripts/debug.sh start|stop [--platform vscode|pycharm]
Requires the device-side debugger package to be present in the app's Python
runtime: `debugpy` for vscode, `pydevd-pycharm` for pycharm. If it is not
installed, add it to "requirements" in plugin.json so the app's pip
controller installs it on next load.
EOF
}
[[ $# -ge 1 ]] || { usage; exit 1; }
ACTION="$1"
shift
PLATFORM=vscode
while [[ $# -gt 0 ]]; do
case "$1" in
--platform)
PLATFORM="${2:-}"
shift 2
;;
--platform=*)
PLATFORM="${1#--platform=}"
shift
;;
*)
die "unknown argument: $1"
;;
esac
done
case "$ACTION" in
start|stop) ;;
*) usage; exit 1 ;;
esac
case "$PLATFORM" in
vscode|pycharm) ;;
*) die "--platform must be vscode or pycharm" ;;
esac
scripts/adb-connect.sh --debug
"$PY" tools/devclient.py debugger "$ACTION" --platform "$PLATFORM"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$PY"
MODE=--dev
ENABLE=0
CONNECT=1
for arg in "$@"; do
case "$arg" in
--release) MODE=--release ;;
--enable) ENABLE=1 ;;
--no-connect) CONNECT=0 ;;
*) die "unknown argument: $arg" ;;
esac
done
"$PY" tools/bundle.py "$MODE" --out dist
BUNDLE="dist/${PLUGIN_ID}.plugin"
[[ -f "$BUNDLE" ]] || die "expected bundle not found at $BUNDLE"
echo "bundle: $BUNDLE ($(stat -c '%s' "$BUNDLE" 2>/dev/null || stat -f '%z' "$BUNDLE") bytes)"
if [[ "$CONNECT" -eq 1 ]]; then
need_cmd "$ADB"
scripts/adb-connect.sh
fi
DEPLOY_ARGS=(deploy "$BUNDLE" --id "$PLUGIN_ID")
if [[ "$ENABLE" -eq 1 ]]; then
DEPLOY_ARGS+=(--enable)
fi
"$PY" tools/devclient.py "${DEPLOY_ARGS[@]}"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$PY" curl unzip sha256sum
BETA=0
for arg in "$@"; do
case "$arg" in
--beta) BETA=1 ;;
*) die "unknown argument: $arg" ;;
esac
done
API_URL="https://api.github.com/repos/exteraSquad/plugins-pysdk-builds/releases?per_page=20"
RELEASE_JSON="$(mktemp)"
trap 'rm -rf "$RELEASE_JSON" "$WORKDIR"' EXIT
curl -fsSL "$API_URL" -o "$RELEASE_JSON" || die "failed to query GitHub releases API"
read -r TAG ASSET_URL SHA_URL < <("$PY" - "$RELEASE_JSON" "$BETA" <<'PYEOF'
import json, sys
path, allow_beta = sys.argv[1], sys.argv[2] == "1"
with open(path, encoding="utf-8") as f:
releases = json.load(f)
for rel in releases:
if rel.get("prerelease") and not allow_beta:
continue
assets = {a["name"]: a["browser_download_url"] for a in rel.get("assets", [])}
if "stubs.zip" in assets:
sha_url = assets.get("stubs.zip.sha256", "")
print(rel["tag_name"], assets["stubs.zip"], sha_url)
break
else:
sys.exit("no release with a stubs.zip asset found")
PYEOF
) || die "no release with a stubs.zip asset found"
[[ -n "$TAG" ]] || die "could not determine a release to install"
WORKDIR="$(mktemp -d)"
curl -fsSL "$ASSET_URL" -o "$WORKDIR/stubs.zip" || die "failed to download stubs.zip"
if [[ -n "$SHA_URL" ]]; then
curl -fsSL "$SHA_URL" -o "$WORKDIR/stubs.zip.sha256" || die "failed to download stubs.zip.sha256"
(cd "$WORKDIR" && sha256sum -c stubs.zip.sha256) || die "checksum verification failed for stubs.zip"
else
echo "warning: no stubs.zip.sha256 asset published for $TAG; skipping checksum verification" >&2
fi
rm -rf stubs
mkdir -p stubs
unzip -q "$WORKDIR/stubs.zip" -d "$WORKDIR/outer"
INNER="$WORKDIR/outer/pyi-files.zip"
[[ -f "$INNER" ]] || die "expected pyi-files.zip inside stubs.zip, not found"
unzip -q "$INNER" -d stubs
echo "installed SDK stubs from release $TAG into stubs/"
+26
View File
@@ -0,0 +1,26 @@
# Shared helpers for scripts/*.sh. Source, do not execute:
# source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG=com.exteragram.messenger
DEV_PORT=42690
DEBUG_PORT=5678
PY="${PYTHON:-python3}"
ADB="${ADB:-adb}"
PLUGIN_ID="$("$PY" -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" "$REPO_ROOT/plugin.json")"
die() {
echo "error: $*" >&2
exit 1
}
need_cmd() {
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || die "required command not found on PATH: $cmd"
done
}
app_pid() {
"$ADB" shell pidof -s "$PKG" | tr -d '\r'
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$ADB"
CLEAR=0
STDIO=0
PATTERN=""
while [[ $# -gt 0 ]]; do
case "$1" in
-c) CLEAR=1; shift ;;
-t) STDIO=1; shift ;;
-g) PATTERN="${2:-}"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
if [[ "$CLEAR" -eq 1 ]]; then
"$ADB" logcat -c
fi
run_logcat() {
if [[ "$STDIO" -eq 1 ]]; then
"$ADB" logcat -s python.stdout:'*' python.stderr:'*'
else
local pid
pid="$(app_pid)"
[[ -n "$pid" ]] || die "exteraGram is not running — open the app first"
"$ADB" logcat --pid="$pid"
fi
}
if [[ -n "$PATTERN" ]]; then
run_logcat | grep --line-buffered -E "$PATTERN"
else
run_logcat
fi
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
source scripts/lib.sh
need_cmd "$PY"
checksum() {
find src plugin.json -type f -printf '%T@ %p\n' 2>/dev/null | sha256sum
}
run_deploy() {
if ! scripts/deploy.sh "$@"; then
echo "deploy failed, still watching..." >&2
fi
}
echo "watching src/ and plugin.json for changes..."
run_deploy "$@"
if command -v inotifywait >/dev/null 2>&1; then
while true; do
inotifywait -qq -r -e close_write,create,delete,move src plugin.json 2>/dev/null || true
sleep 0.3
run_deploy "$@"
done
else
echo "inotifywait not found; falling back to polling" >&2
last="$(checksum)"
while true; do
sleep 1
current="$(checksum)"
if [[ "$current" != "$last" ]]; then
last="$current"
sleep 0.3
run_deploy "$@"
fi
done
fi
+1
View File
@@ -0,0 +1 @@
from .template import render, TemplateError
+13
View File
@@ -0,0 +1,13 @@
from textutil import normalize
class TemplateError(ValueError):
pass
def render(template: str, name: str) -> str:
normalized = normalize(name)
try:
return template.format(name=normalized)
except (KeyError, IndexError, ValueError):
raise TemplateError(f"unsupported placeholder in {template!r}")
+50
View File
@@ -0,0 +1,50 @@
from base_plugin import BasePlugin, HookResult, HookStrategy
from android_utils import log
from ui.settings import Header, Input, Text
from greeting import render, TemplateError
DEFAULT_TEMPLATE = "Hello, {name}!"
class HelloPlugin(BasePlugin):
def on_plugin_load(self):
log("hello_bundle loaded")
self.add_on_send_message_hook()
def on_plugin_unload(self):
log("hello_bundle unloaded")
def create_settings(self):
return [
Header(text="Hello Bundle"),
Input(
key="template",
text="Greeting template",
default=DEFAULT_TEMPLATE,
subtext="Use {name} as placeholder.",
icon="msg_edit",
),
Text(
text="Example",
subtext=".hello alice smith -> Hello, Alice Smith!",
icon="msg_info",
),
]
def on_send_message_hook(self, account, params):
message = getattr(params, "message", None)
if not isinstance(message, str):
return HookResult()
stripped = message.strip()
if not stripped.startswith(".hello"):
return HookResult()
parts = stripped.split(" ", 1)
name = parts[1] if len(parts) > 1 else ""
if not name.strip():
params.message = "Usage: .hello <name>"
else:
try:
params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
except TemplateError as exc:
params.message = "Bad template: " + str(exc)
return HookResult(strategy=HookStrategy.MODIFY, params=params)
+2
View File
@@ -0,0 +1,2 @@
def normalize(name: str) -> str:
return " ".join(w[:1].upper() + w[1:] for w in name.split())
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env python3
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
"""
from __future__ import annotations
import argparse
import ast
import base64
import json
import re
import sys
import zlib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = REPO_ROOT / "src"
PLUGIN_JSON = REPO_ROOT / "plugin.json"
RECOGNIZED_KEYS = {
"id",
"name",
"description",
"author",
"version",
"icon",
"app_version",
"sdk_version",
"requirements",
}
REQUIRED_KEYS = {"id", "name"}
ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{1,31}$")
KEY_TO_DUNDER = {
"id": "__id__",
"name": "__name__",
"description": "__description__",
"author": "__author__",
"version": "__version__",
"icon": "__icon__",
"app_version": "__app_version__",
"sdk_version": "__sdk_version__",
"requirements": "__requirements__",
}
METADATA_DUNDERS = set(KEY_TO_DUNDER.values()) | {"__min_version__"}
RUNTIME_PREAMBLE = '''\
import base64 as _b64, builtins as _bi, json as _j, linecache as _lc, types as _t, zlib as _z
_BUNDLE = _j.loads(_z.decompress(_b64.b64decode(_BUNDLE_BLOB)).decode("utf-8"))
_MODS = _BUNDLE["modules"]
_LOADED = {}
_ROOTS = {n.partition(".")[0] for n in _MODS}
_REAL_IMPORT = _bi.__import__
def _bundle_register_source(info):
lines = info["src"].splitlines(True)
_lc.cache[info["file"]] = (len(info["src"]), None, lines, info["file"])
def _bundle_load(name):
mod = _LOADED.get(name)
if mod is not None:
return mod
info = _MODS.get(name)
if info is None:
raise ImportError("no bundled module named %r" % name)
parent = name.rpartition(".")[0]
if parent:
_bundle_load(parent)
mod = _t.ModuleType(name)
mod.__file__ = info["file"]
mod.__package__ = name if info["pkg"] else parent
if info["pkg"]:
mod.__path__ = []
builtins_ns = dict(vars(_bi))
builtins_ns["__import__"] = _bundle_import
mod.__dict__["__builtins__"] = builtins_ns
_LOADED[name] = mod
_bundle_register_source(info)
try:
exec(compile(info["src"], info["file"], "exec"), mod.__dict__)
except BaseException:
_LOADED.pop(name, None)
raise
if parent:
setattr(_LOADED[parent], name.rpartition(".")[2], mod)
return mod
def _bundle_import(name, globals=None, locals=None, fromlist=(), level=0):
if level:
pkg = (globals or {}).get("__package__") or ""
parts = pkg.split(".") if pkg else []
if level > 1:
parts = parts[: 1 - level]
if not parts:
return _REAL_IMPORT(name, globals, locals, fromlist, level)
full = ".".join(parts) + ("." + name if name else "")
else:
full = name
if full.partition(".")[0] not in _ROOTS or full not in _MODS:
return _REAL_IMPORT(name, globals, locals, fromlist, level)
mod = _bundle_load(full)
if not fromlist:
return mod if level else _bundle_load(full.partition(".")[0])
for item in fromlist:
if item != "*" and not hasattr(mod, item) and full + "." + item in _MODS:
_bundle_load(full + "." + item)
return mod
'''
ENTRY_EXECUTION = '''\
_ENTRY = _MODS[_BUNDLE["entry"]]
_bundle_register_source(_ENTRY)
_bundle_builtins = dict(vars(_bi))
_bundle_builtins["__import__"] = _bundle_import
globals()["__builtins__"] = _bundle_builtins
exec(compile(_ENTRY["src"], _ENTRY["file"], "exec"), globals())
'''
class ValidationError(Exception):
pass
def load_metadata() -> dict:
if not PLUGIN_JSON.is_file():
raise ValidationError(f"plugin.json not found at {PLUGIN_JSON}")
try:
text = PLUGIN_JSON.read_text(encoding="utf-8")
except OSError as exc:
raise ValidationError(f"cannot read plugin.json: {exc}")
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise ValidationError(f"plugin.json is not valid JSON: {exc}")
if not isinstance(data, dict):
raise ValidationError("plugin.json must be a JSON object")
unknown = set(data) - RECOGNIZED_KEYS
if unknown:
raise ValidationError(
"plugin.json has unrecognized keys: " + ", ".join(sorted(unknown))
)
missing = REQUIRED_KEYS - set(data)
if missing:
raise ValidationError(
"plugin.json is missing required keys: " + ", ".join(sorted(missing))
)
plugin_id = data["id"]
if not isinstance(plugin_id, str) or not ID_RE.match(plugin_id):
raise ValidationError(
f"plugin.json id {plugin_id!r} is invalid: must be 2-32 chars, start with a "
"letter, and contain only A-Za-z0-9_-"
)
if not isinstance(data["name"], str) or not data["name"]:
raise ValidationError("plugin.json name must be a non-empty string")
return data
def collect_modules() -> dict:
if not SRC_DIR.is_dir():
raise ValidationError(f"src/ directory not found at {SRC_DIR}")
modules: dict = {}
for path in sorted(SRC_DIR.rglob("*")):
if path.is_dir():
continue
rel = path.relative_to(SRC_DIR)
if any(part.startswith(".") for part in rel.parts):
continue
if "__pycache__" in rel.parts:
continue
if path.suffix == ".pyc":
continue
if path.suffix != ".py":
print(
f"warning: skipping non-Python file {path.relative_to(REPO_ROOT)} "
"(single-file plugins cannot carry assets)",
file=sys.stderr,
)
continue
is_pkg = rel.name == "__init__.py"
dotted_parts = rel.parts[:-1] if is_pkg else rel.parts[:-1] + (rel.stem,)
name = ".".join(dotted_parts)
if not name:
raise ValidationError(f"cannot derive a module name for {path}")
try:
src = path.read_text(encoding="utf-8")
except OSError as exc:
raise ValidationError(f"cannot read {path}: {exc}")
modules[name] = {"src": src, "path": path, "pkg": is_pkg}
return modules
def validate_entry(modules: dict) -> None:
main_path = SRC_DIR / "main.py"
if not main_path.is_file():
raise ValidationError("src/main.py does not exist")
entry = modules.get("main")
if entry is None:
raise ValidationError("src/main.py could not be collected as the 'main' module")
try:
tree = ast.parse(entry["src"], filename=str(main_path))
except SyntaxError as exc:
raise ValidationError(f"src/main.py has a syntax error: {exc}")
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.level > 0:
raise ValidationError(
"src/main.py must use absolute imports only (found a relative import); "
"it is executed at plugin-module top level"
)
if isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
for target in targets:
if isinstance(target, ast.Name) and target.id in METADATA_DUNDERS:
raise ValidationError(
f"src/main.py must not assign metadata dunder {target.id!r} "
"(metadata belongs to plugin.json only)"
)
def _base_names(class_node: ast.ClassDef) -> set:
names = set()
for base in class_node.bases:
if isinstance(base, ast.Name):
names.add(base.id)
elif isinstance(base, ast.Attribute):
names.add(base.attr)
return names
plugin_classes = [
node.name
for node in tree.body
if isinstance(node, ast.ClassDef) and "BasePlugin" in _base_names(node)
]
if len(plugin_classes) != 1:
raise ValidationError(
"src/main.py must contain exactly one class inheriting BasePlugin, found "
f"{len(plugin_classes)}"
)
def validate_modules_syntax(modules: dict) -> None:
for name, info in modules.items():
try:
ast.parse(info["src"], filename=str(info["path"]))
except SyntaxError as exc:
raise ValidationError(f"{info['path']} has a syntax error: {exc}")
def format_metadata_constants(data: dict) -> str:
lines = []
for key, dunder in KEY_TO_DUNDER.items():
if key not in data:
continue
value = data[key]
if key in ("description", "icon") and value == "":
continue
if key == "requirements" and value == []:
continue
lines.append(f"{dunder} = {value!r}")
return "\n".join(lines)
def build_payload(modules: dict, release: bool) -> dict:
mod_dict = {}
for name, info in modules.items():
if release:
file_value = str(info["path"].relative_to(REPO_ROOT))
else:
file_value = str(info["path"].resolve())
mod_dict[name] = {"src": info["src"], "file": file_value, "pkg": info["pkg"]}
return {"modules": mod_dict, "entry": "main"}
def encode_payload(payload: dict) -> str:
data = json.dumps(payload, sort_keys=True).encode("utf-8")
compressed = zlib.compress(data, 9)
return base64.b64encode(compressed).decode("ascii")
def format_blob_literal(blob: str, width: int = 96) -> str:
if not blob:
return '_BUNDLE_BLOB = ""'
lines = [blob[i : i + width] for i in range(0, len(blob), width)]
body = "\n".join(f' "{line}"' for line in lines)
return "_BUNDLE_BLOB = (\n" + body + "\n)"
def assemble_bundle(data: dict, modules: dict, release: bool) -> str:
header = "# Generated by tools/bundle.py from plugin.json + src/ — do not edit; edit the sources.\n"
metadata = format_metadata_constants(data)
payload = build_payload(modules, release)
blob_literal = format_blob_literal(encode_payload(payload))
text = "\n".join([header, metadata, "", blob_literal, "", RUNTIME_PREAMBLE, ENTRY_EXECUTION])
if not text.endswith("\n"):
text += "\n"
return text
def parse_args(argv):
parser = argparse.ArgumentParser(
description="Bundle a multi-file exteraGram plugin into a single .plugin file."
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument(
"--dev", action="store_true", help="embed absolute local paths for debugging (default)"
)
mode.add_argument(
"--release", action="store_true", help="embed repo-relative paths for distribution"
)
parser.add_argument("--out", default="dist", help="output directory (default: dist)")
parser.add_argument("--check", action="store_true", help="validate only; do not write output")
return parser.parse_args(argv)
def main(argv=None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv)
release = bool(args.release)
try:
data = load_metadata()
modules = collect_modules()
validate_entry(modules)
validate_modules_syntax(modules)
except ValidationError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if args.check:
print("ok")
return 0
text = assemble_bundle(data, modules, release)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"{data['id']}.plugin"
out_path.write_text(text, encoding="utf-8")
print(f"wrote {out_path} ({out_path.stat().st_size} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Client for the exteraGram on-device plugin dev server.
Speaks the wire protocol at 127.0.0.1:42690 (after `adb forward tcp:42690 tcp:42690`):
consecutive UTF-8 JSON objects, no length framing. Requests are
{"@": <command>, "#": <id>, ...}; responses echo "#".
"""
from __future__ import annotations
import argparse
import json
import socket
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_JSON = REPO_ROOT / "plugin.json"
CONNECT_ATTEMPTS = 5
CONNECT_RETRY_DELAY = 1.0
SOCKET_TIMEOUT = 1.0
RESPONSE_TIMEOUT = 30.0
UNREACHABLE_MESSAGE = (
"dev server unreachable — run scripts/adb-connect.sh, and enable exteraGram "
"Preferences > Plugins > plugin engine + developer mode"
)
def default_plugin_id() -> str | None:
try:
data = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data.get("id")
class DevClient:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
self._sock: socket.socket | None = None
self._buf = b""
self._decoder = json.JSONDecoder()
self._next_id = 1
def connect(self) -> None:
last_exc = None
for attempt in range(CONNECT_ATTEMPTS):
try:
sock = socket.create_connection((self.host, self.port), timeout=SOCKET_TIMEOUT)
sock.settimeout(SOCKET_TIMEOUT)
self._sock = sock
return
except OSError as exc:
last_exc = exc
if attempt < CONNECT_ATTEMPTS - 1:
time.sleep(CONNECT_RETRY_DELAY)
print(UNREACHABLE_MESSAGE, file=sys.stderr)
raise SystemExit(1) from last_exc
def close(self) -> None:
if self._sock is not None:
self._sock.close()
self._sock = None
def _read_one(self, deadline: float) -> dict:
while True:
self._buf = self._buf.lstrip()
if self._buf:
try:
text = self._buf.decode("utf-8")
except UnicodeDecodeError:
text = None
if text:
try:
obj, idx = self._decoder.raw_decode(text)
consumed = len(text[:idx].encode("utf-8"))
self._buf = self._buf[consumed:]
return obj
except json.JSONDecodeError:
pass
if time.monotonic() >= deadline:
print("error: timed out waiting for dev server response", file=sys.stderr)
raise SystemExit(1)
try:
chunk = self._sock.recv(4096)
except socket.timeout:
continue
except OSError as exc:
print(f"error: connection lost: {exc}", file=sys.stderr)
raise SystemExit(1)
if not chunk:
print("error: dev server closed the connection", file=sys.stderr)
raise SystemExit(1)
self._buf += chunk
def request(self, command: str, **fields) -> dict:
req_id = self._next_id
self._next_id += 1
payload = {"@": command, "#": req_id, **fields}
self._sock.sendall(json.dumps(payload).encode("utf-8"))
deadline = time.monotonic() + RESPONSE_TIMEOUT
while True:
resp = self._read_one(deadline)
if resp.get("#") == req_id:
if resp.get("error") or resp.get("errors"):
print(json.dumps(resp, indent=2), file=sys.stderr)
raise SystemExit(1)
return resp
# response to a different (stale) request id: keep draining
def cmd_ping(client: DevClient, args) -> int:
resp = client.request("ping")
print(json.dumps(resp, indent=2))
return 0
def cmd_plugins(client: DevClient, args) -> int:
resp = client.request("get_plugins")
print(json.dumps(resp, indent=2))
return 0
def cmd_deploy(client: DevClient, args) -> int:
plugin_id = args.id or default_plugin_id()
if not plugin_id:
print("error: could not determine plugin id (pass --id or fix plugin.json)", file=sys.stderr)
return 1
try:
content = Path(args.file).read_text(encoding="utf-8")
except OSError as exc:
print(f"error: cannot read {args.file}: {exc}", file=sys.stderr)
return 1
client.request("write_plugin", plugin_id=plugin_id, content=content)
time.sleep(0.3)
if args.enable:
client.request("enable_plugin", plugin_id=plugin_id)
if not args.no_reload:
client.request("reload_plugin", plugin_id=plugin_id)
print(f"deployed {args.file} as {plugin_id}")
return 0
def cmd_simple_id(command: str):
def handler(client: DevClient, args) -> int:
client.request(command, plugin_id=args.id)
print(f"{command} {args.id}: ok")
return 0
return handler
def cmd_debugger(client: DevClient, args) -> int:
if args.action == "start":
client.request(
"start_debugger",
host=args.debug_host,
port=args.debug_port,
platform=args.platform,
)
print(f"debugger start requested ({args.platform} -> {args.debug_host}:{args.debug_port})")
else:
client.request("stop_debugger", platform=args.platform)
print(f"debugger stop requested ({args.platform})")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="exteraGram plugin dev server client")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=42690)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("ping").set_defaults(func=cmd_ping)
sub.add_parser("plugins").set_defaults(func=cmd_plugins)
p_deploy = sub.add_parser("deploy")
p_deploy.add_argument("file")
p_deploy.add_argument("--id")
p_deploy.add_argument("--enable", action="store_true")
p_deploy.add_argument("--no-reload", action="store_true")
p_deploy.set_defaults(func=cmd_deploy)
for name, command in (
("enable", "enable_plugin"),
("disable", "disable_plugin"),
("reload", "reload_plugin"),
("remove", "remove_plugin"),
):
p = sub.add_parser(name)
p.add_argument("id")
p.set_defaults(func=cmd_simple_id(command))
p_debugger = sub.add_parser("debugger")
p_debugger.add_argument("action", choices=["start", "stop"])
p_debugger.add_argument("--platform", choices=["vscode", "pycharm"], default="vscode")
p_debugger.add_argument("--debug-host", default="127.0.0.1")
p_debugger.add_argument("--debug-port", type=int, default=5678)
p_debugger.set_defaults(func=cmd_debugger)
return parser
def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
client = DevClient(args.host, args.port)
client.connect()
try:
return args.func(client, args)
finally:
client.close()
if __name__ == "__main__":
sys.exit(main())
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Offline verification harness for a built .plugin bundle. Runs without a phone.
Usage: python3 tools/verify_bundle.py <bundle.plugin>
"""
from __future__ import annotations
import ast
import json
import sys
import traceback
import types
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_JSON = REPO_ROOT / "plugin.json"
_FAILURES = []
def check(name: str, condition: bool, detail: str = "") -> None:
if condition:
print(f"PASS {name}")
else:
message = f"FAIL {name}"
if detail:
message += f": {detail}"
print(message, file=sys.stderr)
_FAILURES.append(name)
class HookStrategy(Enum):
DEFAULT = auto()
MODIFY = auto()
CANCEL = auto()
MODIFY_FINAL = auto()
@dataclass
class HookResult:
strategy: HookStrategy = HookStrategy.DEFAULT
params: object = None
LOG_LINES: list[str] = []
class BasePlugin:
def __init__(self):
self._settings = {}
def get_setting(self, key, default=None):
return self._settings.get(key, default)
def set_setting(self, key, value):
self._settings[key] = value
def add_on_send_message_hook(self):
pass
def on_plugin_load(self):
pass
def on_plugin_unload(self):
pass
def create_settings(self):
return []
def fake_log(*args, **kwargs):
LOG_LINES.append(" ".join(str(a) for a in args))
class _SettingsRow:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def install_fakes() -> None:
base_plugin_mod = types.ModuleType("base_plugin")
base_plugin_mod.BasePlugin = BasePlugin
base_plugin_mod.HookResult = HookResult
base_plugin_mod.HookStrategy = HookStrategy
sys.modules["base_plugin"] = base_plugin_mod
android_utils_mod = types.ModuleType("android_utils")
android_utils_mod.log = fake_log
sys.modules["android_utils"] = android_utils_mod
ui_mod = types.ModuleType("ui")
ui_mod.__path__ = []
sys.modules["ui"] = ui_mod
ui_settings_mod = types.ModuleType("ui.settings")
for cls_name in ("Header", "Input", "Text", "Switch"):
setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
sys.modules["ui.settings"] = ui_settings_mod
ui_mod.settings = ui_settings_mod
class SimpleParams:
def __init__(self, message):
self.message = message
KEY_TO_DUNDER = {
"id": "__id__",
"name": "__name__",
"description": "__description__",
"author": "__author__",
"version": "__version__",
"icon": "__icon__",
"app_version": "__app_version__",
"sdk_version": "__sdk_version__",
"requirements": "__requirements__",
}
def expected_dunders(meta: dict) -> dict:
result = {}
for key, dunder in KEY_TO_DUNDER.items():
if key not in meta:
continue
value = meta[key]
if key in ("description", "icon") and value == "":
continue
if key == "requirements" and value == []:
continue
result[dunder] = value
return result
def main(argv=None) -> int:
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
print("usage: tools/verify_bundle.py <bundle.plugin>", file=sys.stderr)
return 1
bundle_path = Path(argv[0])
if not bundle_path.is_file():
print(f"error: bundle not found at {bundle_path}", file=sys.stderr)
return 1
install_fakes()
source = bundle_path.read_text(encoding="utf-8")
modules_before = set(sys.modules)
ns = {"__name__": "hello_bundle"}
exec(compile(source, str(bundle_path), "exec"), ns)
new_modules = set(sys.modules) - modules_before
meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
expected = expected_dunders(meta)
mismatches = [
f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
for dunder, value in expected.items()
if ns.get(dunder) != value
]
check("metadata constants match plugin.json", not mismatches, "; ".join(mismatches))
tree = ast.parse(source, filename=str(bundle_path))
top_level_assigned = {
target.id
for node in tree.body
if isinstance(node, ast.Assign)
for target in node.targets
if isinstance(target, ast.Name)
}
check(
"metadata exposed as top-level literal assignments (AST)",
set(expected) <= top_level_assigned,
f"missing: {set(expected) - top_level_assigned}",
)
plugin_classes = [
value
for value in ns.values()
if isinstance(value, type) and issubclass(value, BasePlugin) and value is not BasePlugin
]
check(
"exactly one BasePlugin subclass defined",
len(plugin_classes) == 1,
f"found {len(plugin_classes)}",
)
plugin_cls = plugin_classes[0] if len(plugin_classes) == 1 else None
if plugin_cls is not None:
check(
"plugin class __module__ matches executed namespace",
plugin_cls.__module__ == ns["__name__"],
f"__module__={plugin_cls.__module__!r} != {ns['__name__']!r}",
)
plugin = plugin_cls()
LOG_LINES.clear()
plugin.on_plugin_load()
check(
"on_plugin_load emits load log line",
any("hello_bundle loaded" in line for line in LOG_LINES),
f"log lines: {LOG_LINES}",
)
params = SimpleParams(".hello alice smith")
result = plugin.on_send_message_hook(account=0, params=params)
check(
"'.hello alice smith' renders greeting",
params.message == "Hello, Alice Smith!",
f"got {params.message!r}",
)
check(
"on_send_message_hook returns MODIFY strategy",
result.strategy == HookStrategy.MODIFY,
f"got {result.strategy}",
)
plugin.set_setting("template", "{nope}")
params2 = SimpleParams(".hello someone")
plugin.on_send_message_hook(account=0, params=params2)
check(
"bad template produces 'Bad template:' message",
params2.message.startswith("Bad template:"),
f"got {params2.message!r}",
)
# main.py imports `render`/`TemplateError` by name, so they live directly
# in the executed namespace — call render() directly to inspect the raised
# traceback (proves linecache registration for bundled, non-file modules).
render = ns.get("render")
template_error = ns.get("TemplateError")
traceback_text = ""
if render is not None and template_error is not None:
try:
render("{nope}", "x")
except template_error:
traceback_text = traceback.format_exc()
check(
"render() traceback names src/greeting/template.py",
"src/greeting/template.py" in traceback_text,
f"traceback:\n{traceback_text}",
)
check(
"traceback includes the raising source line text",
"unsupported placeholder" in traceback_text,
f"traceback:\n{traceback_text}",
)
check(
"sys.modules gained no entry for bundled module names",
not ({"greeting", "textutil", "main"} & new_modules),
f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
)
if _FAILURES:
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)
return 1
print("\nall checks passed")
return 0
if __name__ == "__main__":
sys.exit(main())