telegram-kit 0.1.2__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,102 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ concurrency:
11
+ group: ci-${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ env:
15
+ PYTHONUTF8: "1"
16
+
17
+ jobs:
18
+ test:
19
+ name: Python 3.10 / ${{ matrix.os }}
20
+ timeout-minutes: 15
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ os: [macos-latest, ubuntu-latest, windows-latest]
25
+ runs-on: ${{ matrix.os }}
26
+ steps:
27
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
28
+ with:
29
+ persist-credentials: false
30
+ - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
31
+ with:
32
+ python-version: "3.10"
33
+ enable-cache: true
34
+ cache-dependency-glob: uv.lock
35
+ - name: Install locked dependencies
36
+ run: uv sync --locked --all-groups
37
+ - name: Lint
38
+ run: uv run ruff check .
39
+ - name: Test
40
+ run: uv run python -m unittest discover -s tests -t . -v
41
+ - name: Build distributions
42
+ run: uv build --wheel
43
+ - name: Validate wheel is importable standalone
44
+ shell: python
45
+ run: |
46
+ import os
47
+ import pathlib
48
+ import subprocess
49
+ import tempfile
50
+
51
+ wheel, = pathlib.Path("dist").glob("*.whl")
52
+ with tempfile.TemporaryDirectory() as directory:
53
+ environment = pathlib.Path(directory)
54
+ subprocess.run(["uv", "venv", environment], check=True)
55
+ scripts = environment / ("Scripts" if os.name == "nt" else "bin")
56
+ python = scripts / ("python.exe" if os.name == "nt" else "python")
57
+ subprocess.run(
58
+ ["uv", "pip", "install", "--python", python, "--no-index", wheel],
59
+ check=True,
60
+ )
61
+ subprocess.run([python, "-I", "-c", "import telegram_kit"], check=True)
62
+ print("telegram_kit imports standalone OK")
63
+
64
+ # Failure-only pager. A green run sends nothing on purpose: a notification
65
+ # that arrives on every push is one nobody reads. Credentials live in repo
66
+ # secrets, never in this file — see README "CI notifications" for the two
67
+ # `gh secret set` commands that configure them.
68
+ notify-telegram:
69
+ needs: test
70
+ if: ${{ failure() && github.event_name == 'push' }}
71
+ runs-on: ubuntu-latest
72
+ timeout-minutes: 5
73
+ steps:
74
+ - name: Notify Telegram
75
+ env:
76
+ TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
77
+ CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
78
+ run: |
79
+ # Unconfigured secrets are a valid state (a fork, or a clone whose
80
+ # owner never set them up). Skip quietly instead of failing the job
81
+ # and reporting a second, misleading red X on top of the real one.
82
+ if [ -z "$TOKEN" ] || [ -z "$CHAT_ID" ]; then
83
+ echo "::notice::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set — skipping notification"
84
+ exit 0
85
+ fi
86
+ # parse_mode=HTML: a branch or commit subject containing < & > would
87
+ # otherwise produce unparseable markup and Telegram would 400.
88
+ esc() { printf '%s' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'; }
89
+ ci_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
90
+ message="🚨 <b>CI failed</b>
91
+
92
+ <b>Repository:</b> <code>$(esc "$GITHUB_REPOSITORY")</code>
93
+ <b>Branch:</b> <code>$(esc "$GITHUB_REF_NAME")</code>
94
+ <b>Commit:</b> <code>$(esc "${GITHUB_SHA::7}")</code>
95
+
96
+ <a href=\"$ci_url\">Open failed CI run</a>"
97
+ curl --fail --silent --show-error --max-time 30 \
98
+ --data-urlencode "chat_id=$CHAT_ID" \
99
+ --data-urlencode "parse_mode=HTML" \
100
+ --data-urlencode "disable_web_page_preview=true" \
101
+ --data-urlencode "text=$message" \
102
+ "https://api.telegram.org/bot$TOKEN/sendMessage"
@@ -0,0 +1,49 @@
1
+ name: Publish
2
+
3
+ # GitHub Releases are created by the /release script; this job only uploads to PyPI.
4
+ on:
5
+ push:
6
+ tags:
7
+ - "v*"
8
+
9
+ permissions:
10
+ contents: read
11
+ id-token: write
12
+
13
+ concurrency:
14
+ group: publish-${{ github.ref }}
15
+
16
+ env:
17
+ PYTHONUTF8: "1"
18
+
19
+ jobs:
20
+ publish:
21
+ runs-on: ubuntu-latest
22
+ timeout-minutes: 15
23
+ steps:
24
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
25
+ with:
26
+ persist-credentials: false
27
+ - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
28
+ with:
29
+ python-version: "3.10"
30
+ enable-cache: true
31
+ cache-dependency-glob: uv.lock
32
+ - name: Install locked dependencies
33
+ run: uv sync --locked --all-groups
34
+ - name: Check, test, and build
35
+ run: |
36
+ uv run ruff check .
37
+ uv run python -m unittest discover -s tests -t .
38
+ uv build
39
+ - name: Validate wheel version matches tag
40
+ run: |
41
+ uv venv .release-venv
42
+ uv pip install --python .release-venv/bin/python --no-index dist/*.whl
43
+ reported="$(.release-venv/bin/python -I -c 'import importlib.metadata as m; print(m.version("telegram-kit"))')"
44
+ if [ "$reported" != "${GITHUB_REF_NAME#v}" ]; then
45
+ echo "::error::wheel reports '$reported', expected '${GITHUB_REF_NAME#v}'"
46
+ exit 1
47
+ fi
48
+ - name: Publish to PyPI
49
+ run: uv publish --trusted-publishing always
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .ruff_cache/
6
+ .coverage
@@ -0,0 +1,31 @@
1
+ ## [0.1.2] - 2026-09-26
2
+
3
+ ### 🐛 Bug Fixes
4
+
5
+ - **tests:** Stop wiping env vars Windows needs to boot Python
6
+
7
+ ### 📚 Documentation
8
+
9
+ - **readme:** Document PyPI install alongside git-tag pin
10
+
11
+ ### ⚙️ Miscellaneous Tasks
12
+
13
+ - **publish:** Add PyPI publish workflow on v* tags
14
+ ## [0.1.1] - 2026-09-26
15
+
16
+ ### 📚 Documentation
17
+
18
+ - Document CI Telegram notification setup
19
+
20
+ ### 🧪 Testing
21
+
22
+ - Cover every branch of the credential store and writer
23
+
24
+ ### ⚙️ Miscellaneous Tasks
25
+
26
+ - Add python tooling and CI
27
+ ## [0.1.0] - 2026-09-26
28
+
29
+ ### 🚀 Features
30
+
31
+ - Extract telegram_kit as a standalone package
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wes Kao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF THE SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: telegram-kit
3
+ Version: 0.1.2
4
+ Summary: Secure Telegram Bot API notifications for any Python project. Stdlib only.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # telegram-kit
11
+
12
+ Secure Telegram Bot API notifications for any Python project. Stdlib only —
13
+ no dependencies, no vendoring.
14
+
15
+ ```python
16
+ import telegram_kit
17
+
18
+ telegram_kit.notify("build finished", service="my-app", chat_id="123456789")
19
+
20
+ store = telegram_kit.CredentialStore("my-app")
21
+ token = telegram_kit.read_hidden("Telegram bot token (hidden): ")
22
+ if token and store.set("telegram_bot_token", token):
23
+ print("stored as", telegram_kit.mask_secret(token))
24
+ ```
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ uv add "telegram-kit>=0.1.2,<0.2"
30
+ ```
31
+
32
+ Published to PyPI on every `v*` tag (`.github/workflows/publish.yml`).
33
+ `uv lock --upgrade-package telegram-kit` is how every project using this kit
34
+ picks up a fix — no file to copy, no diff to reapply. A project that is never
35
+ published to PyPI can pin the git tag instead:
36
+ `uv add "telegram-kit @ git+https://github.com/weskao/telegram-kit@v0.1.2"`.
37
+
38
+ ## What it guarantees, whichever project uses it
39
+
40
+ - **No plaintext fallback.** `CredentialStore` writes to the OS credential
41
+ store (macOS Keychain, Linux Secret Service, Windows DPAPI). With none
42
+ available, it refuses to store rather than falling back to a plaintext
43
+ file or home-rolled obfuscation.
44
+ - **Each caller gets its own namespace.** `CredentialStore(service)` keys
45
+ every item under that service name, so two projects on the same machine
46
+ never collide.
47
+ - **Credentials never touch argv or the process list** — batch-mode/stdin
48
+ paths are used for every backend.
49
+ - **Owner-only atomic writes** (`write_private`) for anything that must live
50
+ on disk, on POSIX and Windows alike.
51
+
52
+ ## API
53
+
54
+ | Function | Purpose |
55
+ |---|---|
56
+ | `CredentialStore(service, dpapi_dir=None)` | Get/set/delete a secret in the OS store. |
57
+ | `resolve_credentials(token, chat_id, environ=None)` | Configured value, else `TG_BOT_TOKEN`/`TG_CHAT_ID`. |
58
+ | `send_message(token, chat_id, text, timeout=10)` | One `sendMessage` call. `False` on any failure. |
59
+ | `notify(text, service, chat_id="", token_key=..., store=None)` | Resolve + send in one call. |
60
+ | `read_hidden(prompt, ask=None)` | Hidden input; `None` if the terminal can't hide it. |
61
+ | `mask_secret(secret)` | `********` plus at most the last 4 characters. |
62
+ | `write_private(target, content)` | Atomic, owner-only file write. |
63
+
64
+ ## Develop
65
+
66
+ ```bash
67
+ uv run python -m unittest discover -s tests -t . -v
68
+ uv run ruff check .
69
+ ```
70
+
71
+ ## CI notifications
72
+
73
+ `.github/workflows/ci.yml` runs the test matrix (macOS/Linux/Windows) on every push and PR,
74
+ then a separate `notify-telegram` job sends a Telegram message only when the test job fails on
75
+ a `push` (never on green runs, never on `pull_request`, to avoid pinging on forks/external PRs).
76
+ Configure it once per repo:
77
+
78
+ ```bash
79
+ gh secret set TELEGRAM_BOT_TOKEN
80
+ gh secret set TELEGRAM_CHAT_ID
81
+ ```
82
+
83
+ Unconfigured secrets are a valid state — the job skips quietly instead of failing a second time
84
+ on top of the real failure.
@@ -0,0 +1,75 @@
1
+ # telegram-kit
2
+
3
+ Secure Telegram Bot API notifications for any Python project. Stdlib only —
4
+ no dependencies, no vendoring.
5
+
6
+ ```python
7
+ import telegram_kit
8
+
9
+ telegram_kit.notify("build finished", service="my-app", chat_id="123456789")
10
+
11
+ store = telegram_kit.CredentialStore("my-app")
12
+ token = telegram_kit.read_hidden("Telegram bot token (hidden): ")
13
+ if token and store.set("telegram_bot_token", token):
14
+ print("stored as", telegram_kit.mask_secret(token))
15
+ ```
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ uv add "telegram-kit>=0.1.2,<0.2"
21
+ ```
22
+
23
+ Published to PyPI on every `v*` tag (`.github/workflows/publish.yml`).
24
+ `uv lock --upgrade-package telegram-kit` is how every project using this kit
25
+ picks up a fix — no file to copy, no diff to reapply. A project that is never
26
+ published to PyPI can pin the git tag instead:
27
+ `uv add "telegram-kit @ git+https://github.com/weskao/telegram-kit@v0.1.2"`.
28
+
29
+ ## What it guarantees, whichever project uses it
30
+
31
+ - **No plaintext fallback.** `CredentialStore` writes to the OS credential
32
+ store (macOS Keychain, Linux Secret Service, Windows DPAPI). With none
33
+ available, it refuses to store rather than falling back to a plaintext
34
+ file or home-rolled obfuscation.
35
+ - **Each caller gets its own namespace.** `CredentialStore(service)` keys
36
+ every item under that service name, so two projects on the same machine
37
+ never collide.
38
+ - **Credentials never touch argv or the process list** — batch-mode/stdin
39
+ paths are used for every backend.
40
+ - **Owner-only atomic writes** (`write_private`) for anything that must live
41
+ on disk, on POSIX and Windows alike.
42
+
43
+ ## API
44
+
45
+ | Function | Purpose |
46
+ |---|---|
47
+ | `CredentialStore(service, dpapi_dir=None)` | Get/set/delete a secret in the OS store. |
48
+ | `resolve_credentials(token, chat_id, environ=None)` | Configured value, else `TG_BOT_TOKEN`/`TG_CHAT_ID`. |
49
+ | `send_message(token, chat_id, text, timeout=10)` | One `sendMessage` call. `False` on any failure. |
50
+ | `notify(text, service, chat_id="", token_key=..., store=None)` | Resolve + send in one call. |
51
+ | `read_hidden(prompt, ask=None)` | Hidden input; `None` if the terminal can't hide it. |
52
+ | `mask_secret(secret)` | `********` plus at most the last 4 characters. |
53
+ | `write_private(target, content)` | Atomic, owner-only file write. |
54
+
55
+ ## Develop
56
+
57
+ ```bash
58
+ uv run python -m unittest discover -s tests -t . -v
59
+ uv run ruff check .
60
+ ```
61
+
62
+ ## CI notifications
63
+
64
+ `.github/workflows/ci.yml` runs the test matrix (macOS/Linux/Windows) on every push and PR,
65
+ then a separate `notify-telegram` job sends a Telegram message only when the test job fails on
66
+ a `push` (never on green runs, never on `pull_request`, to avoid pinging on forks/external PRs).
67
+ Configure it once per repo:
68
+
69
+ ```bash
70
+ gh secret set TELEGRAM_BOT_TOKEN
71
+ gh secret set TELEGRAM_CHAT_ID
72
+ ```
73
+
74
+ Unconfigured secrets are a valid state — the job skips quietly instead of failing a second time
75
+ on top of the real failure.
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "telegram-kit"
3
+ version = "0.1.2"
4
+ description = "Secure Telegram Bot API notifications for any Python project. Stdlib only."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ dependencies = []
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["src/telegram_kit"]
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "ruff>=0.15.21",
20
+ ]
@@ -0,0 +1,366 @@
1
+ """Secure Telegram notifications for any Python project. Stdlib only.
2
+
3
+ ``pip``/``uv`` install this repository and ``import telegram_kit`` — it has no
4
+ dependency on the ``codex_reset_watch`` CLI, so importing it is cheap.
5
+
6
+ import telegram_kit
7
+
8
+ telegram_kit.notify("build finished", service="my-app", chat_id="123456789")
9
+
10
+ store = telegram_kit.CredentialStore("my-app")
11
+ token = telegram_kit.read_hidden("Telegram bot token (hidden): ")
12
+ if token and store.set("telegram_bot_token", token):
13
+ print("stored as", telegram_kit.mask_secret(token))
14
+
15
+ What it guarantees, whichever project uses it:
16
+
17
+ * The bot token lives only in the OS credential store, namespaced by
18
+ *service*: macOS Keychain (``security``), Linux Secret Service
19
+ (``secret-tool``) or Windows DPAPI (PowerShell). With none available,
20
+ nothing is stored; there is no plaintext or obfuscated fallback.
21
+ * The secret never enters an argument vector (``ps``, shell history): every
22
+ helper receives it on stdin.
23
+ * Reads never raise. A locked keyring or missing helper just means "no secret".
24
+ * ``TG_BOT_TOKEN`` / ``TG_CHAT_ID`` are fallbacks for values the caller did
25
+ not configure, each on its own.
26
+
27
+ Passing ``service="codex-reset-watch"`` reuses the token that ``crw config``
28
+ stored. The chat id is ordinary configuration, so each project keeps its own.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import contextlib
33
+ import functools
34
+ import getpass
35
+ import http.client
36
+ import json
37
+ import os
38
+ import pathlib
39
+ import platform
40
+ import re
41
+ import shutil
42
+ import subprocess
43
+ import urllib.error
44
+ import urllib.parse
45
+ import urllib.request
46
+ import uuid
47
+ import warnings
48
+ from collections.abc import Callable, Mapping
49
+
50
+ IS_MACOS = platform.system() == "Darwin"
51
+ IS_WINDOWS = platform.system() == "Windows"
52
+
53
+ #: How long a credential helper may take before we give up on it.
54
+ TIMEOUT_SECONDS = 10
55
+
56
+ #: Environment variables that stand in for credentials the caller did not set.
57
+ TOKEN_ENV, CHAT_ID_ENV = "TG_BOT_TOKEN", "TG_CHAT_ID"
58
+ TOKEN_KEY = "telegram_bot_token"
59
+
60
+ BACKEND_LABELS = {
61
+ "keychain": "macOS Keychain",
62
+ "libsecret": "Secret Service (libsecret)",
63
+ "dpapi": "Windows DPAPI",
64
+ }
65
+
66
+ _API = "https://api.telegram.org/bot{token}/sendMessage"
67
+
68
+
69
+ # ── owner-only files ─────────────────────────────────────────────────────────
70
+
71
+ def write_private(target: pathlib.Path, content: str) -> None:
72
+ """Atomically write owner-only text; fail before writing if protection fails."""
73
+ target = pathlib.Path(target)
74
+ target.parent.mkdir(parents=True, exist_ok=True)
75
+ if target.is_symlink():
76
+ raise OSError("Refusing to overwrite a symlink")
77
+ temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
78
+ created = False
79
+ try:
80
+ if os.name == "nt":
81
+ # The ACL is attached at creation, before content is written.
82
+ # The path and content travel on stdin, never the command line.
83
+ script = (
84
+ "$ErrorActionPreference='Stop'; "
85
+ "$data=[Console]::In.ReadToEnd() | ConvertFrom-Json; "
86
+ "$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User; "
87
+ "$acl=[Security.AccessControl.FileSecurity]::new(); "
88
+ "$acl.SetAccessRuleProtection($true,$false); $acl.SetOwner($sid); "
89
+ "$rule=[Security.AccessControl.FileSystemAccessRule]::new($sid,"
90
+ "[Security.AccessControl.FileSystemRights]::FullControl,"
91
+ "[Security.AccessControl.AccessControlType]::Allow); "
92
+ "$acl.AddAccessRule($rule); "
93
+ "$file=[IO.FileStream]::new($data.path,[IO.FileMode]::CreateNew,"
94
+ "[Security.AccessControl.FileSystemRights]::FullControl,[IO.FileShare]::None,4096,"
95
+ "[IO.FileOptions]::None,$acl); "
96
+ "$writer=[IO.StreamWriter]::new($file,[Text.UTF8Encoding]::new($false)); "
97
+ "try { $writer.Write($data.content) } finally { $writer.Dispose() }"
98
+ )
99
+ created = True # PowerShell may create it before failing or timing out
100
+ try:
101
+ result = subprocess.run(
102
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
103
+ input=json.dumps({"path": str(temporary), "content": content}, ensure_ascii=True),
104
+ capture_output=True, text=True, timeout=15, check=False,
105
+ )
106
+ except subprocess.SubprocessError as exc:
107
+ raise OSError("Unable to create an owner-only file") from exc
108
+ if result.returncode:
109
+ raise OSError("Unable to create an owner-only file")
110
+ else:
111
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
112
+ created = True
113
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
114
+ stream.write(content)
115
+ os.replace(temporary, target)
116
+ created = False
117
+ finally:
118
+ if created:
119
+ temporary.unlink(missing_ok=True)
120
+
121
+
122
+ # ── credential store ─────────────────────────────────────────────────────────
123
+
124
+ def _run(argv, stdin: str | None = None) -> tuple[int, str]:
125
+ """The single subprocess funnel: ``(returncode, stdout)``.
126
+
127
+ Tests replace this whole function, so nothing above it needs a keychain.
128
+ """
129
+ completed = subprocess.run(
130
+ list(argv), input=stdin, capture_output=True, text=True,
131
+ timeout=TIMEOUT_SECONDS, check=False,
132
+ )
133
+ return completed.returncode, completed.stdout
134
+
135
+
136
+ def _unhex(secret: str) -> str:
137
+ """Undo the hex encoding ``security -w`` applies to "non-clean" secrets.
138
+
139
+ It decides that per item, so the shape of the output is the only signal.
140
+ A secret that is itself pure hex stays as-is unless it also decodes to
141
+ valid UTF-8 — Telegram tokens contain ``:`` so they never take that path.
142
+ """
143
+ if not re.fullmatch(r"(?:[0-9a-fA-F]{2})+", secret):
144
+ return secret
145
+ try:
146
+ return bytes.fromhex(secret).decode("utf-8")
147
+ except (ValueError, UnicodeDecodeError):
148
+ return secret
149
+
150
+
151
+ def _batch_quote(value: str) -> str:
152
+ """Escape a value for a double-quoted argument in ``security -i`` batch mode.
153
+
154
+ Rejects newlines outright: batch mode is line-oriented, so an embedded one
155
+ would end the command and let the rest be read as a second one.
156
+ """
157
+ if "\n" in value or "\r" in value:
158
+ raise ValueError("security batch argument contains a newline")
159
+ return value.replace("\\", "\\\\").replace('"', '\\"')
160
+
161
+
162
+ def _detect_backend() -> str | None:
163
+ if IS_MACOS and shutil.which("security"):
164
+ return "keychain"
165
+ if IS_WINDOWS and shutil.which("powershell.exe"):
166
+ return "dpapi"
167
+ if shutil.which("secret-tool"):
168
+ return "libsecret"
169
+ return None
170
+
171
+
172
+ @functools.lru_cache(maxsize=1)
173
+ def backend() -> str | None:
174
+ """Which credential store this machine has, or ``None``. Probed once."""
175
+ return _detect_backend()
176
+
177
+
178
+ def available() -> bool:
179
+ return backend() is not None
180
+
181
+
182
+ def backend_label() -> str:
183
+ """A human name for the active store, for ``doctor`` and the config menu."""
184
+ return BACKEND_LABELS.get(backend() or "", "none")
185
+
186
+
187
+ def legacy_windows_env_token_present() -> bool:
188
+ """Detect a token left by older `setx` installs without reading it aloud."""
189
+ if not IS_WINDOWS:
190
+ return False
191
+ try:
192
+ import winreg
193
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
194
+ value, _ = winreg.QueryValueEx(key, "TG_BOT_TOKEN")
195
+ return bool(value)
196
+ except OSError:
197
+ return False
198
+
199
+
200
+ def _default_dpapi_dir(service: str) -> pathlib.Path:
201
+ base = os.environ.get("APPDATA") or str(pathlib.Path.home() / "AppData" / "Roaming")
202
+ return pathlib.Path(base) / service
203
+
204
+
205
+ class CredentialStore:
206
+ """The OS credential store, scoped to one *service* name.
207
+
208
+ *dpapi_dir* returns the folder for Windows DPAPI ciphertext files. It is a
209
+ callable so a caller whose config folder can move (an env override) is
210
+ asked each time rather than once.
211
+ """
212
+
213
+ def __init__(self, service: str,
214
+ dpapi_dir: Callable[[], pathlib.Path] | None = None) -> None:
215
+ self.service = service
216
+ self._dpapi_dir = dpapi_dir or (lambda: _default_dpapi_dir(service))
217
+
218
+ def _dpapi_path(self, key: str) -> pathlib.Path:
219
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", key):
220
+ raise ValueError("Invalid credential identifier")
221
+ return pathlib.Path(self._dpapi_dir()) / f"{key}.dpapi"
222
+
223
+ def get(self, key: str) -> str:
224
+ """The stored secret for *key*, or ``""`` when there is none.
225
+
226
+ Never raises: a locked keyring, a missing helper or a denied prompt all
227
+ mean "no secret", which the caller already handles.
228
+ """
229
+ active = backend()
230
+ if active is None:
231
+ return ""
232
+ with contextlib.suppress(Exception):
233
+ if active == "keychain":
234
+ code, out = _run(["security", "find-generic-password",
235
+ "-s", self.service, "-a", key, "-w"])
236
+ if code == 0:
237
+ return _unhex(out.strip())
238
+ elif active == "libsecret":
239
+ code, out = _run(["secret-tool", "lookup", "service", self.service, "account", key])
240
+ else: # dpapi
241
+ path = self._dpapi_path(key)
242
+ if not path.exists():
243
+ return ""
244
+ code, out = _run([
245
+ "powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
246
+ ("$s = [Console]::In.ReadToEnd().Trim() | ConvertTo-SecureString; "
247
+ "[Runtime.InteropServices.Marshal]::PtrToStringAuto("
248
+ "[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))"),
249
+ ], stdin=path.read_text(encoding="utf-8"))
250
+ if code == 0:
251
+ return out.strip()
252
+ return ""
253
+
254
+ def set(self, key: str, value: str) -> bool:
255
+ """Store *value* for *key*. ``False`` when it could not be stored securely.
256
+
257
+ An empty *value* deletes the item instead of storing a blank — that is how
258
+ the menu clears a token.
259
+ """
260
+ if not value:
261
+ return self.delete(key)
262
+ active = backend()
263
+ if active is None:
264
+ return False # refuse rather than write plaintext; see the module docstring
265
+ with contextlib.suppress(Exception):
266
+ if active == "keychain":
267
+ # `add-generic-password -w` with no argument does NOT read stdin — it
268
+ # opens /dev/tty and prompts, so piping the secret there stored an
269
+ # empty item and still exited 0. Batch mode (`security -i`) takes the
270
+ # whole command on stdin, which keeps the secret out of every argv
271
+ # (i.e. out of `ps`), and -X hex-encodes it past the tokenizer's
272
+ # quoting and newline rules. -U updates in place rather than stacking
273
+ # duplicate items.
274
+ command = 'add-generic-password -U -s "{}" -a "{}" -X {}\n'.format(
275
+ _batch_quote(self.service), _batch_quote(key), value.encode("utf-8").hex())
276
+ code, _ = _run(["security", "-i"], stdin=command)
277
+ elif active == "libsecret":
278
+ code, _ = _run(["secret-tool", "store", "--label", f"{self.service} {key}",
279
+ "service", self.service, "account", key], stdin=value)
280
+ else: # dpapi
281
+ path = self._dpapi_path(key)
282
+ code, encrypted = _run([
283
+ "powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
284
+ ("$in = [Console]::In.ReadToEnd().Trim(); "
285
+ "ConvertTo-SecureString $in -AsPlainText -Force | ConvertFrom-SecureString"),
286
+ ], stdin=value)
287
+ if code != 0 or not encrypted.strip():
288
+ return False
289
+ write_private(path, encrypted.strip())
290
+ return code == 0
291
+ return False
292
+
293
+ def delete(self, key: str) -> bool:
294
+ """Remove the stored secret for *key*. ``False`` when there was nothing to remove."""
295
+ active = backend()
296
+ if active is None:
297
+ return False
298
+ with contextlib.suppress(Exception):
299
+ if active == "keychain":
300
+ code, _ = _run(["security", "delete-generic-password", "-s", self.service, "-a", key])
301
+ elif active == "libsecret":
302
+ code, _ = _run(["secret-tool", "clear", "service", self.service, "account", key])
303
+ else: # dpapi
304
+ path = self._dpapi_path(key)
305
+ path.unlink(missing_ok=True)
306
+ return True
307
+ return code == 0
308
+ return False
309
+
310
+
311
+ # ── resolving, sending, entering, showing ────────────────────────────────────
312
+
313
+ def resolve_credentials(token: str = "", chat_id: str = "", *,
314
+ environ: Mapping[str, str] | None = None) -> tuple[str, str]:
315
+ """``(token, chat_id)``: what the caller configured, else the environment.
316
+
317
+ Each value falls back on its own, and blank counts as unset. Configured
318
+ values win so a stale ``TG_BOT_TOKEN`` in a shell profile cannot keep
319
+ notifying through a bot the user already replaced.
320
+ """
321
+ env = os.environ if environ is None else environ
322
+ return (str(token or "").strip() or env.get(TOKEN_ENV, "").strip(),
323
+ str(chat_id or "").strip() or env.get(CHAT_ID_ENV, "").strip())
324
+
325
+
326
+ def send_message(token: str, chat_id: str, text: str, *, timeout: float = 10) -> bool:
327
+ """POST one sendMessage to the Bot API. False on missing credentials or any failure."""
328
+ if not token or not chat_id:
329
+ return False
330
+ data = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode("utf-8")
331
+ request = urllib.request.Request(_API.format(token=token), data=data, method="POST")
332
+ try:
333
+ with urllib.request.urlopen(request, timeout=timeout) as response:
334
+ response.read()
335
+ except (urllib.error.URLError, OSError, ValueError, http.client.HTTPException):
336
+ return False # incl. InvalidURL from a hand-corrupted token
337
+ return True
338
+
339
+
340
+ def notify(text: str, *, service: str, chat_id: str = "", token_key: str = TOKEN_KEY,
341
+ store: CredentialStore | None = None) -> bool:
342
+ """Send *text* with *service*'s stored token. Never raises; False on failure."""
343
+ store = store or CredentialStore(service)
344
+ token, chat = resolve_credentials(store.get(token_key), chat_id)
345
+ return send_message(token, chat, text)
346
+
347
+
348
+ def read_hidden(prompt: str, *, ask: Callable[[str], str] | None = None) -> str | None:
349
+ """Read a secret without echo. ``None`` when echo cannot be disabled or input ends.
350
+
351
+ ``getpass`` silently falls back to an echoing prompt when it has no
352
+ terminal; that warning is treated as a refusal, never as consent.
353
+ """
354
+ try:
355
+ with warnings.catch_warnings():
356
+ warnings.simplefilter("error", getpass.GetPassWarning)
357
+ return (ask or getpass.getpass)(prompt).strip()
358
+ except (EOFError, OSError, getpass.GetPassWarning):
359
+ return None
360
+
361
+
362
+ def mask_secret(secret: str) -> str:
363
+ """Stars, plus at most the last 4 characters — never enough to reuse."""
364
+ if not secret:
365
+ return ""
366
+ return "*" * 8 + secret[-4:] if len(secret) > 12 else "*" * 8
@@ -0,0 +1 @@
1
+ """Test package for telegram_kit."""
@@ -0,0 +1,425 @@
1
+ """The reusable Telegram kit other projects import. Synthetic credentials only."""
2
+ import getpass
3
+ import os
4
+ import pathlib
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import types
9
+ import unittest
10
+ import urllib.parse
11
+ import warnings
12
+ from unittest import mock
13
+
14
+ import telegram_kit
15
+
16
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
17
+
18
+
19
+ class WindowsOs:
20
+ """Makes ``telegram_kit.os.name`` read ``"nt"`` without touching the real
21
+ global ``os`` module — patching that directly breaks pathlib for every
22
+ other test in the process (it stops knowing which flavour it's on)."""
23
+ name = "nt"
24
+
25
+ def __getattr__(self, attr):
26
+ return getattr(os, attr)
27
+
28
+
29
+ class Recorder:
30
+ def __init__(self, *results):
31
+ self.calls, self.results = [], list(results)
32
+
33
+ def __call__(self, argv, stdin=None):
34
+ self.calls.append((list(argv), stdin))
35
+ return self.results.pop(0) if self.results else (0, "")
36
+
37
+
38
+ def pinned(name, run):
39
+ return mock.patch.multiple(telegram_kit, backend=lambda: name, _run=run)
40
+
41
+
42
+ class StandaloneTests(unittest.TestCase):
43
+ def test_importing_the_kit_needs_only_the_standard_library(self):
44
+ """No project this kit is vendored/depended into should have to
45
+ install anything else just to get ``import telegram_kit``."""
46
+ result = subprocess.run( # noqa: PLW1510 - want a status, not to raise
47
+ [sys.executable, "-I", "-c", "import telegram_kit"],
48
+ cwd=ROOT, env={**os.environ, "PYTHONPATH": str(ROOT / "src")},
49
+ )
50
+ self.assertEqual(result.returncode, 0)
51
+
52
+
53
+ class CredentialStoreTests(unittest.TestCase):
54
+ def test_each_service_gets_its_own_keychain_namespace(self):
55
+ run = Recorder((0, "fake-token\n"), (0, ""))
56
+ store = telegram_kit.CredentialStore("other-app")
57
+ with pinned("keychain", run):
58
+ self.assertEqual(store.get("telegram_bot_token"), "fake-token")
59
+ self.assertTrue(store.set("telegram_bot_token", "fake-token"))
60
+ self.assertIn("other-app", run.calls[0][0])
61
+ self.assertIn('"other-app"', run.calls[1][1])
62
+ self.assertNotIn("fake-token", " ".join(run.calls[1][0]) + run.calls[1][1])
63
+
64
+ def test_without_a_backend_nothing_is_stored(self):
65
+ store = telegram_kit.CredentialStore("other-app")
66
+ with pinned(None, Recorder()):
67
+ self.assertFalse(store.set("telegram_bot_token", "fake-token"))
68
+ self.assertEqual(store.get("telegram_bot_token"), "")
69
+
70
+ def test_dpapi_files_live_in_the_callers_directory(self):
71
+ with tempfile.TemporaryDirectory() as d:
72
+ store = telegram_kit.CredentialStore("other-app", dpapi_dir=lambda: pathlib.Path(d))
73
+ with pinned("dpapi", Recorder((0, "ciphertext\n"))):
74
+ self.assertTrue(store.set("telegram_bot_token", "fake-token"))
75
+ self.assertEqual((pathlib.Path(d) / "telegram_bot_token.dpapi").read_text(), "ciphertext")
76
+
77
+ def test_dpapi_key_cannot_escape_its_directory(self):
78
+ with tempfile.TemporaryDirectory() as d:
79
+ store = telegram_kit.CredentialStore("other-app", dpapi_dir=lambda: pathlib.Path(d))
80
+ with pinned("dpapi", Recorder((0, "ciphertext\n"))):
81
+ self.assertFalse(store.set("../escape", "fake-token"))
82
+
83
+ def test_dpapi_get_returns_empty_when_the_file_is_missing(self):
84
+ with tempfile.TemporaryDirectory() as d:
85
+ store = telegram_kit.CredentialStore("other-app", dpapi_dir=lambda: pathlib.Path(d))
86
+ with pinned("dpapi", Recorder()):
87
+ self.assertEqual(store.get("telegram_bot_token"), "")
88
+
89
+ def test_dpapi_get_decrypts_the_stored_file(self):
90
+ with tempfile.TemporaryDirectory() as d:
91
+ (pathlib.Path(d) / "telegram_bot_token.dpapi").write_text("ciphertext")
92
+ store = telegram_kit.CredentialStore("other-app", dpapi_dir=lambda: pathlib.Path(d))
93
+ with pinned("dpapi", Recorder((0, "fake-token\n"))):
94
+ self.assertEqual(store.get("telegram_bot_token"), "fake-token")
95
+
96
+ def test_dpapi_set_fails_when_powershell_reports_an_error(self):
97
+ with tempfile.TemporaryDirectory() as d:
98
+ store = telegram_kit.CredentialStore("other-app", dpapi_dir=lambda: pathlib.Path(d))
99
+ with pinned("dpapi", Recorder((1, ""))):
100
+ self.assertFalse(store.set("telegram_bot_token", "fake-token"))
101
+ self.assertFalse((pathlib.Path(d) / "telegram_bot_token.dpapi").exists())
102
+
103
+ def test_dpapi_delete_removes_the_file(self):
104
+ with tempfile.TemporaryDirectory() as d:
105
+ path = pathlib.Path(d) / "telegram_bot_token.dpapi"
106
+ path.write_text("ciphertext")
107
+ store = telegram_kit.CredentialStore("other-app", dpapi_dir=lambda: pathlib.Path(d))
108
+ with pinned("dpapi", Recorder()):
109
+ self.assertTrue(store.delete("telegram_bot_token"))
110
+ self.assertFalse(path.exists())
111
+
112
+ def test_dpapi_delete_succeeds_even_when_nothing_was_stored(self):
113
+ with tempfile.TemporaryDirectory() as d:
114
+ store = telegram_kit.CredentialStore("other-app", dpapi_dir=lambda: pathlib.Path(d))
115
+ with pinned("dpapi", Recorder()):
116
+ self.assertTrue(store.delete("telegram_bot_token"))
117
+
118
+ def test_delete_without_a_backend_reports_failure(self):
119
+ store = telegram_kit.CredentialStore("other-app")
120
+ with pinned(None, Recorder()):
121
+ self.assertFalse(store.delete("telegram_bot_token"))
122
+
123
+
124
+ class CredentialResolutionTests(unittest.TestCase):
125
+ def test_configured_values_win_over_environment(self):
126
+ env = {"TG_BOT_TOKEN": "env-token", "TG_CHAT_ID": "222"}
127
+ self.assertEqual(telegram_kit.resolve_credentials("fake-token", "111", environ=env),
128
+ ("fake-token", "111"))
129
+
130
+ def test_each_value_falls_back_to_environment_on_its_own(self):
131
+ env = {"TG_BOT_TOKEN": " env-token ", "TG_CHAT_ID": "222"}
132
+ self.assertEqual(telegram_kit.resolve_credentials("", "111", environ=env), ("env-token", "111"))
133
+ self.assertEqual(telegram_kit.resolve_credentials("fake-token", " ", environ=env),
134
+ ("fake-token", "222"))
135
+
136
+
137
+ class SendMessageTests(unittest.TestCase):
138
+ def test_false_on_missing_credentials_without_a_network_call(self):
139
+ with mock.patch("urllib.request.urlopen") as urlopen:
140
+ self.assertFalse(telegram_kit.send_message("", "111", "hello"))
141
+ self.assertFalse(telegram_kit.send_message("fake-token", "", "hello"))
142
+ urlopen.assert_not_called()
143
+
144
+ def test_false_on_a_network_error_never_raises(self):
145
+ import urllib.error
146
+ with mock.patch("urllib.request.urlopen", side_effect=urllib.error.URLError("offline")):
147
+ self.assertFalse(telegram_kit.send_message("fake-token", "111", "hello"))
148
+
149
+ def test_false_on_a_malformed_token_never_raises(self):
150
+ """A hand-corrupted token can make the URL itself invalid (``InvalidURL``,
151
+ a ``ValueError`` subclass) — still reported as a failed send, not a crash."""
152
+ with mock.patch("urllib.request.urlopen", side_effect=ValueError("bad url")):
153
+ self.assertFalse(telegram_kit.send_message("not a token", "111", "hello"))
154
+
155
+
156
+ class NotifyTests(unittest.TestCase):
157
+ def test_notify_sends_with_the_services_stored_token(self):
158
+ sent = []
159
+
160
+ class Response:
161
+ def __enter__(self):
162
+ return self
163
+
164
+ def __exit__(self, *exc):
165
+ return False
166
+
167
+ def read(self):
168
+ return b"{}"
169
+
170
+ def urlopen(request, timeout):
171
+ sent.append(request)
172
+ return Response()
173
+
174
+ with pinned("keychain", Recorder((0, "fake-token\n"))), \
175
+ mock.patch("urllib.request.urlopen", side_effect=urlopen), \
176
+ mock.patch.dict(os.environ, {}, clear=True):
177
+ self.assertTrue(telegram_kit.notify("hello", service="other-app", chat_id="111"))
178
+ self.assertIn("/botfake-token/sendMessage", sent[0].full_url)
179
+ self.assertEqual(urllib.parse.parse_qs(sent[0].data.decode())["chat_id"], ["111"])
180
+
181
+ def test_notify_without_credentials_reports_failure(self):
182
+ with pinned(None, Recorder()), mock.patch.dict(os.environ, {}, clear=True), \
183
+ mock.patch("urllib.request.urlopen") as urlopen:
184
+ self.assertFalse(telegram_kit.notify("hello", service="other-app", chat_id="111"))
185
+ urlopen.assert_not_called()
186
+
187
+
188
+ class InputAndDisplayTests(unittest.TestCase):
189
+ def test_hidden_input_refuses_when_echo_cannot_be_disabled(self):
190
+ def echoing(prompt):
191
+ warnings.warn("echo on", getpass.GetPassWarning)
192
+ return "fake-token"
193
+ with warnings.catch_warnings(record=True):
194
+ self.assertIsNone(telegram_kit.read_hidden("Token: ", ask=echoing))
195
+
196
+ def test_hidden_input_returns_the_entered_secret(self):
197
+ self.assertEqual(telegram_kit.read_hidden("Token: ", ask=lambda _: " fake-token "), "fake-token")
198
+
199
+ def test_mask_never_shows_more_than_four_characters(self):
200
+ self.assertEqual(telegram_kit.mask_secret("123456:ABCDEFGHIJ"), "********GHIJ")
201
+ self.assertEqual(telegram_kit.mask_secret("short"), "********")
202
+ self.assertEqual(telegram_kit.mask_secret(""), "")
203
+
204
+
205
+ class WritePrivateTests(unittest.TestCase):
206
+ def test_writes_owner_only_and_atomically(self):
207
+ with tempfile.TemporaryDirectory() as d:
208
+ target = pathlib.Path(d) / "secret.txt"
209
+ telegram_kit.write_private(target, "content")
210
+ self.assertEqual(target.read_text(), "content")
211
+ if os.name != "nt":
212
+ self.assertEqual(target.stat().st_mode & 0o777, 0o600)
213
+ self.assertEqual(list(pathlib.Path(d).iterdir()), [target]) # no leftover temp file
214
+
215
+ def test_refuses_to_overwrite_a_symlink(self):
216
+ with tempfile.TemporaryDirectory() as d:
217
+ real = pathlib.Path(d) / "real.txt"
218
+ real.write_text("untouched")
219
+ link = pathlib.Path(d) / "link.txt"
220
+ link.symlink_to(real)
221
+ with self.assertRaises(OSError):
222
+ telegram_kit.write_private(link, "attacker-controlled")
223
+ self.assertEqual(real.read_text(), "untouched")
224
+
225
+ def test_windows_path_runs_powershell_and_cleans_up_on_failure(self):
226
+ def failing_powershell(argv, input, **kwargs):
227
+ pathlib.Path(__import__("json").loads(input)["path"]).write_text("partial")
228
+ return subprocess.CompletedProcess(argv, 1)
229
+ with tempfile.TemporaryDirectory() as d:
230
+ target = pathlib.Path(d) / "secret.txt"
231
+ with mock.patch.object(telegram_kit, "os", WindowsOs()), \
232
+ mock.patch.object(telegram_kit.subprocess, "run", side_effect=failing_powershell), \
233
+ self.assertRaises(OSError):
234
+ telegram_kit.write_private(target, "content")
235
+ self.assertEqual(list(pathlib.Path(d).iterdir()), [])
236
+
237
+ def test_windows_timeout_is_oserror_and_leaves_no_temp_file(self):
238
+ def slow_powershell(argv, **kwargs):
239
+ raise subprocess.TimeoutExpired(argv, 15)
240
+ with tempfile.TemporaryDirectory() as d:
241
+ target = pathlib.Path(d) / "secret.txt"
242
+ with mock.patch.object(telegram_kit, "os", WindowsOs()), \
243
+ mock.patch.object(telegram_kit.subprocess, "run", side_effect=slow_powershell), \
244
+ self.assertRaises(OSError):
245
+ telegram_kit.write_private(target, "content")
246
+ self.assertEqual(list(pathlib.Path(d).iterdir()), [])
247
+
248
+
249
+ class SubprocessHelperTests(unittest.TestCase):
250
+ def test_run_executes_and_captures_stdout(self):
251
+ code, out = telegram_kit._run([sys.executable, "-c", "print('hi')"])
252
+ self.assertEqual((code, out.strip()), (0, "hi"))
253
+
254
+ def test_unhex_decodes_a_hex_encoded_secret(self):
255
+ self.assertEqual(telegram_kit._unhex(b"hello".hex()), "hello")
256
+
257
+ def test_unhex_leaves_a_non_hex_secret_alone(self):
258
+ self.assertEqual(telegram_kit._unhex("123456:ABCDEF"), "123456:ABCDEF")
259
+
260
+ def test_unhex_leaves_hex_that_is_not_valid_utf8_alone(self):
261
+ garbage = bytes([0xFF, 0xFE]).hex()
262
+ self.assertEqual(telegram_kit._unhex(garbage), garbage)
263
+
264
+ def test_batch_quote_escapes_backslashes_and_quotes(self):
265
+ self.assertEqual(telegram_kit._batch_quote(r'back\slash "quote"'),
266
+ r'back\\slash \"quote\"')
267
+
268
+ def test_batch_quote_rejects_a_newline(self):
269
+ with self.assertRaises(ValueError):
270
+ telegram_kit._batch_quote("line1\nline2")
271
+
272
+ def test_detect_backend_prefers_keychain_on_macos(self):
273
+ with mock.patch.object(telegram_kit, "IS_MACOS", True), \
274
+ mock.patch.object(telegram_kit, "IS_WINDOWS", False), \
275
+ mock.patch.object(telegram_kit.shutil, "which", return_value="/usr/bin/security"):
276
+ self.assertEqual(telegram_kit._detect_backend(), "keychain")
277
+
278
+ def test_detect_backend_prefers_dpapi_on_windows(self):
279
+ with mock.patch.object(telegram_kit, "IS_MACOS", False), \
280
+ mock.patch.object(telegram_kit, "IS_WINDOWS", True), \
281
+ mock.patch.object(telegram_kit.shutil, "which", return_value="powershell.exe"):
282
+ self.assertEqual(telegram_kit._detect_backend(), "dpapi")
283
+
284
+ def test_detect_backend_falls_back_to_libsecret_on_linux(self):
285
+ with mock.patch.object(telegram_kit, "IS_MACOS", False), \
286
+ mock.patch.object(telegram_kit, "IS_WINDOWS", False), \
287
+ mock.patch.object(telegram_kit.shutil, "which", return_value="/usr/bin/secret-tool"):
288
+ self.assertEqual(telegram_kit._detect_backend(), "libsecret")
289
+
290
+ def test_detect_backend_is_none_with_nothing_installed(self):
291
+ with mock.patch.object(telegram_kit, "IS_MACOS", False), \
292
+ mock.patch.object(telegram_kit, "IS_WINDOWS", False), \
293
+ mock.patch.object(telegram_kit.shutil, "which", return_value=None):
294
+ self.assertIsNone(telegram_kit._detect_backend())
295
+
296
+
297
+ class BackendLabelTests(unittest.TestCase):
298
+ def test_available_and_label_reflect_the_detected_backend(self):
299
+ with mock.patch.object(telegram_kit, "backend", lambda: "libsecret"):
300
+ self.assertTrue(telegram_kit.available())
301
+ self.assertEqual(telegram_kit.backend_label(), "Secret Service (libsecret)")
302
+
303
+ def test_unavailable_when_nothing_is_detected(self):
304
+ with mock.patch.object(telegram_kit, "backend", lambda: None):
305
+ self.assertFalse(telegram_kit.available())
306
+ self.assertEqual(telegram_kit.backend_label(), "none")
307
+
308
+ def test_backend_probes_only_once_and_caches_the_result(self):
309
+ telegram_kit.backend.cache_clear()
310
+ try:
311
+ with mock.patch.object(telegram_kit, "_detect_backend", return_value="keychain") as probe:
312
+ self.assertEqual(telegram_kit.backend(), "keychain")
313
+ self.assertEqual(telegram_kit.backend(), "keychain")
314
+ probe.assert_called_once()
315
+ finally:
316
+ telegram_kit.backend.cache_clear()
317
+
318
+
319
+ class DpapiDirTests(unittest.TestCase):
320
+ def test_defaults_to_appdata_when_set(self):
321
+ with mock.patch.dict(os.environ, {"APPDATA": r"C:\Users\wes\AppData\Roaming"}):
322
+ self.assertEqual(telegram_kit._default_dpapi_dir("other-app"),
323
+ pathlib.Path(r"C:\Users\wes\AppData\Roaming") / "other-app")
324
+
325
+ def test_falls_back_to_home_when_appdata_is_unset(self):
326
+ with mock.patch.dict(os.environ):
327
+ os.environ.pop("APPDATA", None)
328
+ self.assertEqual(telegram_kit._default_dpapi_dir("other-app"),
329
+ pathlib.Path.home() / "AppData" / "Roaming" / "other-app")
330
+
331
+
332
+ def _fake_winreg(*, value=None, opening_fails=False):
333
+ module = types.SimpleNamespace(HKEY_CURRENT_USER=object())
334
+
335
+ class _Key:
336
+ def __enter__(self):
337
+ return self
338
+
339
+ def __exit__(self, *exc):
340
+ return False
341
+
342
+ def open_key(hive, path):
343
+ if opening_fails:
344
+ raise OSError("no such registry key")
345
+ return _Key()
346
+
347
+ module.OpenKey = open_key
348
+ module.QueryValueEx = lambda key, name: (value, 1)
349
+ return module
350
+
351
+
352
+ class LegacyWindowsTokenTests(unittest.TestCase):
353
+ def test_false_on_non_windows_without_touching_the_registry(self):
354
+ with mock.patch.object(telegram_kit, "IS_WINDOWS", False):
355
+ self.assertFalse(telegram_kit.legacy_windows_env_token_present())
356
+
357
+ def test_true_when_the_registry_still_holds_a_token(self):
358
+ with mock.patch.object(telegram_kit, "IS_WINDOWS", True), \
359
+ mock.patch.dict(sys.modules, {"winreg": _fake_winreg(value="fake-token")}):
360
+ self.assertTrue(telegram_kit.legacy_windows_env_token_present())
361
+
362
+ def test_false_when_the_stored_value_is_empty(self):
363
+ with mock.patch.object(telegram_kit, "IS_WINDOWS", True), \
364
+ mock.patch.dict(sys.modules, {"winreg": _fake_winreg(value="")}):
365
+ self.assertFalse(telegram_kit.legacy_windows_env_token_present())
366
+
367
+ def test_false_when_the_registry_key_does_not_exist(self):
368
+ with mock.patch.object(telegram_kit, "IS_WINDOWS", True), \
369
+ mock.patch.dict(sys.modules, {"winreg": _fake_winreg(opening_fails=True)}):
370
+ self.assertFalse(telegram_kit.legacy_windows_env_token_present())
371
+
372
+
373
+ class CredentialStoreLibsecretAndKeychainTests(unittest.TestCase):
374
+ """The keychain/libsecret argv shapes get/set/delete build, and that a
375
+ non-zero exit or a raised exception both mean "no secret" rather than a
376
+ crash — mirrors CredentialStoreTests but for the two backends that test
377
+ class's dpapi-focused cases don't touch."""
378
+
379
+ def test_libsecret_get_set_delete_argv(self):
380
+ store = telegram_kit.CredentialStore("other-app")
381
+ run = Recorder((0, "fake-token\n"), (0, ""), (0, ""))
382
+ with pinned("libsecret", run):
383
+ self.assertEqual(store.get("telegram_bot_token"), "fake-token")
384
+ self.assertTrue(store.set("telegram_bot_token", "fake-token"))
385
+ self.assertTrue(store.delete("telegram_bot_token"))
386
+ get_argv, set_argv, delete_argv = (c[0] for c in run.calls)
387
+ self.assertEqual(get_argv, ["secret-tool", "lookup", "service", "other-app",
388
+ "account", "telegram_bot_token"])
389
+ self.assertEqual(set_argv[:2], ["secret-tool", "store"])
390
+ self.assertEqual(run.calls[1][1], "fake-token") # secret travels on stdin, not argv
391
+ self.assertEqual(delete_argv, ["secret-tool", "clear", "service", "other-app",
392
+ "account", "telegram_bot_token"])
393
+
394
+ def test_keychain_delete_argv(self):
395
+ store = telegram_kit.CredentialStore("other-app")
396
+ run = Recorder((0, ""))
397
+ with pinned("keychain", run):
398
+ self.assertTrue(store.delete("telegram_bot_token"))
399
+ self.assertEqual(run.calls[0][0], ["security", "delete-generic-password",
400
+ "-s", "other-app", "-a", "telegram_bot_token"])
401
+
402
+ def test_a_nonzero_exit_reads_as_no_secret_not_a_crash(self):
403
+ store = telegram_kit.CredentialStore("other-app")
404
+ with pinned("keychain", Recorder((1, ""))):
405
+ self.assertEqual(store.get("telegram_bot_token"), "")
406
+
407
+ def test_a_raising_helper_reads_as_no_secret_not_a_crash(self):
408
+ def exploding(argv, stdin=None):
409
+ raise OSError("helper vanished")
410
+ store = telegram_kit.CredentialStore("other-app")
411
+ with pinned("keychain", exploding):
412
+ self.assertEqual(store.get("telegram_bot_token"), "")
413
+ self.assertFalse(store.set("telegram_bot_token", "fake-token"))
414
+ self.assertFalse(store.delete("telegram_bot_token"))
415
+
416
+ def test_setting_an_empty_value_deletes_instead_of_storing_blank(self):
417
+ store = telegram_kit.CredentialStore("other-app")
418
+ run = Recorder((0, ""))
419
+ with pinned("keychain", run):
420
+ self.assertTrue(store.set("telegram_bot_token", ""))
421
+ self.assertEqual(run.calls[0][0][:2], ["security", "delete-generic-password"])
422
+
423
+
424
+ if __name__ == "__main__":
425
+ unittest.main()
@@ -0,0 +1,43 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "ruff"
7
+ version = "0.16.9"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
12
+ { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
13
+ { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
14
+ { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
15
+ { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
16
+ { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
17
+ { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
18
+ { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
19
+ { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
20
+ { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
21
+ { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
22
+ { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
23
+ { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
24
+ { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
25
+ { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
26
+ { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
27
+ { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
28
+ ]
29
+
30
+ [[package]]
31
+ name = "telegram-kit"
32
+ version = "0.1.2"
33
+ source = { editable = "." }
34
+
35
+ [package.dev-dependencies]
36
+ dev = [
37
+ { name = "ruff" },
38
+ ]
39
+
40
+ [package.metadata]
41
+
42
+ [package.metadata.requires-dev]
43
+ dev = [{ name = "ruff", specifier = ">=0.15.21" }]