youlmk 0.1.0__tar.gz
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.
- youlmk-0.1.0/.gitignore +4 -0
- youlmk-0.1.0/PKG-INFO +34 -0
- youlmk-0.1.0/README.md +17 -0
- youlmk-0.1.0/pyproject.toml +30 -0
- youlmk-0.1.0/tests/test_notify.py +82 -0
- youlmk-0.1.0/youlmk/__init__.py +178 -0
youlmk-0.1.0/.gitignore
ADDED
youlmk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: youlmk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send a notification to your YouLMK inbox from Python. One call, no dependencies.
|
|
5
|
+
Project-URL: Homepage, https://youlmk.com
|
|
6
|
+
Project-URL: Documentation, https://youlmk.com/docs/sdks
|
|
7
|
+
Project-URL: Source, https://github.com/bylivebetter/youlmk
|
|
8
|
+
Author: Live Better Platforms, Inc.
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: alerts,notifications,push,webhook,youlmk
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Communications
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# youlmk
|
|
19
|
+
|
|
20
|
+
Send a notification to your [YouLMK](https://youlmk.com) inbox with one call. Python 3.9+, the standard library only.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install youlmk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from youlmk import notify
|
|
28
|
+
|
|
29
|
+
notify(os.environ["YOULMK_TOKEN"], "backup finished", body="4.2 GB in 3 min 10 s", priority="low")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The token is your source's bearer token (`ylk_…`), from the source's page. Every option is in the [payload reference](https://youlmk.com/docs/payload); the answer is a `Result(id, receipt, grouped, held)`; an error is a `YouLMKError` with the door's `code`, the `field` for a 422, and `retry_after` for a 429.
|
|
33
|
+
|
|
34
|
+
Docs: https://youlmk.com/docs/sdks
|
youlmk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# youlmk
|
|
2
|
+
|
|
3
|
+
Send a notification to your [YouLMK](https://youlmk.com) inbox with one call. Python 3.9+, the standard library only.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install youlmk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from youlmk import notify
|
|
11
|
+
|
|
12
|
+
notify(os.environ["YOULMK_TOKEN"], "backup finished", body="4.2 GB in 3 min 10 s", priority="low")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The token is your source's bearer token (`ylk_…`), from the source's page. Every option is in the [payload reference](https://youlmk.com/docs/payload); the answer is a `Result(id, receipt, grouped, held)`; an error is a `YouLMKError` with the door's `code`, the `field` for a 422, and `retry_after` for a 429.
|
|
16
|
+
|
|
17
|
+
Docs: https://youlmk.com/docs/sdks
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "youlmk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Send a notification to your YouLMK inbox from Python. One call, no dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Live Better Platforms, Inc." }]
|
|
13
|
+
keywords = ["notifications", "push", "youlmk", "alerts", "webhook"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Communications",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://youlmk.com"
|
|
23
|
+
Documentation = "https://youlmk.com/docs/sdks"
|
|
24
|
+
Source = "https://github.com/bylivebetter/youlmk"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["youlmk"]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.sdist]
|
|
30
|
+
include = ["youlmk", "tests", "README.md", "pyproject.toml"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import unittest
|
|
5
|
+
import urllib.error
|
|
6
|
+
from email.message import Message
|
|
7
|
+
|
|
8
|
+
from youlmk import Result, YouLMK, YouLMKError, notify, to_payload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Answer(io.BytesIO):
|
|
12
|
+
"""What urllib hands back: a body with a status and headers."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, status, body, headers=None):
|
|
15
|
+
super().__init__(json.dumps(body).encode())
|
|
16
|
+
self.status = status
|
|
17
|
+
self.headers = Message()
|
|
18
|
+
for k, v in (headers or {}).items():
|
|
19
|
+
self.headers[k] = v
|
|
20
|
+
|
|
21
|
+
def __enter__(self):
|
|
22
|
+
return self
|
|
23
|
+
|
|
24
|
+
def __exit__(self, *_):
|
|
25
|
+
self.close()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Opener:
|
|
29
|
+
def __init__(self, status, body, headers=None):
|
|
30
|
+
self.status, self.body, self.headers = status, body, headers
|
|
31
|
+
self.requests = []
|
|
32
|
+
|
|
33
|
+
def open(self, request, timeout=None):
|
|
34
|
+
self.requests.append(request)
|
|
35
|
+
if self.status >= 400:
|
|
36
|
+
msg = Message()
|
|
37
|
+
for k, v in (self.headers or {}).items():
|
|
38
|
+
msg[k] = v
|
|
39
|
+
raise urllib.error.HTTPError(request.full_url, self.status, "error", msg, io.BytesIO(json.dumps(self.body).encode()))
|
|
40
|
+
return Answer(self.status, self.body, self.headers)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NotifyTests(unittest.TestCase):
|
|
44
|
+
def test_posts_the_token_and_the_snake_case_payload(self):
|
|
45
|
+
door = Opener(200, {"ok": True, "id": "ntf_1", "receipt": "rcpt_1", "grouped": False, "held": True})
|
|
46
|
+
result = YouLMK("ylk_test", base_url="https://door.test/", opener=door).notify("deploy finished", body="4 min", priority="high", url="https://x.test/1", url_label="Logs", fields={"Env": "prod"}, idempotency_key="run-9")
|
|
47
|
+
self.assertEqual(result, Result(id="ntf_1", receipt="rcpt_1", grouped=False, held=True))
|
|
48
|
+
request = door.requests[0]
|
|
49
|
+
self.assertEqual(request.full_url, "https://door.test/v1/notify")
|
|
50
|
+
self.assertEqual(request.get_header("Authorization"), "Bearer ylk_test")
|
|
51
|
+
self.assertEqual(request.get_header("Idempotency-key"), "run-9")
|
|
52
|
+
self.assertEqual(json.loads(request.data), {"title": "deploy finished", "body": "4 min", "priority": "high", "url": "https://x.test/1", "url_label": "Logs", "fields": {"Env": "prod"}})
|
|
53
|
+
|
|
54
|
+
def test_leaves_out_what_was_not_given(self):
|
|
55
|
+
self.assertEqual(to_payload("hi"), {"title": "hi"})
|
|
56
|
+
|
|
57
|
+
def test_errors_carry_the_code_the_field_and_retry_after(self):
|
|
58
|
+
with self.assertRaises(YouLMKError) as limited:
|
|
59
|
+
YouLMK("ylk_test", opener=Opener(429, {"error": "rate_limited"}, {"Retry-After": "7"})).notify("x")
|
|
60
|
+
self.assertEqual((limited.exception.status, limited.exception.code, limited.exception.retry_after), (429, "rate_limited", 7))
|
|
61
|
+
with self.assertRaises(YouLMKError) as invalid:
|
|
62
|
+
YouLMK("ylk_test", opener=Opener(422, {"error": "invalid_field", "field": "priority"})).notify("x")
|
|
63
|
+
self.assertEqual(invalid.exception.field, "priority")
|
|
64
|
+
self.assertIn("priority", str(invalid.exception))
|
|
65
|
+
|
|
66
|
+
def test_refuses_an_empty_token(self):
|
|
67
|
+
with self.assertRaises(YouLMKError):
|
|
68
|
+
YouLMK("")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@unittest.skipUnless(os.environ.get("YOULMK_TOKEN"), "no token in the environment")
|
|
72
|
+
class LiveTests(unittest.TestCase):
|
|
73
|
+
"""One notification into the dev inbox, from this package, when a token is in the environment."""
|
|
74
|
+
|
|
75
|
+
def test_sends_one(self):
|
|
76
|
+
result = notify(os.environ["YOULMK_TOKEN"], "youlmk-python sent this", body="from the package's own test", priority="low", base_url=os.environ.get("YOULMK_BASE", "https://youlmk.com"))
|
|
77
|
+
self.assertTrue(result.id.startswith("ntf_"))
|
|
78
|
+
self.assertTrue(result.receipt.startswith("rcpt_"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
unittest.main()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""youlmk: send a notification to your inbox with one call.
|
|
2
|
+
|
|
3
|
+
from youlmk import notify
|
|
4
|
+
notify(os.environ["YOULMK_TOKEN"], "backup finished", body="4.2 GB in 3 min 10 s")
|
|
5
|
+
|
|
6
|
+
Wraps POST youlmk.com/v1/notify with the source's bearer token
|
|
7
|
+
(https://youlmk.com/docs/http). The standard library only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Mapping, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
__all__ = ["YouLMK", "YouLMKError", "Result", "notify", "to_payload", "__version__"]
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE = "https://youlmk.com"
|
|
22
|
+
|
|
23
|
+
_MESSAGES = {
|
|
24
|
+
"unknown_key": "No source has this token",
|
|
25
|
+
"key_rotated": "This token was rotated or its source deleted",
|
|
26
|
+
"trial_used": "The free notifications are used; subscribe to keep sending",
|
|
27
|
+
"too_large": "The payload is over 8 KB",
|
|
28
|
+
"rate_limited": "Too many sends; wait retry_after seconds",
|
|
29
|
+
"daily_cap": "The account's daily cap is reached",
|
|
30
|
+
"sending_paused": "Sending is paused; try again shortly",
|
|
31
|
+
"bad_json": "The body was not JSON",
|
|
32
|
+
"network": "YouLMK could not be reached",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class YouLMKError(Exception):
|
|
37
|
+
"""The door answered with an error, or could not be reached.
|
|
38
|
+
|
|
39
|
+
status: the HTTP status, or 0 when nothing came back.
|
|
40
|
+
code: the door's own word (unknown_key, trial_used, invalid_field, rate_limited, ...).
|
|
41
|
+
field: for invalid_field, which one.
|
|
42
|
+
retry_after: for rate_limited, daily_cap and sending_paused, seconds to wait.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, status: int, code: str, field: Optional[str] = None, retry_after: Optional[int] = None):
|
|
46
|
+
self.status = status
|
|
47
|
+
self.code = code
|
|
48
|
+
self.field = field
|
|
49
|
+
self.retry_after = retry_after
|
|
50
|
+
if code == "invalid_field":
|
|
51
|
+
message = f"The {field or 'payload'} is over its limit or the wrong shape"
|
|
52
|
+
else:
|
|
53
|
+
message = _MESSAGES.get(code) or f"YouLMK answered {status}" + (f" ({code})" if code else "")
|
|
54
|
+
super().__init__(message)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Result:
|
|
59
|
+
"""What the door answers: the notification's id, its receipt, and two facts about where it went."""
|
|
60
|
+
|
|
61
|
+
id: str
|
|
62
|
+
receipt: str
|
|
63
|
+
grouped: bool
|
|
64
|
+
held: bool
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def to_payload(
|
|
68
|
+
title: str,
|
|
69
|
+
*,
|
|
70
|
+
body: Optional[str] = None,
|
|
71
|
+
priority: Optional[str] = None,
|
|
72
|
+
url: Optional[str] = None,
|
|
73
|
+
url_label: Optional[str] = None,
|
|
74
|
+
actions: Optional[Sequence[Mapping[str, str]]] = None,
|
|
75
|
+
group: Optional[str] = None,
|
|
76
|
+
image: Optional[str] = None,
|
|
77
|
+
fields: Optional[Mapping[str, str]] = None,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""The JSON the door reads: snake_case, with nothing None in it."""
|
|
80
|
+
payload: dict[str, Any] = {"title": title}
|
|
81
|
+
if body is not None:
|
|
82
|
+
payload["body"] = body
|
|
83
|
+
if priority is not None:
|
|
84
|
+
payload["priority"] = priority
|
|
85
|
+
if url is not None:
|
|
86
|
+
payload["url"] = url
|
|
87
|
+
if url_label is not None:
|
|
88
|
+
payload["url_label"] = url_label
|
|
89
|
+
if actions is not None:
|
|
90
|
+
payload["actions"] = [dict(a) for a in actions]
|
|
91
|
+
if group is not None:
|
|
92
|
+
payload["group"] = group
|
|
93
|
+
if image is not None:
|
|
94
|
+
payload["image"] = image
|
|
95
|
+
if fields is not None:
|
|
96
|
+
payload["fields"] = dict(fields)
|
|
97
|
+
return payload
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class YouLMK:
|
|
101
|
+
"""A client bound to one source's token, for code that sends more than once."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, token: str, *, base_url: str = DEFAULT_BASE, timeout: float = 10.0, opener: Any = None):
|
|
104
|
+
if not token or not isinstance(token, str):
|
|
105
|
+
raise YouLMKError(0, "unknown_key")
|
|
106
|
+
self._token = token
|
|
107
|
+
self._base = base_url.rstrip("/")
|
|
108
|
+
self._timeout = timeout
|
|
109
|
+
# A urllib opener to use instead of the default; the tests hand in one that answers without a network.
|
|
110
|
+
self._opener = opener or urllib.request.build_opener()
|
|
111
|
+
|
|
112
|
+
def notify(
|
|
113
|
+
self,
|
|
114
|
+
title: str,
|
|
115
|
+
*,
|
|
116
|
+
body: Optional[str] = None,
|
|
117
|
+
priority: Optional[str] = None,
|
|
118
|
+
url: Optional[str] = None,
|
|
119
|
+
url_label: Optional[str] = None,
|
|
120
|
+
actions: Optional[Sequence[Mapping[str, str]]] = None,
|
|
121
|
+
group: Optional[str] = None,
|
|
122
|
+
image: Optional[str] = None,
|
|
123
|
+
fields: Optional[Mapping[str, str]] = None,
|
|
124
|
+
idempotency_key: Optional[str] = None,
|
|
125
|
+
) -> Result:
|
|
126
|
+
"""Send one notification. Returns its id and receipt; raises YouLMKError."""
|
|
127
|
+
payload = to_payload(title, body=body, priority=priority, url=url, url_label=url_label, actions=actions, group=group, image=image, fields=fields)
|
|
128
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
129
|
+
request = urllib.request.Request(
|
|
130
|
+
f"{self._base}/v1/notify",
|
|
131
|
+
data=data,
|
|
132
|
+
method="POST",
|
|
133
|
+
headers={
|
|
134
|
+
"Authorization": f"Bearer {self._token}",
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
"User-Agent": f"youlmk-python/{__version__}",
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
if idempotency_key:
|
|
140
|
+
request.add_header("Idempotency-Key", idempotency_key)
|
|
141
|
+
try:
|
|
142
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
143
|
+
status = response.status
|
|
144
|
+
text = response.read().decode("utf-8")
|
|
145
|
+
retry_header = response.headers.get("Retry-After")
|
|
146
|
+
except urllib.error.HTTPError as error:
|
|
147
|
+
status = error.code
|
|
148
|
+
text = error.read().decode("utf-8", errors="replace")
|
|
149
|
+
retry_header = error.headers.get("Retry-After") if error.headers else None
|
|
150
|
+
except (urllib.error.URLError, OSError) as error:
|
|
151
|
+
raise YouLMKError(0, "network") from error
|
|
152
|
+
try:
|
|
153
|
+
answer = json.loads(text) if text else {}
|
|
154
|
+
except ValueError:
|
|
155
|
+
answer = {}
|
|
156
|
+
if not isinstance(answer, dict):
|
|
157
|
+
answer = {}
|
|
158
|
+
if status < 200 or status >= 300:
|
|
159
|
+
retry_after = None
|
|
160
|
+
if retry_header:
|
|
161
|
+
try:
|
|
162
|
+
retry_after = int(retry_header)
|
|
163
|
+
except ValueError:
|
|
164
|
+
retry_after = None
|
|
165
|
+
code = answer.get("error") if isinstance(answer.get("error"), str) else ""
|
|
166
|
+
field = answer.get("field") if isinstance(answer.get("field"), str) else None
|
|
167
|
+
raise YouLMKError(status, code, field=field, retry_after=retry_after)
|
|
168
|
+
return Result(
|
|
169
|
+
id=str(answer.get("id", "")),
|
|
170
|
+
receipt=str(answer.get("receipt", "")),
|
|
171
|
+
grouped=answer.get("grouped") is True,
|
|
172
|
+
held=answer.get("held") is True,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def notify(token: str, title: str, *, base_url: str = DEFAULT_BASE, timeout: float = 10.0, **options: Any) -> Result:
|
|
177
|
+
"""Send one notification with a source's token. The same as YouLMK(token).notify(title, ...)."""
|
|
178
|
+
return YouLMK(token, base_url=base_url, timeout=timeout).notify(title, **options)
|