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 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