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.
@@ -0,0 +1,4 @@
1
+ dist/
2
+ build/
3
+ __pycache__/
4
+ *.egg-info/
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)