generated from exteraGramPlugins/pluginBase
Client Commands: telegram-panel slash commands with Discord-style parameters
Lists the panel account's client commands as Telegram slash commands (hourly refresh, cached on the device), shows their parameters while typing, runs complete commands on the panel instead of sending them and shows the answer as an "Only visible to you" message (latest 20 kept in memory). Holding send offers Run / Send as message / Send response in chat (red ! badge); an answer's menu starts with Send in chat. Other plugins register their own commands through the client_commands library module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84bdc44a76
commit
46a1f6ee1f
29 files changed
+3293
-220
No files matched your search
+312
-138
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline verification harness for a built .plugin bundle. Runs without a phone.
|
||||
|
||||
Executes the bundle against faked SDK modules and checks what can be checked
|
||||
off the device: metadata, the argument parser (in step with telegram-panel's),
|
||||
HTML responses, the library API other plugins use, the command catalog
|
||||
against a fake panel, and plugin commands run by the runner.
|
||||
|
||||
Usage: python3 tools/verify_bundle.py <bundle.plugin>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -8,7 +13,7 @@ from __future__ import annotations
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
import tempfile
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
@@ -31,6 +36,14 @@ def check(name: str, condition: bool, detail: str = "") -> None:
|
||||
_FAILURES.append(name)
|
||||
|
||||
|
||||
def raises(fn, error_type, text=None):
|
||||
try:
|
||||
fn()
|
||||
except error_type as exc:
|
||||
return text is None or text in str(exc)
|
||||
return False
|
||||
|
||||
|
||||
class HookStrategy(Enum):
|
||||
DEFAULT = auto()
|
||||
MODIFY = auto()
|
||||
@@ -44,7 +57,8 @@ class HookResult:
|
||||
params: object = None
|
||||
|
||||
|
||||
LOG_LINES: list[str] = []
|
||||
class MethodHook:
|
||||
pass
|
||||
|
||||
|
||||
class BasePlugin:
|
||||
@@ -54,56 +68,85 @@ class BasePlugin:
|
||||
def get_setting(self, key, default=None):
|
||||
return self._settings.get(key, default)
|
||||
|
||||
def set_setting(self, key, value):
|
||||
def set_setting(self, key, value, reload_settings=False):
|
||||
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)
|
||||
|
||||
|
||||
class _UserConfig:
|
||||
"""org.telegram.messenger.UserConfig: account slot n is Telegram user 1000 + n."""
|
||||
|
||||
def __init__(self, slot):
|
||||
self.slot = slot
|
||||
|
||||
@classmethod
|
||||
def getInstance(cls, slot):
|
||||
return cls(slot)
|
||||
|
||||
def getClientUserId(self):
|
||||
return 1000 + self.slot
|
||||
|
||||
|
||||
FILES_DIR = tempfile.mkdtemp(prefix="cc-verify-")
|
||||
|
||||
|
||||
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
|
||||
def module(name, **attrs):
|
||||
mod = types.ModuleType(name)
|
||||
mod.__dict__.update(attrs)
|
||||
sys.modules[name] = mod
|
||||
return 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
|
||||
module(
|
||||
"base_plugin",
|
||||
BasePlugin=BasePlugin,
|
||||
HookResult=HookResult,
|
||||
HookStrategy=HookStrategy,
|
||||
MethodHook=MethodHook,
|
||||
)
|
||||
module("android_utils", log=lambda *a, **k: None, run_on_ui_thread=lambda fn, delay=0: fn())
|
||||
module("file_utils", get_files_dir=lambda: FILES_DIR)
|
||||
ui = module("ui")
|
||||
ui.__path__ = []
|
||||
ui.settings = module(
|
||||
"ui.settings",
|
||||
**{n: type(n, (_SettingsRow,), {}) for n in ("Header", "Input", "Text", "Switch", "Divider")},
|
||||
)
|
||||
for name in ("org", "org.telegram"):
|
||||
module(name).__path__ = []
|
||||
module("org.telegram.messenger", UserConfig=_UserConfig)
|
||||
|
||||
|
||||
class SimpleParams:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
class FakeApi:
|
||||
"""The panel, as api.Api's methods answer."""
|
||||
|
||||
def __init__(self, tokens):
|
||||
self._tokens = tokens # token -> (tg id, [command json]) or an ApiError
|
||||
self.configured = True
|
||||
|
||||
def tokens(self):
|
||||
return list(self._tokens)
|
||||
|
||||
def _of(self, token):
|
||||
found = self._tokens[token]
|
||||
if isinstance(found, Exception):
|
||||
raise found
|
||||
return found
|
||||
|
||||
def me(self, token):
|
||||
tg_id, _ = self._of(token)
|
||||
return {"tg_id": tg_id, "account_name": f"acc{tg_id}", "running": True}
|
||||
|
||||
def commands(self, token):
|
||||
_, commands = self._of(token)
|
||||
return {"commands": commands, "refresh_seconds": 3600}
|
||||
|
||||
|
||||
KEY_TO_DUNDER = {
|
||||
@@ -133,6 +176,223 @@ def expected_dunders(meta: dict) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def check_metadata(source, ns, bundle_path):
|
||||
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))
|
||||
assigned = {
|
||||
t.id for node in tree.body if isinstance(node, ast.Assign) for t in node.targets if isinstance(t, ast.Name)
|
||||
}
|
||||
check("metadata exposed as top-level literal assignments (AST)", set(expected) <= assigned)
|
||||
plugins = [v for v in ns.values() if isinstance(v, type) and issubclass(v, BasePlugin) and v is not BasePlugin]
|
||||
check("exactly one BasePlugin subclass defined", len(plugins) == 1, f"found {len(plugins)}")
|
||||
return plugins[0] if len(plugins) == 1 else None
|
||||
|
||||
|
||||
def check_parser(load):
|
||||
model, parse = load("commands.model"), load("commands.parse")
|
||||
Param, Command = model.Param, model.Command
|
||||
ban = Command(
|
||||
"ban",
|
||||
[
|
||||
Param("days", "integer"),
|
||||
Param("mode", "choice", False, default="soft", choices=("soft", "hard")),
|
||||
Param("silent", "boolean", False, default=False),
|
||||
Param("reason", "string", False),
|
||||
],
|
||||
"Bans the replied user.",
|
||||
aliases=["b"],
|
||||
reply="required",
|
||||
)
|
||||
# The same cases as telegram-panel's tests/test_client_commands.py.
|
||||
check("split /Ban@me", parse.split_command("/Ban@me 3") == ("ban", "3"))
|
||||
check("no command mid-text", parse.split_command("hello /ban") is None)
|
||||
check(
|
||||
"positional, choice, boolean and the greedy last string",
|
||||
parse.parse(ban, "/b 3 HARD yes for spamming links")
|
||||
== {"days": 3, "mode": "hard", "silent": True, "reason": "for spamming links"},
|
||||
)
|
||||
check(
|
||||
"named and quoted",
|
||||
parse.parse(ban, '/ban reason:"too loud" days:7') == {"days": 7, "mode": "soft", "silent": False, "reason": "too loud"},
|
||||
)
|
||||
check("an empty name: is not given", parse.parse(ban, "/ban 1 mode:")["mode"] == "soft")
|
||||
for text, error in (
|
||||
("/ban", "Missing required parameter: days"),
|
||||
("/ban x", "days must be a whole number"),
|
||||
("/ban 1 medium", "mode must be one of: soft, hard"),
|
||||
("/ban 1 soft maybe", "silent must be yes or no"),
|
||||
('/ban 1 "open', "Unclosed quote"),
|
||||
("/ban days:1 days:2", "days is given twice"),
|
||||
):
|
||||
check(f"error for {text!r}", raises(lambda: parse.parse(ban, text), parse.CommandError, error))
|
||||
add = Command("add", [Param("a", "integer"), Param("b", "integer")])
|
||||
check("extra words without a string to take them", raises(lambda: parse.parse(add, "/add 1 2 3"), parse.CommandError, "Unexpected arguments: 3"))
|
||||
|
||||
check("bare /prefix is a name being typed", parse.command_name_at("/ba") == "ba" and parse.command_name_at("/ba ") is None)
|
||||
typing = parse.analyze(ban, "/ban 3 ", None, has_reply=True)
|
||||
check("analyze: next free parameter is current", typing.current == "mode" and typing.complete, typing.current)
|
||||
named = parse.analyze(ban, "/ban reason:so", None, has_reply=True)
|
||||
check("analyze: a named parameter at the cursor", named.current == "reason" and named.missing == ["days"])
|
||||
check("analyze: missing days blocks running", not named.complete and "days" in named.error)
|
||||
no_reply = parse.analyze(ban, "/ban 3", None, has_reply=False)
|
||||
check("analyze: a required reply blocks running", no_reply.error == "Reply to a message to use /ban")
|
||||
middle = parse.analyze(ban, "/ban 3 hard", 6, has_reply=True)
|
||||
check("analyze: the cursor inside a word", middle.current == "days", middle.current)
|
||||
check("usage and hint", ban.usage() == "/ban <days> [mode] [silent] [reason]" and ban.hint().startswith("<days> [mode]"))
|
||||
back = Command.from_json(ban.to_json())
|
||||
check("command JSON round trip", back.to_json() == ban.to_json() and back.names == ("ban", "b"))
|
||||
check("bad names are refused", raises(lambda: Command("no spaces"), ValueError))
|
||||
|
||||
|
||||
def check_markup(load):
|
||||
markup = load("commands.markup")
|
||||
text, entities = markup.parse('😀 <b>bold</b> <a href="https://x.y">link</a><br><tg-spoiler>s</tg-spoiler>')
|
||||
check("HTML text", text == "😀 bold link\ns", repr(text))
|
||||
check(
|
||||
"HTML entities in UTF-16 units",
|
||||
[(e["type"], e["offset"], e["length"]) for e in entities] == [("bold", 3, 4), ("text_link", 8, 4), ("spoiler", 13, 1)],
|
||||
repr(entities),
|
||||
)
|
||||
_, pre = markup.parse('<pre><code class="language-py">x = 1</code></pre>')
|
||||
check("pre with a language", pre == [{"type": "pre", "offset": 0, "length": 5, "language": "py"}], repr(pre))
|
||||
_, mention = markup.parse('<a href="tg://user?id=42">Ann</a>')
|
||||
check("tg://user links are mentions", mention[0]["type"] == "mention_name" and mention[0]["user_id"] == 42)
|
||||
check("escape", markup.escape("<a&b>") == "<a&b>")
|
||||
|
||||
|
||||
def check_library(load):
|
||||
import builtins
|
||||
|
||||
sys.modules.pop("client_commands", None)
|
||||
builtins.__dict__.pop("__client_commands_state__", None)
|
||||
queued = []
|
||||
early = types.ModuleType("client_commands")
|
||||
early.pending = [lambda api: queued.append(api.register("early", lambda ctx: "hi", owner="other"))]
|
||||
sys.modules["client_commands"] = early
|
||||
lib = load("library").Library()
|
||||
lib.install()
|
||||
api = sys.modules["client_commands"]
|
||||
check("library published as client_commands", api is early and api.available and hasattr(api, "register"))
|
||||
check("setups queued before loading ran", len(queued) == 1 and "pending" not in api.__dict__)
|
||||
api.register(
|
||||
"roll",
|
||||
lambda ctx, sides=6: f"🎲 <b>{sides}</b>",
|
||||
params=[api.Param("sides", "integer", required=False, default=6)],
|
||||
summary="Rolls a die",
|
||||
owner="dice",
|
||||
)
|
||||
names = [c["name"] for c in api.commands()]
|
||||
check("registered commands listed", names == ["early", "roll"], repr(names))
|
||||
lib.uninstall()
|
||||
sys.modules.pop("client_commands") # the engine drops modules a plugin added
|
||||
again = load("library").Library()
|
||||
again.install()
|
||||
check("registrations survive a reload", [c.name for c in again.local_commands()] == ["early", "roll"])
|
||||
api.register("late", lambda ctx: None, owner="other") # an api kept from before
|
||||
check("an old api registers into the same registry", "late" in [c.name for c in again.local_commands()])
|
||||
api = sys.modules["client_commands"]
|
||||
check("unregister_owner", api.unregister_owner("other") == 2 and not api.unregister("early"))
|
||||
return again
|
||||
|
||||
|
||||
def check_catalog(load, library):
|
||||
from_api = load("api")
|
||||
catalog_mod = load("catalog")
|
||||
getid = {
|
||||
"name": "getid",
|
||||
"summary": "Ids",
|
||||
"reply": "optional",
|
||||
"params": [{"name": "target", "type": "string", "required": False}],
|
||||
}
|
||||
roll_shadow = {"name": "roll", "summary": "Panel roll", "params": []}
|
||||
api = FakeApi(
|
||||
{
|
||||
"tok_a": (1000, [getid, roll_shadow]),
|
||||
"tok_b": (1001, [{"name": "ping"}]),
|
||||
"tok_bad": from_api.ApiError("403: Not a commands token", 403),
|
||||
}
|
||||
)
|
||||
plugin = BasePlugin()
|
||||
catalog = catalog_mod.Catalog(plugin, api, library)
|
||||
catalog._refresh_due()
|
||||
names0 = [c.name for c in catalog.commands_for(0)]
|
||||
check("account 0: panel commands, then plugins'", names0 == ["getid", "roll"], repr(names0))
|
||||
check("a panel command shadows a plugin's", catalog.find(0, "roll").summary == "Panel roll")
|
||||
names1 = [c.name for c in catalog.commands_for(1)]
|
||||
check("account 1 has its own panel commands", names1 == ["ping", "roll"], repr(names1))
|
||||
check("tokens picked by Telegram id", catalog.token_for(0) == "tok_a" and catalog.token_for(1) == "tok_b")
|
||||
check("a refused token is reported", "tok_bad" in catalog.errors())
|
||||
check("accounts without a token only get plugins'", [c.name for c in catalog.commands_for(5)] == ["roll"])
|
||||
cached = catalog_mod.Catalog(plugin, FakeApi({}), library)
|
||||
check("commands cached on disk", [c.name for c in cached.commands_for(0)] == ["getid", "roll"])
|
||||
plugin.set_setting("server_commands", False)
|
||||
check("panel commands can be turned off", [c.name for c in catalog.commands_for(0)] == ["roll"])
|
||||
plugin.set_setting("server_commands", True)
|
||||
api._tokens.pop("tok_b")
|
||||
api._tokens.pop("tok_bad")
|
||||
catalog._refresh_due()
|
||||
check("a removed token's commands go", [c.name for c in catalog.commands_for(1)] == ["roll"])
|
||||
|
||||
|
||||
def check_runner(load, library):
|
||||
runner_mod = load("runner")
|
||||
runner = runner_mod.Runner(BasePlugin(), None, None, library)
|
||||
roll = next(c for c in library.local_commands() if c.name == "roll")
|
||||
inv = types.SimpleNamespace(
|
||||
account=0, dialog_id=5, topic_id=0, reply=None, text="/roll 20", command=roll, send_to_chat=False
|
||||
)
|
||||
result = runner._local(inv)
|
||||
check(
|
||||
"plugin command response parsed",
|
||||
result["ok"] and result["text"] == "🎲 20" and result["entities"] == [{"type": "bold", "offset": 3, "length": 2}],
|
||||
repr(result),
|
||||
)
|
||||
api = sys.modules["client_commands"]
|
||||
|
||||
def strict(ctx, n):
|
||||
raise api.CommandError(f"no {n}")
|
||||
|
||||
api.register("strict", strict, params=[{"name": "n", "type": "integer", "required": True}], owner="t")
|
||||
inv.command = next(c for c in library.local_commands() if c.name == "strict")
|
||||
inv.text = "/strict 3"
|
||||
check("CommandError is a user-facing error", runner._local(inv) == {"ok": False, "error": "no 3"})
|
||||
inv.text = "/strict"
|
||||
check("bad arguments are a user-facing error", runner._local(inv)["error"] == "Missing required parameter: n")
|
||||
runner.bypass("/strict 1")
|
||||
check("bypassed text goes through once", runner.bypassed("/strict 1", consume=True) and not runner.bypassed("/strict 1"))
|
||||
|
||||
|
||||
def check_send_hook(plugin_cls):
|
||||
plugin = plugin_cls()
|
||||
plugin.runner = types.SimpleNamespace(bypassed=lambda text, consume=False: False)
|
||||
plugin.catalog = types.SimpleNamespace(find=lambda account, name: None)
|
||||
params = types.SimpleNamespace(message="hello", scheduleDate=0)
|
||||
check("plain messages pass the send hook", plugin.on_send_message_hook(0, params).strategy == HookStrategy.DEFAULT)
|
||||
params.message = "/unknown"
|
||||
check("unknown commands pass the send hook", plugin.on_send_message_hook(0, params).strategy == HookStrategy.DEFAULT)
|
||||
model = sys.modules.get("_cc_model")
|
||||
demo = model.Command("demo", [model.Param("count", "integer")])
|
||||
refills = []
|
||||
plugin.catalog = types.SimpleNamespace(find=lambda account, name: demo if name == "demo" else None)
|
||||
plugin._refill = lambda *args: refills.append(args)
|
||||
params = types.SimpleNamespace(message="/demo", scheduleDate=0, peer=5, replyToMsg=None, replyToTopMsg=None)
|
||||
result = plugin.on_send_message_hook(0, params)
|
||||
check(
|
||||
"an incomplete command is never sent: it goes back into the field",
|
||||
result.strategy == HookStrategy.CANCEL and refills and refills[0][2] == "/demo",
|
||||
repr(refills),
|
||||
)
|
||||
keys = {getattr(r, "key", None) for r in plugin.create_settings()}
|
||||
check("settings have the panel URL and tokens", {"server_url", "api_tokens", "send_to_chat"} <= keys, repr(keys))
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
if len(argv) != 1:
|
||||
@@ -145,112 +405,26 @@ def main(argv=None) -> int:
|
||||
|
||||
install_fakes()
|
||||
source = bundle_path.read_text(encoding="utf-8")
|
||||
|
||||
modules_before = set(sys.modules)
|
||||
ns = {"__name__": "hello_bundle"}
|
||||
ns = {"__name__": "client_commands_plugin"}
|
||||
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
|
||||
plugin_cls = check_metadata(source, ns, bundle_path)
|
||||
load = ns.get("_bundle_load")
|
||||
check("bundle exposes its module loader", load is not None)
|
||||
if load is not None:
|
||||
sys.modules["_cc_model"] = load("commands.model")
|
||||
check_parser(load)
|
||||
check_markup(load)
|
||||
library = check_library(load)
|
||||
check_catalog(load, library)
|
||||
check_runner(load, library)
|
||||
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}",
|
||||
)
|
||||
check_send_hook(plugin_cls)
|
||||
|
||||
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)}",
|
||||
)
|
||||
sys.modules.pop("_cc_model", None)
|
||||
leaked = {"commands", "hooks", "catalog", "runner", "main"} & (set(sys.modules) - modules_before)
|
||||
check("bundled modules stay out of sys.modules", not leaked, f"leaked: {leaked}")
|
||||
|
||||
if _FAILURES:
|
||||
print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)
|
||||
|
||||
Reference in new issue
Block a user