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.
- telegram_kit-0.1.2/.github/workflows/ci.yml +102 -0
- telegram_kit-0.1.2/.github/workflows/publish.yml +49 -0
- telegram_kit-0.1.2/.gitignore +6 -0
- telegram_kit-0.1.2/CHANGELOG.md +31 -0
- telegram_kit-0.1.2/LICENSE +20 -0
- telegram_kit-0.1.2/PKG-INFO +84 -0
- telegram_kit-0.1.2/README.md +75 -0
- telegram_kit-0.1.2/pyproject.toml +20 -0
- telegram_kit-0.1.2/src/telegram_kit/__init__.py +366 -0
- telegram_kit-0.1.2/tests/__init__.py +1 -0
- telegram_kit-0.1.2/tests/test_telegram_kit.py +425 -0
- telegram_kit-0.1.2/uv.lock +43 -0
|
@@ -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/&/\&/g' -e 's/</\</g' -e 's/>/\>/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,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" }]
|