push-tools 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- push_tools/__init__.py +195 -0
- push_tools/base.py +189 -0
- push_tools/channels/__init__.py +25 -0
- push_tools/channels/pushplus.py +343 -0
- push_tools/channels/qmsg.py +242 -0
- push_tools/channels/serverchan.py +181 -0
- push_tools/channels/telegram.py +217 -0
- push_tools/channels/wechat.py +856 -0
- push_tools/composite.py +179 -0
- push_tools/errors.py +131 -0
- push_tools/factory.py +48 -0
- push_tools/registry.py +338 -0
- push_tools-0.1.0.dist-info/METADATA +656 -0
- push_tools-0.1.0.dist-info/RECORD +17 -0
- push_tools-0.1.0.dist-info/WHEEL +5 -0
- push_tools-0.1.0.dist-info/licenses/LICENSE +21 -0
- push_tools-0.1.0.dist-info/top_level.txt +1 -0
push_tools/__init__.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""push-tools: a small, pluggable message-push toolkit.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
Core abstractions:
|
|
6
|
+
|
|
7
|
+
- :class:`PushChannel` / :class:`PushResult` - channel base class and result.
|
|
8
|
+
- :func:`register_channel` - decorator that registers a channel plugin.
|
|
9
|
+
- :func:`create_channel` - factory: build a channel by its registered name.
|
|
10
|
+
- :class:`PushComposite` - fan one message out to several channels.
|
|
11
|
+
- :data:`registry` / :func:`load_plugins` - plugin registry and entry-point
|
|
12
|
+
discovery.
|
|
13
|
+
|
|
14
|
+
Built-in channels:
|
|
15
|
+
|
|
16
|
+
- :class:`PushPlus` (registered name ``"pushplus"``)
|
|
17
|
+
- :class:`Qmsg` (registered name ``"qmsg"``)
|
|
18
|
+
- :class:`ServerChan` (registered name ``"server"``)
|
|
19
|
+
- :class:`Telegram` (registered name ``"telegram"``)
|
|
20
|
+
- :class:`WorkWechat` (registered name ``"workWechat"``)
|
|
21
|
+
- :class:`WorkWechatRobot` (registered name ``"workWechatRobot"``)
|
|
22
|
+
|
|
23
|
+
Quick example::
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
logging.basicConfig(level=logging.INFO)
|
|
27
|
+
|
|
28
|
+
from push_tools import create_channel
|
|
29
|
+
|
|
30
|
+
pusher = create_channel("pushplus", "your-token")
|
|
31
|
+
pusher.send("hello world", title="greeting")
|
|
32
|
+
|
|
33
|
+
Writing a plugin
|
|
34
|
+
----------------
|
|
35
|
+
Subclass :class:`PushChannel`, decorate it, and start using it::
|
|
36
|
+
|
|
37
|
+
from push_tools import PushChannel, register_channel, create_channel
|
|
38
|
+
|
|
39
|
+
@register_channel("stdout")
|
|
40
|
+
class StdoutChannel(PushChannel):
|
|
41
|
+
def send(self, message, **options):
|
|
42
|
+
print(message)
|
|
43
|
+
return self._succeed(raw={"echo": message})
|
|
44
|
+
|
|
45
|
+
create_channel("stdout").send("hello")
|
|
46
|
+
|
|
47
|
+
Third-party packages can also ship channels as ``push_tools.channels``
|
|
48
|
+
entry points - see :mod:`push_tools.registry`.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
from collections.abc import Mapping
|
|
54
|
+
|
|
55
|
+
# Core building blocks.
|
|
56
|
+
from .base import PushChannel, PushResult
|
|
57
|
+
from .composite import PushComposite
|
|
58
|
+
from .errors import (
|
|
59
|
+
AccessFailed,
|
|
60
|
+
ChannelAlreadyRegistered,
|
|
61
|
+
PushChannelError,
|
|
62
|
+
PushToolsError,
|
|
63
|
+
UnknownChannelError,
|
|
64
|
+
catch_exception,
|
|
65
|
+
)
|
|
66
|
+
from .factory import create_channel
|
|
67
|
+
from .registry import (
|
|
68
|
+
ENTRY_POINT_GROUP,
|
|
69
|
+
ChannelRegistry,
|
|
70
|
+
load_plugins,
|
|
71
|
+
register_channel,
|
|
72
|
+
registry,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Importing the built-in channel package triggers self-registration.
|
|
76
|
+
from . import channels as _channels # noqa: F401
|
|
77
|
+
from .channels.pushplus import PushPlus
|
|
78
|
+
from .channels.qmsg import Qmsg
|
|
79
|
+
from .channels.serverchan import ServerChan
|
|
80
|
+
from .channels.telegram import Telegram
|
|
81
|
+
from .channels.wechat import WorkWechat, WorkWechatRobot
|
|
82
|
+
|
|
83
|
+
__version__ = "0.0.1"
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------- #
|
|
86
|
+
# Backward-compatible aliases (push-tools 0.0.1 API)
|
|
87
|
+
# ---------------------------------------------------------------------- #
|
|
88
|
+
# New code should use the PEP-8 names above; the lowercase aliases exist so
|
|
89
|
+
# existing callers keep working unchanged.
|
|
90
|
+
push = PushChannel
|
|
91
|
+
pushplus = PushPlus
|
|
92
|
+
qmsg = Qmsg
|
|
93
|
+
server = ServerChan
|
|
94
|
+
telegram = Telegram
|
|
95
|
+
workWechat = WorkWechat
|
|
96
|
+
workWechatRobot = WorkWechatRobot
|
|
97
|
+
push_composite = PushComposite
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class push_creator:
|
|
101
|
+
"""Deprecated factory wrapper from push-tools 0.0.1.
|
|
102
|
+
|
|
103
|
+
New code should use :func:`create_channel`, which raises
|
|
104
|
+
:class:`UnknownChannelError` for unknown names instead of printing.
|
|
105
|
+
|
|
106
|
+
Example (legacy style)::
|
|
107
|
+
|
|
108
|
+
creator = push_creator("qmsg", "your-key")
|
|
109
|
+
creator.send("hello world")
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, type, key):
|
|
113
|
+
self.type = type
|
|
114
|
+
# Preserve the original "print and keep going" behaviour for callers
|
|
115
|
+
# that relied on an unknown type not raising.
|
|
116
|
+
try:
|
|
117
|
+
self.push = self.create(type, key)
|
|
118
|
+
except UnknownChannelError:
|
|
119
|
+
print(f"Unsupported push type: {type}")
|
|
120
|
+
self.push = None
|
|
121
|
+
|
|
122
|
+
def create(self, type, key):
|
|
123
|
+
"""Build the underlying channel; kept for API compatibility."""
|
|
124
|
+
|
|
125
|
+
return create_channel(type, key)
|
|
126
|
+
|
|
127
|
+
def send(self, msg, **kwargs):
|
|
128
|
+
"""Forward the message to the wrapped channel, if any."""
|
|
129
|
+
|
|
130
|
+
if self.push is None:
|
|
131
|
+
print("No push instance created.")
|
|
132
|
+
return None
|
|
133
|
+
return self.push.send(msg, **kwargs)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _RegistryView(Mapping):
|
|
137
|
+
"""Read-only dynamic ``{name: channel_class}`` view of the registry.
|
|
138
|
+
|
|
139
|
+
Backward-compatible replacement for the old static ``push_server`` dict:
|
|
140
|
+
it stays in sync with third-party entry-point plugins loaded at runtime.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __getitem__(self, name):
|
|
144
|
+
try:
|
|
145
|
+
return registry.get(name)
|
|
146
|
+
except UnknownChannelError as exc:
|
|
147
|
+
raise KeyError(name) from exc
|
|
148
|
+
|
|
149
|
+
def __iter__(self):
|
|
150
|
+
return iter(registry.names())
|
|
151
|
+
|
|
152
|
+
def __len__(self):
|
|
153
|
+
return len(registry.names())
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Legacy name for the global name -> class mapping.
|
|
157
|
+
push_server = _RegistryView()
|
|
158
|
+
|
|
159
|
+
__all__ = [
|
|
160
|
+
# core
|
|
161
|
+
"PushChannel",
|
|
162
|
+
"PushResult",
|
|
163
|
+
"PushComposite",
|
|
164
|
+
"create_channel",
|
|
165
|
+
"register_channel",
|
|
166
|
+
"registry",
|
|
167
|
+
"ChannelRegistry",
|
|
168
|
+
"load_plugins",
|
|
169
|
+
"ENTRY_POINT_GROUP",
|
|
170
|
+
# errors
|
|
171
|
+
"PushToolsError",
|
|
172
|
+
"PushChannelError",
|
|
173
|
+
"AccessFailed",
|
|
174
|
+
"UnknownChannelError",
|
|
175
|
+
"ChannelAlreadyRegistered",
|
|
176
|
+
"catch_exception",
|
|
177
|
+
# built-in channels
|
|
178
|
+
"PushPlus",
|
|
179
|
+
"Qmsg",
|
|
180
|
+
"ServerChan",
|
|
181
|
+
"Telegram",
|
|
182
|
+
"WorkWechat",
|
|
183
|
+
"WorkWechatRobot",
|
|
184
|
+
# legacy aliases
|
|
185
|
+
"push",
|
|
186
|
+
"pushplus",
|
|
187
|
+
"qmsg",
|
|
188
|
+
"server",
|
|
189
|
+
"telegram",
|
|
190
|
+
"workWechat",
|
|
191
|
+
"workWechatRobot",
|
|
192
|
+
"push_composite",
|
|
193
|
+
"push_creator",
|
|
194
|
+
"push_server",
|
|
195
|
+
]
|
push_tools/base.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Abstract base class shared by every push channel.
|
|
2
|
+
|
|
3
|
+
A *channel* is a small adapter that knows how to deliver a text message to
|
|
4
|
+
one third-party service (PushPlus, Qmsg, ServerChan, WeCom, ...). Third-party
|
|
5
|
+
packages add new services by subclassing :class:`PushChannel` and registering
|
|
6
|
+
the subclass with :func:`push_tools.registry.register_channel`.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
Minimal custom channel::
|
|
10
|
+
|
|
11
|
+
from push_tools import PushChannel, register_channel
|
|
12
|
+
|
|
13
|
+
@register_channel("stdout")
|
|
14
|
+
class StdoutChannel(PushChannel):
|
|
15
|
+
def send(self, message, **options):
|
|
16
|
+
print(message)
|
|
17
|
+
return self._succeed(raw={"echo": message})
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any, ClassVar, Optional
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("push_tools")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class PushResult:
|
|
32
|
+
"""Immutable result object returned by a successful :meth:`PushChannel.send`.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
success: Always ``True`` for a returned result (``None`` is returned
|
|
36
|
+
on failure by ``@catch_exception``).
|
|
37
|
+
channel: Registered name of the channel that produced the result.
|
|
38
|
+
raw: Parsed JSON body (or any other data) returned by the upstream
|
|
39
|
+
service; useful for debugging and auditing.
|
|
40
|
+
message: Optional human-readable note.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
::
|
|
44
|
+
|
|
45
|
+
result = pusher.send("hello")
|
|
46
|
+
result.success # True
|
|
47
|
+
result.channel # 'pushplus'
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
success: bool
|
|
51
|
+
channel: str
|
|
52
|
+
raw: Any = None
|
|
53
|
+
message: str = ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PushChannel(ABC):
|
|
57
|
+
"""Abstract base class for all push channels.
|
|
58
|
+
|
|
59
|
+
Class attributes:
|
|
60
|
+
name: Registered channel name. Filled in automatically by
|
|
61
|
+
``@register_channel("...")``; subclasses do not need to set it.
|
|
62
|
+
default_timeout: Default HTTP timeout (seconds) used when the caller
|
|
63
|
+
does not pass ``timeout`` explicitly. Prevents a stalled
|
|
64
|
+
connection from blocking forever.
|
|
65
|
+
allowed_options: White-list of keyword names that
|
|
66
|
+
:meth:`_filter_options` will forward to the upstream API. This
|
|
67
|
+
keeps a fan-out call safe: unknown options are silently ignored.
|
|
68
|
+
|
|
69
|
+
Constructor arguments:
|
|
70
|
+
token: Credential required by the service. Most channels take a
|
|
71
|
+
string key/token; the WeCom application channel takes a dict of
|
|
72
|
+
``{"corpid": ..., "corpSecret": ...}``.
|
|
73
|
+
timeout: Per-request HTTP timeout in seconds; falls back to
|
|
74
|
+
:attr:`default_timeout` when omitted.
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
Implement a new channel::
|
|
78
|
+
|
|
79
|
+
@register_channel("bark")
|
|
80
|
+
class Bark(PushChannel):
|
|
81
|
+
url = "https://api.day.app"
|
|
82
|
+
allowed_options = frozenset({"title", "sound"})
|
|
83
|
+
|
|
84
|
+
@catch_exception
|
|
85
|
+
def send(self, message, **options):
|
|
86
|
+
data = self._filter_options(**options)
|
|
87
|
+
data["body"] = message
|
|
88
|
+
res = requests.post(f"{self.url}/{self.token}",
|
|
89
|
+
data=data, timeout=self.timeout).json()
|
|
90
|
+
if res.get("code") == 200:
|
|
91
|
+
return self._succeed(raw=res)
|
|
92
|
+
raise AccessFailed(res.get("message"))
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
# Filled in by the registry decorator; empty for unregistered subclasses.
|
|
96
|
+
name: ClassVar[str] = ""
|
|
97
|
+
|
|
98
|
+
# Network timeout shared by the built-in HTTP channels.
|
|
99
|
+
default_timeout: ClassVar[float] = 10.0
|
|
100
|
+
|
|
101
|
+
# Keyword arguments accepted by the upstream API of a concrete channel.
|
|
102
|
+
allowed_options: ClassVar[frozenset] = frozenset()
|
|
103
|
+
|
|
104
|
+
def __init__(self, token: Any = None, *, timeout: "float | None" = None):
|
|
105
|
+
# ``token`` is the preferred name; ``key`` is kept as an alias so the
|
|
106
|
+
# original attribute name from push-tools 0.0.1 still works.
|
|
107
|
+
self.token = token
|
|
108
|
+
self.key = token
|
|
109
|
+
self.timeout = self.default_timeout if timeout is None else timeout
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def label(self) -> str:
|
|
113
|
+
"""Log/display label: registered name when available, class name otherwise."""
|
|
114
|
+
|
|
115
|
+
return self.name or self.__class__.__name__
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def send(self, message: str, **options: Any) -> "Optional[PushResult]":
|
|
119
|
+
"""Send ``message`` to the remote service.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
message: Text body to deliver. Channels may accept Markdown / HTML
|
|
123
|
+
depending on their ``msgtype`` / ``template`` options.
|
|
124
|
+
**options: Channel-specific keyword arguments. Unknown keywords
|
|
125
|
+
must be ignored so one :class:`PushComposite` fan-out call can
|
|
126
|
+
carry options for heterogeneous channels.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
A :class:`PushResult` on success, or ``None`` when the method is
|
|
130
|
+
decorated with :func:`~push_tools.errors.catch_exception` and an
|
|
131
|
+
exception was caught.
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
>>> pusher.send("hello world", title="greeting") # doctest: +SKIP
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
raise NotImplementedError
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------ #
|
|
140
|
+
# Helpers reusable by concrete channels
|
|
141
|
+
# ------------------------------------------------------------------ #
|
|
142
|
+
|
|
143
|
+
def success(self, *args: Any) -> None:
|
|
144
|
+
"""Log an ``INFO`` message prefixed with the channel label.
|
|
145
|
+
|
|
146
|
+
Preserved from the original ``push`` base class which printed
|
|
147
|
+
``[ClassName] Operate successfully.``.
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
>>> self.success() # doctest: +SKIP
|
|
151
|
+
>>> self.success("media id:", "abc123") # doctest: +SKIP
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
if args:
|
|
155
|
+
logger.info("[%s] %s", self.label, " ".join(str(arg) for arg in args))
|
|
156
|
+
else:
|
|
157
|
+
logger.info("[%s] Operate successfully.", self.label)
|
|
158
|
+
|
|
159
|
+
def _succeed(self, raw: Any = None) -> PushResult:
|
|
160
|
+
"""Log success and build the :class:`PushResult` returned by ``send``.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
raw: Optional raw payload (usually the decoded JSON response).
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
>>> return self._succeed(raw=response_json) # doctest: +SKIP
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
self.success()
|
|
170
|
+
return PushResult(success=True, channel=self.label, raw=raw)
|
|
171
|
+
|
|
172
|
+
def _filter_options(self, **options: Any) -> dict:
|
|
173
|
+
"""Keep only keys listed in :attr:`allowed_options`.
|
|
174
|
+
|
|
175
|
+
This makes a single ``composite.send(..., title=..., qq=...)`` call
|
|
176
|
+
safe: ``title`` reaches PushPlus while ``qq`` reaches Qmsg and every
|
|
177
|
+
other channel simply ignores the unrelated keys.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
::
|
|
181
|
+
|
|
182
|
+
# with allowed_options = frozenset({"title"})
|
|
183
|
+
self._filter_options(title="hi", ignored=1)
|
|
184
|
+
# -> {'title': 'hi'}
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
key: value for key, value in options.items() if key in self.allowed_options
|
|
189
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Built-in push channels shipped with push-tools.
|
|
2
|
+
|
|
3
|
+
Importing this package imports every built-in channel module once, which in
|
|
4
|
+
turn registers the channel classes on the global registry. Third-party
|
|
5
|
+
channels do not live here - they are discovered from installed packages via
|
|
6
|
+
the ``push_tools.channels`` entry-point group (see
|
|
7
|
+
:mod:`push_tools.registry`).
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
List every available channel name (built-ins + installed plugins)::
|
|
11
|
+
|
|
12
|
+
from push_tools import registry
|
|
13
|
+
print(registry.names())
|
|
14
|
+
# ['pushplus', 'qmsg', 'server', 'telegram', 'workWechat',
|
|
15
|
+
# 'workWechatRobot']
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
# Importing the modules triggers their @register_channel decorators.
|
|
21
|
+
from . import pushplus as pushplus # noqa: F401
|
|
22
|
+
from . import qmsg as qmsg # noqa: F401
|
|
23
|
+
from . import serverchan as serverchan # noqa: F401
|
|
24
|
+
from . import telegram as telegram # noqa: F401
|
|
25
|
+
from . import wechat as wechat # noqa: F401
|