Merge pull request 'feat(notify): value-first emoji rows' (#65) from dev-notify-layout into main
test / komodo-deploy-script (push) Successful in 17s
ci / docker (push) Successful in 1m54s

This commit was merged in pull request #65.
This commit is contained in:
trex Bot committed 2026-11-03 15:19:40 -06:00
commit 5b54d9e558
6 files changed
+364 -59

No files matched your search

+29 -15
View File
@@ -137,35 +137,49 @@ Pushover and Apprise share one summary builder, so both report the same
numbers. The title states the outcome, the body gives the scanned total, the numbers. The title states the outcome, the body gives the scanned total, the
actionable counters, then the tracker split. actionable counters, then the tracker split.
`format = "plain"` (default) produces aligned text: The default summary looks like this:
``` ```
qbit-maid: 7 torrents removed qbit-maid: 7 torrents removed
Scanned 42 torrents in 12s Scanned 42 torrents in 12s
Removed 7 🗑️ 7 removed · 👻 4 orphaned
Orphaned 4 ⏭️ 5 ignored · 🐣 3 premature
Ignored 5
Premature 3
Protected 10 🛡️ 10 protected · 🌐 20 public
Non-protected 20
``` ```
`format = "html"` bolds the labels and counts using Pushover's HTML subset Each counter is `emoji value label`, grouped two per line and joined with a
(`html=1` is set automatically). Apprise has no HTML support, so it always middle dot. The value leads so the numbers are the first thing the eye lands
receives the plain body. on, and the separator survives any font — Pushover renders the body in a
proportional font, so space-padded columns collapse into a ragged list on a
phone.
Setting `use_emoji = true` switches both styles to `emoji label: value` rows: `format = "html"` bolds the values using Pushover's HTML subset (`html=1` is
set automatically). Apprise has no HTML support, so it always receives the
plain body.
`use_emoji` defaults to `true`. Set it to `false` for a plain-text variant:
``` ```
🗑️ Removed: 7 Scanned 42 torrents in 12s
👻 Orphaned: 4
⏭️ Ignored: 5 7 removed · 4 orphaned
🐣 Premature: 3 5 ignored · 3 premature
10 protected · 20 public
``` ```
To preview the result without sending a push, render the real
`build_summary()` output as a phone-width mockup:
```
python3 docs/notify-preview.py /tmp/notify.png
```
Pass `--mock` to render the design sketches instead of the live output.
Other options: Other options:
- `include_hostname` — add a `Host:` line, useful when several instances notify - `include_hostname` — add a `Host:` line, useful when several instances notify
+5 -2
View File
@@ -26,9 +26,12 @@ use_pushover = false
po_key = "<key>" po_key = "<key>"
po_token = "<token>" po_token = "<token>"
# Notification formatting # Notification formatting
# format: "plain" (aligned text) or "html" (bold labels, Pushover HTML) # format: "plain" (grouped text) or "html" (bold values, Pushover HTML)
# Counters render as "emoji value label", grouped two per line and joined
# with a middle dot, because Pushover uses a proportional font and padded
# columns do not align.
format = "plain" format = "plain"
use_emoji = false use_emoji = true
include_hostname = false include_hostname = false
notify_on_empty = true notify_on_empty = true
# Optional: -2..2. Priority 2 also needs retry/expire (defaults shown). # Optional: -2..2. Priority 2 also needs retry/expire (defaults shown).
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Render notification layouts as a phone-width PNG mockup.
Pushover renders the body in a proportional font, so space-padded columns do
not line up on a phone. These mockups use a proportional font on purpose so
the preview matches what the user actually sees.
By default the cards are rendered from the real ``build_summary()`` output, so
the preview is ground truth rather than a hand-written approximation. Pass
``--mock`` to render the built-in candidate sketches instead.
Usage: python3 docs/notify-preview.py [--mock] [out.png]
"""
import datetime
import html
import os
import subprocess
import sys
import tempfile
from collections import Counter
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
STATS = {
"total": 42,
"removed": 7,
"orphaned": 4,
"ignored": 5,
"premature": 3,
"protected": 10,
"non_protected": 20,
"runtime": "12s",
}
EMOJI = {
"removed": "\U0001F5D1\uFE0F",
"orphaned": "\U0001F47B",
"ignored": "\u23ED\uFE0F",
"premature": "\U0001F423",
"protected": "\U0001F6E1\uFE0F",
"non_protected": "\U0001F310",
}
def b(text):
return f"<b>{text}</b>"
# --- candidate layouts -----------------------------------------------------
# Each returns (title, body_html). Body uses Pushover's HTML subset.
def current_table(s):
"""Today's layout: space-padded columns."""
rows = [
("Removed", s["removed"]),
("Orphaned", s["orphaned"]),
("Ignored", s["ignored"]),
("Premature", s["premature"]),
]
tail = [("Protected", s["protected"]), ("Non-protected", s["non_protected"])]
out = [f"Scanned {s['total']} torrents in {s['runtime']}", ""]
out += [f"{b(lbl)}{' ' * max(14 - len(lbl), 1)}{val:>4}" for lbl, val in rows]
out.append("")
out += [f"{b(lbl)}{' ' * max(14 - len(lbl), 1)}{val:>4}" for lbl, val in tail]
return "qbit-maid: 7 torrents removed", "\n".join(out)
def compact_inline(s):
"""One line per group, middle-dot separated."""
out = [
f"Scanned {s['total']} torrents in {s['runtime']}",
"",
f"{b('Removed')} {s['removed']} \u00b7 {b('Orphaned')} {s['orphaned']}",
f"{b('Ignored')} {s['ignored']} \u00b7 {b('Premature')} {s['premature']}",
"",
f"{b('Protected')} {s['protected']} \u00b7 {b('Non-protected')} {s['non_protected']}",
]
return "qbit-maid: 7 torrents removed", "\n".join(out)
def emoji_rows(s):
"""Emoji-led rows, no padding."""
out = [f"Scanned {s['total']} torrents in {s['runtime']}", ""]
for key, label in (("removed", "Removed"), ("orphaned", "Orphaned"),
("ignored", "Ignored"), ("premature", "Premature")):
out.append(f"{EMOJI[key]} {b(label)} {s[key]}")
out.append("")
for key, label in (("protected", "Protected"), ("non_protected", "Non-protected")):
out.append(f"{EMOJI[key]} {b(label)} {s[key]}")
return "qbit-maid: 7 torrents removed", "\n".join(out)
def hero_line(s):
"""Actionable numbers up front, everything else compact below."""
out = [
f"{b(str(s['removed']))} removed \u00b7 {b(str(s['orphaned']))} orphaned",
f"{s['total']} torrents scanned in {s['runtime']}",
"",
f"Ignored {s['ignored']} \u00b7 Premature {s['premature']}",
f"Protected {s['protected']} \u00b7 Non-protected {s['non_protected']}",
]
return "qbit-maid: 7 torrents removed", "\n".join(out)
def emoji_inline(s):
"""Compact inline, emoji instead of bold labels."""
out = [
f"Scanned {s['total']} torrents in {s['runtime']}",
"",
f"{EMOJI['removed']} {s['removed']} removed \u00b7 {EMOJI['orphaned']} {s['orphaned']} orphaned",
f"{EMOJI['ignored']} {s['ignored']} ignored \u00b7 {EMOJI['premature']} {s['premature']} premature",
"",
f"{EMOJI['protected']} {s['protected']} protected \u00b7 {EMOJI['non_protected']} {s['non_protected']} public",
]
return "qbit-maid: 7 torrents removed", "\n".join(out)
MOCK_CANDIDATES = [
("A \u2014 current (padded table)", current_table),
("B \u2014 compact inline", compact_inline),
("C \u2014 emoji rows", emoji_rows),
("D \u2014 hero line", hero_line),
("E \u2014 emoji inline", emoji_inline),
]
class Sample:
"""Minimal stand-in for the Qbt object, holding only what notifiers read."""
def __init__(self, **overrides):
self.config = {"pushover": {}}
self.total_torrents = STATS["total"]
self.torrent_hash_delete_list = ["a"] * STATS["removed"]
self.up_tor_counter = STATS["orphaned"]
self.ignored_counter = STATS["ignored"]
self.preme_tor_counter = STATS["premature"]
self.tracker_protected_tag = "ipt"
self.tracker_non_protected_tag = "public"
self.c = Counter({"ipt": STATS["protected"],
"public": STATS["non_protected"]})
self.delete_torrents = True
self.st = datetime.datetime(2026, 10, 27, 9, 54, 0)
self.et = datetime.datetime(2026, 10, 27, 9, 54, 12)
for key, value in overrides.items():
setattr(self, key, value)
def real_candidates():
"""Render the actual build_summary() output for each supported style."""
from qlogging import build_summary
out = []
for style in ("plain", "html"):
for emoji in (False, True):
title, body = build_summary(
Sample(), style=style, use_emoji=emoji, include_hostname=False
)
label = f"{style}" + (" + emoji" if emoji else "")
out.append((label, title, body))
return out
def card(name, title, body):
body_html = "<br>".join(body.split("\n"))
return f"""
<div class="card">
<div class="app">PUSHOVER \u00b7 {html.escape(name)}</div>
<div class="title">{html.escape(title)}</div>
<div class="body">{body_html}</div>
</div>
"""
def build_html(mock=False):
if mock:
cards = "\n".join(card(n, *fn(STATS)) for n, fn in MOCK_CANDIDATES)
else:
cards = "\n".join(card(n, t, b) for n, t, b in real_candidates())
return f"""<!doctype html>
<html><head><meta charset="utf-8"><style>
body {{ margin:0; padding:18px; background:#1b1d21;
font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }}
.grid {{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }}
.card {{ background:#2b2f36; border-radius:14px; padding:14px 16px;
box-shadow:0 2px 10px rgba(0,0,0,.45); }}
.app {{ font-size:10px; letter-spacing:.14em; color:#8b929c; margin-bottom:6px; }}
.title {{ font-size:15px; font-weight:600; color:#f2f4f7; margin-bottom:8px; }}
.body {{ font-size:14px; line-height:1.45; color:#d7dbe0; white-space:normal; }}
.body b {{ color:#ffffff; }}
</style></head><body><div class="grid">{cards}</div></body></html>
"""
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
mock = "--mock" in sys.argv[1:]
out = args[0] if args else "/tmp/notify-preview.png"
workdir = tempfile.mkdtemp(prefix="notify-preview-")
page = os.path.join(workdir, "preview.html")
with open(page, "w", encoding="utf-8") as fh:
fh.write(build_html(mock=mock))
subprocess.run([
"docker", "run", "--rm", "-v", f"{workdir}:/w", "-w", "/w",
"--entrypoint", "google-chrome", "dgtlmoon/sockpuppetbrowser:latest",
"--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage",
"--hide-scrollbars", "--window-size=880,760",
"--screenshot=/w/preview.png", "file:///w/preview.html",
], check=True, capture_output=True)
subprocess.run(["cp", os.path.join(workdir, "preview.png"), out], check=True)
print(out)
if __name__ == "__main__":
main()
+47 -25
View File
@@ -108,8 +108,10 @@ def get_script_runtime(self):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
NOTIFY_STYLES = ("plain", "html") NOTIFY_STYLES = ("plain", "html")
DEFAULT_NOTIFY_STYLE = "plain" DEFAULT_NOTIFY_STYLE = "plain"
LABEL_WIDTH = 14 # Pushover renders the body in a proportional font, so space-padded columns
VALUE_WIDTH = 4 # collapse into a ragged list. Rows are therefore joined with a visible
# separator instead of being aligned with spaces.
ROW_SEPARATOR = " \u00b7 "
NOTIFY_EMOJI = { NOTIFY_EMOJI = {
"removed": "\U0001F5D1\uFE0F", "removed": "\U0001F5D1\uFE0F",
@@ -153,7 +155,7 @@ def get_notify_options(self):
priority = po.get("priority") priority = po.get("priority")
return { return {
"style": style, "style": style,
"use_emoji": bool(po.get("use_emoji", False)), "use_emoji": bool(po.get("use_emoji", True)),
"include_hostname": bool(po.get("include_hostname", False)), "include_hostname": bool(po.get("include_hostname", False)),
"notify_on_empty": bool(po.get("notify_on_empty", True)), "notify_on_empty": bool(po.get("notify_on_empty", True)),
"priority": None if priority is None else int(priority), "priority": None if priority is None else int(priority),
@@ -211,30 +213,45 @@ def debug_torrent_list(self):
self.tl.debug(self.torrent_list) self.tl.debug(self.torrent_list)
def _notify_row(label, value, style, use_emoji, key): def _stat_row(pairs, style, use_emoji):
"""Render one label/value line for the chosen style.""" """Render one line of ``emoji value label`` pairs joined by a middle dot.
emoji = NOTIFY_EMOJI.get(key, "") if use_emoji else ""
if use_emoji: The value leads and the label trails, so the numbers are the first thing
the eye lands on even though the font is proportional.
Args:
pairs: Sequence of ``(emoji_key, label, value)`` tuples.
style: ``"plain"`` or ``"html"``.
use_emoji: Prefix each pair with its emoji marker.
Returns:
A single line of text, e.g.
``\U0001F5D1\uFE0F 7 removed \u00b7 \U0001F47B 4 orphaned``.
"""
parts = []
for key, label, value in pairs:
emoji = NOTIFY_EMOJI.get(key, "") if use_emoji else ""
prefix = f"{emoji} " if emoji else "" prefix = f"{emoji} " if emoji else ""
if style == "html": if style == "html":
return f"{prefix}<b>{label}</b>: {value}" parts.append(f"{prefix}<b>{value}</b> {label}")
return f"{prefix}{label}: {value}" else:
if style == "html": parts.append(f"{prefix}{value} {label}")
pad = " " * max(LABEL_WIDTH - len(label), 1) return ROW_SEPARATOR.join(parts)
return f"<b>{label}</b>{pad}{value:>{VALUE_WIDTH}}"
return f"{label:<{LABEL_WIDTH}}{value:>{VALUE_WIDTH}}"
def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False, def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=True,
include_hostname=False): include_hostname=False):
"""Build the ``(title, body)`` pair used by every notifier. """Build the ``(title, body)`` pair used by every notifier.
Result-first layout: the title states what happened, the body gives the Result-first layout: the title states what happened, the body gives the
scanned total, the actionable counters, then the tracker split. scanned total, the actionable counters, then the tracker split. Counters
are grouped two-per-line as ``emoji value label`` and separated by a middle
dot, because Pushover renders a proportional font and space-padded columns
do not line up.
Args: Args:
style: ``"plain"`` for aligned text, ``"html"`` for Pushover HTML. style: ``"plain"`` for grouped text, ``"html"`` for Pushover HTML.
use_emoji: Prefix each row with an emoji marker. use_emoji: Prefix each counter with an emoji marker.
include_hostname: Add a ``Host:`` line above the summary. include_hostname: Add a ``Host:`` line above the summary.
Returns: Returns:
@@ -274,15 +291,20 @@ def build_summary(self, style=DEFAULT_NOTIFY_STYLE, use_emoji=False,
lines.append(f"Scanned {total} {scanned_noun} in {stats['runtime']}") lines.append(f"Scanned {total} {scanned_noun} in {stats['runtime']}")
lines.append("") lines.append("")
removed_label = "Removed" if delete_enabled else "Would remove" removed_label = "removed" if delete_enabled else "would be removed"
lines.append(_notify_row(removed_label, removed, style, use_emoji, "removed")) lines.append(_stat_row([
lines.append(_notify_row("Orphaned", stats["orphaned"], style, use_emoji, "orphaned")) ("removed", removed_label, removed),
lines.append(_notify_row("Ignored", stats["ignored"], style, use_emoji, "ignored")) ("orphaned", "orphaned", stats["orphaned"]),
lines.append(_notify_row("Premature", stats["premature"], style, use_emoji, "premature")) ], style, use_emoji))
lines.append(_stat_row([
("ignored", "ignored", stats["ignored"]),
("premature", "premature", stats["premature"]),
], style, use_emoji))
lines.append("") lines.append("")
lines.append(_notify_row("Protected", stats["protected"], style, use_emoji, "protected")) lines.append(_stat_row([
lines.append(_notify_row("Non-protected", stats["non_protected"], style, ("protected", "protected", stats["protected"]),
use_emoji, "non_protected")) ("non_protected", "public", stats["non_protected"]),
], style, use_emoji))
return title, "\n".join(lines) return title, "\n".join(lines)
+69 -17
View File
@@ -190,19 +190,28 @@ class TestQbitmaid(unittest.TestCase):
self.assertEqual(stats["runtime"], "unknown") self.assertEqual(stats["runtime"], "unknown")
def test_build_summary_plain(self): def test_build_summary_plain(self):
title, body = build_summary(FakeQbt(), style="plain") title, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
self.assertEqual(title, "qbit-maid: 7 torrents removed") self.assertEqual(title, "qbit-maid: 7 torrents removed")
self.assertEqual( self.assertEqual(
body, body,
"Scanned 42 torrents in 12s\n" "Scanned 42 torrents in 12s\n"
"\n" "\n"
"Removed 7\n" "7 removed \u00b7 4 orphaned\n"
"Orphaned 4\n" "5 ignored \u00b7 3 premature\n"
"Ignored 5\n"
"Premature 3\n"
"\n" "\n"
"Protected 10\n" "10 protected \u00b7 20 public",
"Non-protected 20", )
def test_build_summary_plain_emoji(self):
_, body = build_summary(FakeQbt(), style="plain", use_emoji=True)
self.assertEqual(
body,
"Scanned 42 torrents in 12s\n"
"\n"
"\U0001F5D1\uFE0F 7 removed \u00b7 \U0001F47B 4 orphaned\n"
"\u23ED\uFE0F 5 ignored \u00b7 \U0001F423 3 premature\n"
"\n"
"\U0001F6E1\uFE0F 10 protected \u00b7 \U0001F310 20 public",
) )
def test_build_summary_plain_has_no_leading_indent(self): def test_build_summary_plain_has_no_leading_indent(self):
@@ -210,26 +219,65 @@ class TestQbitmaid(unittest.TestCase):
for line in body.splitlines(): for line in body.splitlines():
self.assertFalse(line.startswith(" "), f"unexpected indent: {line!r}") self.assertFalse(line.startswith(" "), f"unexpected indent: {line!r}")
def test_build_summary_has_no_space_padding(self):
"""Pushover uses a proportional font, so padded columns never align."""
for style in ("plain", "html"):
for emoji in (False, True):
_, body = build_summary(FakeQbt(), style=style, use_emoji=emoji)
for line in body.splitlines():
stripped = line.replace(" \u00b7 ", "")
self.assertNotIn(" ", stripped,
f"padded column in {style}/{emoji}: {line!r}")
def test_build_summary_groups_two_counters_per_line(self):
_, body = build_summary(FakeQbt(), style="plain")
rows = [ln for ln in body.splitlines() if "\u00b7" in ln]
self.assertEqual(len(rows), 3)
for row in rows:
self.assertEqual(row.count("\u00b7"), 1, row)
def test_build_summary_value_leads_label(self):
"""The number comes first so it is the first thing the eye lands on."""
_, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
self.assertIn("7 removed", body)
self.assertIn("20 public", body)
self.assertNotIn("Removed 7", body)
self.assertNotIn("Non-protected", body)
def test_build_summary_html(self): def test_build_summary_html(self):
title, body = build_summary(FakeQbt(), style="html") title, body = build_summary(FakeQbt(), style="html")
self.assertEqual(title, "qbit-maid: 7 torrents removed") self.assertEqual(title, "qbit-maid: 7 torrents removed")
self.assertIn("Scanned <b>42</b> torrents in <b>12s</b>", body) self.assertIn("Scanned <b>42</b> torrents in <b>12s</b>", body)
self.assertIn("<b>Removed</b>", body) self.assertIn("<b>7</b> removed", body)
self.assertIn("<b>Non-protected</b>", body) self.assertIn("<b>20</b> public", body)
self.assertNotIn("---", title) self.assertNotIn("---", title)
def test_build_summary_emoji(self): def test_build_summary_emoji(self):
_, plain = build_summary(FakeQbt(), style="plain", use_emoji=True) _, plain = build_summary(FakeQbt(), style="plain", use_emoji=True)
self.assertIn("\U0001F5D1\uFE0F Removed: 7", plain) self.assertIn("\U0001F5D1\uFE0F 7 removed", plain)
self.assertIn("\U0001F47B Orphaned: 4", plain) self.assertIn("\U0001F47B 4 orphaned", plain)
_, html = build_summary(FakeQbt(), style="html", use_emoji=True) _, html = build_summary(FakeQbt(), style="html", use_emoji=True)
self.assertIn("\U0001F5D1\uFE0F <b>Removed</b>: 7", html) self.assertIn("\U0001F5D1\uFE0F <b>7</b> removed", html)
def test_build_summary_emoji_off_has_no_emoji(self):
_, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
for marker in ("\U0001F5D1", "\U0001F47B", "\u23ED", "\U0001F423",
"\U0001F6E1", "\U0001F310"):
self.assertNotIn(marker, body)
def test_build_summary_delete_disabled(self): def test_build_summary_delete_disabled(self):
title, body = build_summary(FakeQbt(delete_torrents=False)) title, body = build_summary(FakeQbt(delete_torrents=False))
self.assertEqual(title, "qbit-maid: 7 torrents would be removed") self.assertEqual(title, "qbit-maid: 7 torrents would be removed")
self.assertIn("Would remove", body) self.assertIn("7 would be removed", body)
self.assertNotIn("Removed", body) self.assertNotIn("7 removed", body)
def test_build_summary_body_does_not_repeat_title_phrase(self):
"""The title carries the outcome; the body must not restate it verbatim."""
title, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
self.assertNotIn("torrents removed", body)
self.assertNotIn("torrents would be removed", body)
self.assertIn("7 removed", body)
self.assertTrue(title.endswith("7 torrents removed"))
def test_build_summary_nothing_to_do(self): def test_build_summary_nothing_to_do(self):
title, _ = build_summary(FakeQbt(torrent_hash_delete_list=[], up_tor_counter=0)) title, _ = build_summary(FakeQbt(torrent_hash_delete_list=[], up_tor_counter=0))
@@ -254,16 +302,20 @@ class TestQbitmaid(unittest.TestCase):
def test_get_notify_options_defaults(self): def test_get_notify_options_defaults(self):
opts = get_notify_options(FakeQbt()) opts = get_notify_options(FakeQbt())
self.assertEqual(opts["style"], "plain") self.assertEqual(opts["style"], "plain")
self.assertFalse(opts["use_emoji"]) self.assertTrue(opts["use_emoji"])
self.assertFalse(opts["include_hostname"]) self.assertFalse(opts["include_hostname"])
self.assertTrue(opts["notify_on_empty"]) self.assertTrue(opts["notify_on_empty"])
self.assertIsNone(opts["priority"]) self.assertIsNone(opts["priority"])
self.assertIsNone(opts["sound"]) self.assertIsNone(opts["sound"])
def test_get_notify_options_emoji_can_be_disabled(self):
opts = get_notify_options(FakeQbt(config={"pushover": {"use_emoji": False}}))
self.assertFalse(opts["use_emoji"])
def test_get_notify_options_overrides(self): def test_get_notify_options_overrides(self):
fake = FakeQbt(config={"pushover": { fake = FakeQbt(config={"pushover": {
"format": "HTML", "format": "HTML",
"use_emoji": True, "use_emoji": False,
"include_hostname": True, "include_hostname": True,
"notify_on_empty": False, "notify_on_empty": False,
"priority": 2, "priority": 2,
@@ -273,7 +325,7 @@ class TestQbitmaid(unittest.TestCase):
}}) }})
opts = get_notify_options(fake) opts = get_notify_options(fake)
self.assertEqual(opts["style"], "html") self.assertEqual(opts["style"], "html")
self.assertTrue(opts["use_emoji"]) self.assertFalse(opts["use_emoji"])
self.assertTrue(opts["include_hostname"]) self.assertTrue(opts["include_hostname"])
self.assertFalse(opts["notify_on_empty"]) self.assertFalse(opts["notify_on_empty"])
self.assertEqual(opts["priority"], 2) self.assertEqual(opts["priority"], 2)