telegram-post-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: telegram-post-cli
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.11
5
+ Requires-Dist: requests
@@ -0,0 +1,55 @@
1
+ # telegram-post-cli
2
+
3
+ CLI utility for posting messages to Telegram channels via Bot API.
4
+
5
+ ## Prerequisites
6
+
7
+ 1. Create a bot via [@BotFather](https://t.me/BotFather)
8
+ 2. Copy the `Bot Token`
9
+ 3. Add the bot as an **administrator** to your public channel
10
+
11
+ ## Install & run
12
+
13
+ ```bash
14
+ # Run directly (no install needed)
15
+ uvx telegram-post-cli@latest --channel myChannel "Hello Telegram!"
16
+
17
+ # Or install globally
18
+ uv tool install telegram-post-cli
19
+ ```
20
+
21
+ On first run the tool will prompt for your Bot Token and save it to
22
+ `~/.config/telegram-post-cli/config.json`.
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ # Inline text
28
+ telegram-post-cli --channel myChannel "Hello Telegram!"
29
+
30
+ # With image
31
+ telegram-post-cli --channel myChannel --image photo.jpg "Caption"
32
+
33
+ # Image without caption
34
+ telegram-post-cli --channel myChannel --image photo.jpg
35
+
36
+ # From file
37
+ telegram-post-cli --channel myChannel --from-file draft.txt
38
+
39
+ # With parse mode
40
+ telegram-post-cli --channel myChannel --parse-mode HTML "<b>Bold</b>"
41
+
42
+ # Interactive input (Ctrl+D to send)
43
+ telegram-post-cli --channel myChannel
44
+
45
+ # Clear saved credentials and re-prompt
46
+ telegram-post-cli --channel myChannel --reset-keys "Hello again!"
47
+ ```
48
+
49
+ The `--channel` flag accepts a channel username (with or without `@`).
50
+
51
+ ## Tests
52
+
53
+ ```bash
54
+ uv run pytest
55
+ ```
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "telegram-post-cli"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.11"
5
+ dependencies = ["requests"]
6
+
7
+ [project.scripts]
8
+ telegram-post-cli = "telegram_post.cli:main"
9
+
10
+ [tool.pytest.ini_options]
11
+ testpaths = ["tests"]
12
+ pythonpath = ["tests"]
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "pytest>=9.0.2",
21
+ ]
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/telegram_post"]
@@ -0,0 +1,102 @@
1
+ """CLI entry-point for telegram-post."""
2
+
3
+ import argparse
4
+ import pathlib
5
+ import sys
6
+
7
+ from telegram_post.client import TelegramClient, normalize_channel
8
+ from telegram_post.config import ConfigStore, JsonConfigStore, prompt_if_missing
9
+
10
+
11
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
12
+ parser = argparse.ArgumentParser(
13
+ description="Post a message to a Telegram channel via Bot API.",
14
+ )
15
+ parser.add_argument("text", nargs="?", help="Message text (inline)")
16
+ parser.add_argument(
17
+ "--from-file", type=pathlib.Path, help="Read message text from a file",
18
+ )
19
+ parser.add_argument(
20
+ "--image",
21
+ type=pathlib.Path,
22
+ metavar="PATH",
23
+ help="Send a photo (jpg/png/gif/webp, max 10 MB)",
24
+ )
25
+ parser.add_argument(
26
+ "--channel",
27
+ required=True,
28
+ help="Telegram channel (e.g. myChannel, @myChannel, or numeric ID)",
29
+ )
30
+ parser.add_argument(
31
+ "--parse-mode",
32
+ choices=["HTML", "Markdown", "MarkdownV2"],
33
+ help="Message parse mode",
34
+ )
35
+ parser.add_argument(
36
+ "--reset-keys",
37
+ action="store_true",
38
+ help="Clear all saved credentials and re-prompt from scratch",
39
+ )
40
+ return parser.parse_args(argv)
41
+
42
+
43
+ def _read_post_text(args: argparse.Namespace) -> str:
44
+ if args.text:
45
+ return args.text
46
+ if args.from_file:
47
+ return args.from_file.read_text(encoding="utf-8").strip()
48
+ print("Enter message text (Ctrl+D to send):")
49
+ return sys.stdin.read().strip()
50
+
51
+
52
+ def main(argv: list[str] | None = None, *, _config: ConfigStore | None = None) -> None:
53
+ args = _parse_args(argv)
54
+ config = _config or JsonConfigStore()
55
+
56
+ if args.reset_keys:
57
+ config.remove(["bot_token"])
58
+
59
+ if not config.get("bot_token"):
60
+ print(
61
+ "\n"
62
+ "First-time setup\n"
63
+ "================\n"
64
+ "You need a Telegram Bot Token.\n"
65
+ "\n"
66
+ "1. Open https://t.me/BotFather\n"
67
+ "2. Send /newbot and follow the prompts\n"
68
+ "3. Copy the Bot Token below\n",
69
+ )
70
+
71
+ bot_token = prompt_if_missing(config, "bot_token", "Bot Token")
72
+ channel = normalize_channel(args.channel)
73
+
74
+ if args.image:
75
+ if not args.image.exists():
76
+ print(f"Image not found: {args.image}", file=sys.stderr)
77
+ sys.exit(1)
78
+
79
+ text = _read_post_text(args) if not args.image else (args.text or "")
80
+ if not args.image and not text:
81
+ print("Empty message text, aborting.", file=sys.stderr)
82
+ sys.exit(1)
83
+
84
+ client = TelegramClient(bot_token)
85
+
86
+ if args.image:
87
+ caption = text or None
88
+ if args.from_file and not args.text:
89
+ caption = args.from_file.read_text(encoding="utf-8").strip() or None
90
+ try:
91
+ result = client.send_photo(
92
+ channel, args.image, caption=caption, parse_mode=args.parse_mode,
93
+ )
94
+ except ValueError as exc:
95
+ print(str(exc), file=sys.stderr)
96
+ sys.exit(1)
97
+ else:
98
+ result = client.send_message(
99
+ channel, text, parse_mode=args.parse_mode,
100
+ )
101
+
102
+ print(f"Message posted!\n{result.url}")
@@ -0,0 +1,149 @@
1
+ """Telegram Bot API client for posting messages and photos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pathlib
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+ import requests
10
+
11
+ _BASE_URL = "https://api.telegram.org/bot"
12
+ _SUPPORTED_IMAGE_TYPES = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
13
+ _MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PostResult:
18
+ """Result of posting a message to Telegram."""
19
+
20
+ message_id: int
21
+ url: str
22
+
23
+
24
+ class TelegramAPI(Protocol):
25
+ """Interface for Telegram Bot API operations."""
26
+
27
+ def send_message(
28
+ self,
29
+ chat_id: str,
30
+ text: str,
31
+ *,
32
+ parse_mode: str | None = None,
33
+ ) -> PostResult:
34
+ """Send a text message and return the result."""
35
+ ...
36
+
37
+ def send_photo(
38
+ self,
39
+ chat_id: str,
40
+ photo_path: pathlib.Path,
41
+ *,
42
+ caption: str | None = None,
43
+ parse_mode: str | None = None,
44
+ ) -> PostResult:
45
+ """Send a photo with optional caption and return the result."""
46
+ ...
47
+
48
+
49
+ class TelegramClient:
50
+ """HTTP client for Telegram Bot API.
51
+
52
+ Usage::
53
+
54
+ client = TelegramClient(bot_token="123:ABC...")
55
+ result = client.send_message("@mychannel", "Hello!")
56
+ """
57
+
58
+ def __init__(self, bot_token: str) -> None:
59
+ self._api_url = f"{_BASE_URL}{bot_token}"
60
+ self._session = requests.Session()
61
+
62
+ def send_message(
63
+ self,
64
+ chat_id: str,
65
+ text: str,
66
+ *,
67
+ parse_mode: str | None = None,
68
+ ) -> PostResult:
69
+ """Send a text message to a chat/channel."""
70
+ body: dict = {"chat_id": chat_id, "text": text}
71
+ if parse_mode is not None:
72
+ body["parse_mode"] = parse_mode
73
+
74
+ resp = self._session.post(f"{self._api_url}/sendMessage", json=body)
75
+ if not resp.ok:
76
+ raise requests.HTTPError(
77
+ f"{resp.status_code}: {resp.text}", response=resp,
78
+ )
79
+ msg = resp.json()["result"]
80
+ return PostResult(
81
+ message_id=msg["message_id"],
82
+ url=_build_url(chat_id, msg["message_id"]),
83
+ )
84
+
85
+ def send_photo(
86
+ self,
87
+ chat_id: str,
88
+ photo_path: pathlib.Path,
89
+ *,
90
+ caption: str | None = None,
91
+ parse_mode: str | None = None,
92
+ ) -> PostResult:
93
+ """Send a photo with optional caption to a chat/channel.
94
+
95
+ Supports jpg, png, gif, webp up to 10 MB.
96
+ Raises ``ValueError`` for unsupported format or oversized files.
97
+ """
98
+ _validate_image(photo_path)
99
+
100
+ data: dict = {"chat_id": chat_id}
101
+ if caption is not None:
102
+ data["caption"] = caption
103
+ if parse_mode is not None:
104
+ data["parse_mode"] = parse_mode
105
+
106
+ with open(photo_path, "rb") as f:
107
+ resp = self._session.post(
108
+ f"{self._api_url}/sendPhoto",
109
+ data=data,
110
+ files={"photo": f},
111
+ )
112
+ if not resp.ok:
113
+ raise requests.HTTPError(
114
+ f"{resp.status_code}: {resp.text}", response=resp,
115
+ )
116
+ msg = resp.json()["result"]
117
+ return PostResult(
118
+ message_id=msg["message_id"],
119
+ url=_build_url(chat_id, msg["message_id"]),
120
+ )
121
+
122
+
123
+ def normalize_channel(channel: str) -> str:
124
+ """Normalize a public channel username for Telegram API.
125
+
126
+ Prepends ``@`` to bare names; already-prefixed names are returned as-is.
127
+ """
128
+ if channel.startswith("@"):
129
+ return channel
130
+ return f"@{channel}"
131
+
132
+
133
+ def _validate_image(path: pathlib.Path) -> None:
134
+ suffix = path.suffix.lower()
135
+ if suffix not in _SUPPORTED_IMAGE_TYPES:
136
+ raise ValueError(
137
+ f"Unsupported image format '{suffix}'. "
138
+ f"Supported: {', '.join(sorted(_SUPPORTED_IMAGE_TYPES))}",
139
+ )
140
+ size = path.stat().st_size
141
+ if size > _MAX_IMAGE_SIZE:
142
+ raise ValueError(
143
+ f"Image too large ({size / 1024 / 1024:.1f} MB). "
144
+ f"Maximum: {_MAX_IMAGE_SIZE / 1024 / 1024:.0f} MB",
145
+ )
146
+
147
+
148
+ def _build_url(chat_id: str, message_id: int) -> str:
149
+ return f"https://t.me/{chat_id[1:]}/{message_id}"
@@ -0,0 +1,89 @@
1
+ """Persistent JSON config store for credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import sys
9
+ from typing import Callable, Protocol
10
+
11
+ _DEFAULT_PATH = pathlib.Path.home() / ".config" / "telegram-post-cli" / "config.json"
12
+
13
+
14
+ class ConfigStore(Protocol):
15
+ """Read/write access to persistent key-value config."""
16
+
17
+ def get(self, key: str) -> str | None: ...
18
+ def set(self, key: str, value: str) -> None: ...
19
+ def set_many(self, items: dict[str, str]) -> None: ...
20
+ def remove(self, keys: list[str]) -> None: ...
21
+
22
+
23
+ class JsonConfigStore:
24
+ """Config store backed by a JSON file.
25
+
26
+ Default path: ``~/.config/telegram-post-cli/config.json``.
27
+ Creates the directory and file on first write. Sets ``0o600`` permissions
28
+ after each write to protect secrets.
29
+ """
30
+
31
+ def __init__(self, path: pathlib.Path = _DEFAULT_PATH) -> None:
32
+ self._path = path
33
+
34
+ def get(self, key: str) -> str | None:
35
+ data = self._read()
36
+ return data.get(key)
37
+
38
+ def set(self, key: str, value: str) -> None:
39
+ data = self._read()
40
+ data[key] = value
41
+ self._write(data)
42
+
43
+ def set_many(self, items: dict[str, str]) -> None:
44
+ data = self._read()
45
+ data.update(items)
46
+ self._write(data)
47
+
48
+ def remove(self, keys: list[str]) -> None:
49
+ data = self._read()
50
+ for k in keys:
51
+ data.pop(k, None)
52
+ self._write(data)
53
+
54
+ def _read(self) -> dict[str, str]:
55
+ if not self._path.exists():
56
+ return {}
57
+ return json.loads(self._path.read_text(encoding="utf-8"))
58
+
59
+ def _write(self, data: dict[str, str]) -> None:
60
+ self._path.parent.mkdir(parents=True, exist_ok=True)
61
+ self._path.write_text(
62
+ json.dumps(data, indent=2) + "\n", encoding="utf-8",
63
+ )
64
+ os.chmod(self._path, 0o600)
65
+
66
+
67
+ def prompt_if_missing(
68
+ config: ConfigStore,
69
+ key: str,
70
+ display_name: str,
71
+ *,
72
+ prompt_fn: Callable[[str], str] | None = None,
73
+ ) -> str:
74
+ """Return the value for *key*, prompting the user if it's not stored yet.
75
+
76
+ Saves the prompted value into *config* for next time.
77
+ Exits if the user provides an empty value.
78
+ """
79
+ value = config.get(key)
80
+ if value:
81
+ return value
82
+ _prompt = prompt_fn or input
83
+ value = _prompt(f"{display_name}: ")
84
+ if not value or not value.strip():
85
+ print("Value required. Aborting.", file=sys.stderr)
86
+ sys.exit(1)
87
+ value = value.strip()
88
+ config.set(key, value)
89
+ return value
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,25 @@
1
+ """Shared test utilities."""
2
+
3
+
4
+ class DictConfigStore:
5
+ """In-memory ConfigStore for tests."""
6
+
7
+ def __init__(self, data: dict[str, str] | None = None) -> None:
8
+ self._data: dict[str, str] = dict(data) if data else {}
9
+
10
+ def get(self, key: str) -> str | None:
11
+ return self._data.get(key)
12
+
13
+ def set(self, key: str, value: str) -> None:
14
+ self._data[key] = value
15
+
16
+ def set_many(self, items: dict[str, str]) -> None:
17
+ self._data.update(items)
18
+
19
+ def remove(self, keys: list[str]) -> None:
20
+ for k in keys:
21
+ self._data.pop(k, None)
22
+
23
+ @property
24
+ def data(self) -> dict[str, str]:
25
+ return self._data
@@ -0,0 +1,247 @@
1
+ """Unit tests for TelegramClient and CLI."""
2
+
3
+ import pathlib
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from telegram_post.cli import main
9
+ from telegram_post.client import PostResult, TelegramClient, normalize_channel
10
+
11
+ from helpers import DictConfigStore
12
+
13
+
14
+ @pytest.fixture
15
+ def client() -> TelegramClient:
16
+ return TelegramClient(bot_token="123:FAKE")
17
+
18
+
19
+ def _base_config() -> DictConfigStore:
20
+ """Config with bot_token pre-filled."""
21
+ return DictConfigStore({"bot_token": "123:FAKE"})
22
+
23
+
24
+ # --- TelegramClient.send_message ---
25
+
26
+
27
+ class TestSendMessage:
28
+ def test_sends_correct_payload(self, client: TelegramClient) -> None:
29
+ with patch.object(client._session, "post") as mock_post:
30
+ mock_post.return_value = _ok_response(
31
+ {"result": {"message_id": 42}},
32
+ )
33
+ client.send_message("@chan", "Hello!")
34
+
35
+ body = mock_post.call_args.kwargs["json"]
36
+ assert body == {"chat_id": "@chan", "text": "Hello!"}
37
+
38
+ def test_includes_parse_mode(self, client: TelegramClient) -> None:
39
+ with patch.object(client._session, "post") as mock_post:
40
+ mock_post.return_value = _ok_response(
41
+ {"result": {"message_id": 1}},
42
+ )
43
+ client.send_message("@chan", "<b>Bold</b>", parse_mode="HTML")
44
+
45
+ body = mock_post.call_args.kwargs["json"]
46
+ assert body["parse_mode"] == "HTML"
47
+
48
+ def test_returns_post_result(self, client: TelegramClient) -> None:
49
+ with patch.object(client._session, "post") as mock_post:
50
+ mock_post.return_value = _ok_response(
51
+ {"result": {"message_id": 42}},
52
+ )
53
+ result = client.send_message("@mychannel", "test")
54
+ assert isinstance(result, PostResult)
55
+ assert result.message_id == 42
56
+ assert result.url == "https://t.me/mychannel/42"
57
+
58
+ def test_raises_on_400(self, client: TelegramClient) -> None:
59
+ with patch.object(client._session, "post") as mock_post:
60
+ mock_post.return_value = _error_response(400)
61
+ with pytest.raises(Exception):
62
+ client.send_message("@chan", "bad")
63
+
64
+ def test_raises_on_403(self, client: TelegramClient) -> None:
65
+ with patch.object(client._session, "post") as mock_post:
66
+ mock_post.return_value = _error_response(403)
67
+ with pytest.raises(Exception):
68
+ client.send_message("@chan", "forbidden")
69
+
70
+ def test_raises_on_429(self, client: TelegramClient) -> None:
71
+ with patch.object(client._session, "post") as mock_post:
72
+ mock_post.return_value = _error_response(429)
73
+ with pytest.raises(Exception):
74
+ client.send_message("@chan", "rate limited")
75
+
76
+
77
+ # --- TelegramClient.send_photo ---
78
+
79
+
80
+ class TestSendPhoto:
81
+ def test_uploads_photo(
82
+ self, client: TelegramClient, tmp_path: pathlib.Path,
83
+ ) -> None:
84
+ img = tmp_path / "photo.jpg"
85
+ img.write_bytes(b"\xff\xd8" + b"\x00" * 100)
86
+ with patch.object(client._session, "post") as mock_post:
87
+ mock_post.return_value = _ok_response(
88
+ {"result": {"message_id": 10}},
89
+ )
90
+ result = client.send_photo("@chan", img)
91
+ assert result.message_id == 10
92
+ assert mock_post.call_args.kwargs["files"]["photo"] is not None
93
+
94
+ def test_includes_caption(
95
+ self, client: TelegramClient, tmp_path: pathlib.Path,
96
+ ) -> None:
97
+ img = tmp_path / "photo.png"
98
+ img.write_bytes(b"\x89PNG" + b"\x00" * 100)
99
+ with patch.object(client._session, "post") as mock_post:
100
+ mock_post.return_value = _ok_response(
101
+ {"result": {"message_id": 11}},
102
+ )
103
+ client.send_photo("@chan", img, caption="Nice pic")
104
+ data = mock_post.call_args.kwargs["data"]
105
+ assert data["caption"] == "Nice pic"
106
+
107
+ def test_rejects_unsupported_format(
108
+ self, client: TelegramClient, tmp_path: pathlib.Path,
109
+ ) -> None:
110
+ bmp = tmp_path / "image.bmp"
111
+ bmp.write_bytes(b"\x00" * 100)
112
+ with pytest.raises(ValueError, match="Unsupported image format"):
113
+ client.send_photo("@chan", bmp)
114
+
115
+ def test_rejects_oversized_file(
116
+ self, client: TelegramClient, tmp_path: pathlib.Path,
117
+ ) -> None:
118
+ big = tmp_path / "huge.png"
119
+ big.write_bytes(b"\x00" * (10 * 1024 * 1024 + 1))
120
+ with pytest.raises(ValueError, match="Image too large"):
121
+ client.send_photo("@chan", big)
122
+
123
+ def test_raises_on_api_error(
124
+ self, client: TelegramClient, tmp_path: pathlib.Path,
125
+ ) -> None:
126
+ img = tmp_path / "pic.jpg"
127
+ img.write_bytes(b"\xff\xd8" + b"\x00" * 100)
128
+ with patch.object(client._session, "post") as mock_post:
129
+ mock_post.return_value = _error_response(400)
130
+ with pytest.raises(Exception):
131
+ client.send_photo("@chan", img)
132
+
133
+
134
+ # --- normalize_channel ---
135
+
136
+
137
+ class TestNormalizeChannel:
138
+ def test_bare_name(self) -> None:
139
+ assert normalize_channel("myChannel") == "@myChannel"
140
+
141
+ def test_already_prefixed(self) -> None:
142
+ assert normalize_channel("@myChannel") == "@myChannel"
143
+
144
+ def test_numeric_string_gets_prefix(self) -> None:
145
+ assert normalize_channel("12345") == "@12345"
146
+
147
+
148
+ # --- CLI ---
149
+
150
+
151
+ class TestCLIOutput:
152
+ @patch("telegram_post.cli.TelegramClient")
153
+ def test_prints_url(
154
+ self, mock_client_cls: MagicMock,
155
+ capsys: pytest.CaptureFixture[str],
156
+ ) -> None:
157
+ mock_client = mock_client_cls.return_value
158
+ mock_client.send_message.return_value = PostResult(
159
+ message_id=42, url="https://t.me/mychan/42",
160
+ )
161
+ main(["--channel", "mychan", "Hello!"], _config=_base_config())
162
+ out = capsys.readouterr().out
163
+ assert "https://t.me/mychan/42" in out
164
+
165
+ @patch("telegram_post.cli.TelegramClient")
166
+ def test_sends_photo_with_caption(
167
+ self, mock_client_cls: MagicMock,
168
+ tmp_path: pathlib.Path,
169
+ ) -> None:
170
+ img = tmp_path / "photo.jpg"
171
+ img.write_bytes(b"\xff\xd8" + b"\x00" * 100)
172
+
173
+ mock_client = mock_client_cls.return_value
174
+ mock_client.send_photo.return_value = PostResult(
175
+ message_id=55, url="https://t.me/mychan/55",
176
+ )
177
+ main(["--channel", "mychan", "--image", str(img), "Caption"], _config=_base_config())
178
+ mock_client.send_photo.assert_called_once_with(
179
+ "@mychan", img, caption="Caption", parse_mode=None,
180
+ )
181
+
182
+ @patch("telegram_post.cli.TelegramClient")
183
+ def test_sends_photo_without_caption(
184
+ self, mock_client_cls: MagicMock,
185
+ tmp_path: pathlib.Path,
186
+ ) -> None:
187
+ img = tmp_path / "photo.jpg"
188
+ img.write_bytes(b"\xff\xd8" + b"\x00" * 100)
189
+
190
+ mock_client = mock_client_cls.return_value
191
+ mock_client.send_photo.return_value = PostResult(
192
+ message_id=56, url="https://t.me/mychan/56",
193
+ )
194
+ main(["--channel", "mychan", "--image", str(img)], _config=_base_config())
195
+ mock_client.send_photo.assert_called_once_with(
196
+ "@mychan", img, caption=None, parse_mode=None,
197
+ )
198
+
199
+ @patch("telegram_post.cli.TelegramClient")
200
+ def test_passes_parse_mode(
201
+ self, mock_client_cls: MagicMock,
202
+ ) -> None:
203
+ mock_client = mock_client_cls.return_value
204
+ mock_client.send_message.return_value = PostResult(
205
+ message_id=1, url="https://t.me/ch/1",
206
+ )
207
+ main(["--channel", "ch", "--parse-mode", "HTML", "<b>Bold</b>"], _config=_base_config())
208
+ mock_client.send_message.assert_called_once_with(
209
+ "@ch", "<b>Bold</b>", parse_mode="HTML",
210
+ )
211
+
212
+
213
+ class TestCLIValidation:
214
+ def test_rejects_empty_text(self) -> None:
215
+ with pytest.raises(SystemExit), patch("sys.stdin") as mock_stdin:
216
+ mock_stdin.read.return_value = ""
217
+ main(["--channel", "ch"], _config=_base_config())
218
+
219
+ @patch("builtins.input", return_value="")
220
+ def test_rejects_missing_bot_token(self, _input: object) -> None:
221
+ config = DictConfigStore()
222
+ with pytest.raises(SystemExit):
223
+ main(["--channel", "ch", "hello"], _config=config)
224
+
225
+ def test_rejects_missing_channel(self) -> None:
226
+ with pytest.raises(SystemExit):
227
+ main(["hello"], _config=_base_config())
228
+
229
+
230
+ # --- helpers ---
231
+
232
+
233
+ def _ok_response(json_data: dict) -> MagicMock:
234
+ resp = MagicMock()
235
+ resp.status_code = 200
236
+ resp.ok = True
237
+ resp.json.return_value = json_data
238
+ resp.raise_for_status.return_value = None
239
+ return resp
240
+
241
+
242
+ def _error_response(status_code: int) -> MagicMock:
243
+ resp = MagicMock()
244
+ resp.status_code = status_code
245
+ resp.ok = False
246
+ resp.text = "error"
247
+ return resp
@@ -0,0 +1,92 @@
1
+ """Tests for JsonConfigStore and prompt_if_missing."""
2
+
3
+ import json
4
+ import os
5
+ import pathlib
6
+
7
+ import pytest
8
+
9
+ from telegram_post.config import JsonConfigStore, prompt_if_missing
10
+
11
+ from helpers import DictConfigStore
12
+
13
+
14
+ # --- JsonConfigStore ---
15
+
16
+
17
+ class TestJsonConfigStore:
18
+ def test_get_returns_none_when_file_missing(self, tmp_path: pathlib.Path) -> None:
19
+ store = JsonConfigStore(tmp_path / "missing" / "config.json")
20
+ assert store.get("anything") is None
21
+
22
+ def test_set_creates_file_and_dirs(self, tmp_path: pathlib.Path) -> None:
23
+ path = tmp_path / "sub" / "config.json"
24
+ store = JsonConfigStore(path)
25
+ store.set("key", "val")
26
+ assert path.exists()
27
+ assert json.loads(path.read_text()) == {"key": "val"}
28
+
29
+ def test_set_preserves_existing_keys(self, tmp_path: pathlib.Path) -> None:
30
+ path = tmp_path / "config.json"
31
+ store = JsonConfigStore(path)
32
+ store.set("a", "1")
33
+ store.set("b", "2")
34
+ assert json.loads(path.read_text()) == {"a": "1", "b": "2"}
35
+
36
+ def test_set_many_writes_multiple_keys(self, tmp_path: pathlib.Path) -> None:
37
+ path = tmp_path / "config.json"
38
+ store = JsonConfigStore(path)
39
+ store.set_many({"x": "10", "y": "20"})
40
+ assert json.loads(path.read_text()) == {"x": "10", "y": "20"}
41
+
42
+ def test_remove_deletes_keys(self, tmp_path: pathlib.Path) -> None:
43
+ path = tmp_path / "config.json"
44
+ store = JsonConfigStore(path)
45
+ store.set_many({"a": "1", "b": "2", "c": "3"})
46
+ store.remove(["a", "c"])
47
+ assert json.loads(path.read_text()) == {"b": "2"}
48
+
49
+ def test_remove_ignores_missing_keys(self, tmp_path: pathlib.Path) -> None:
50
+ path = tmp_path / "config.json"
51
+ store = JsonConfigStore(path)
52
+ store.set("a", "1")
53
+ store.remove(["nonexistent"])
54
+ assert json.loads(path.read_text()) == {"a": "1"}
55
+
56
+ def test_file_permissions(self, tmp_path: pathlib.Path) -> None:
57
+ path = tmp_path / "config.json"
58
+ store = JsonConfigStore(path)
59
+ store.set("secret", "value")
60
+ mode = os.stat(path).st_mode & 0o777
61
+ assert mode == 0o600
62
+
63
+
64
+ # --- prompt_if_missing ---
65
+
66
+
67
+ class TestPromptIfMissing:
68
+ def test_returns_existing_value_without_prompting(self) -> None:
69
+ config = DictConfigStore({"key": "existing"})
70
+ called = False
71
+
72
+ def fake_prompt(_msg: str) -> str:
73
+ nonlocal called
74
+ called = True
75
+ return "new"
76
+
77
+ result = prompt_if_missing(config, "key", "Key", prompt_fn=fake_prompt)
78
+ assert result == "existing"
79
+ assert not called
80
+
81
+ def test_prompts_and_saves_when_missing(self) -> None:
82
+ config = DictConfigStore()
83
+ result = prompt_if_missing(
84
+ config, "key", "Key", prompt_fn=lambda _: "user-input",
85
+ )
86
+ assert result == "user-input"
87
+ assert config.get("key") == "user-input"
88
+
89
+ def test_exits_on_empty_input(self) -> None:
90
+ config = DictConfigStore()
91
+ with pytest.raises(SystemExit):
92
+ prompt_if_missing(config, "key", "Key", prompt_fn=lambda _: "")
@@ -0,0 +1,198 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "certifi"
7
+ version = "2026.1.4"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "charset-normalizer"
16
+ version = "3.4.4"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
21
+ { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
22
+ { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
23
+ { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
24
+ { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
25
+ { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
26
+ { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
27
+ { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
28
+ { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
29
+ { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
30
+ { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
31
+ { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
32
+ { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
33
+ { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
34
+ { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
35
+ { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
36
+ { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
37
+ { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
38
+ { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
39
+ { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
40
+ { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
41
+ { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
42
+ { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
43
+ { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
44
+ { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
45
+ { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
46
+ { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
47
+ { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
48
+ { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
49
+ { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
50
+ { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
51
+ { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
52
+ { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
53
+ { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
54
+ { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
55
+ { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
56
+ { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
57
+ { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
58
+ { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
59
+ { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
60
+ { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
61
+ { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
62
+ { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
63
+ { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
64
+ { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
65
+ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
66
+ { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
67
+ { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
68
+ { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
69
+ { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
70
+ { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
71
+ { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
72
+ { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
73
+ { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
74
+ { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
75
+ { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
76
+ { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
77
+ { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
78
+ { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
79
+ { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
80
+ { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
81
+ { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
82
+ { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
83
+ { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
84
+ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
85
+ ]
86
+
87
+ [[package]]
88
+ name = "colorama"
89
+ version = "0.4.6"
90
+ source = { registry = "https://pypi.org/simple" }
91
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
92
+ wheels = [
93
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
94
+ ]
95
+
96
+ [[package]]
97
+ name = "idna"
98
+ version = "3.11"
99
+ source = { registry = "https://pypi.org/simple" }
100
+ sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
101
+ wheels = [
102
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
103
+ ]
104
+
105
+ [[package]]
106
+ name = "iniconfig"
107
+ version = "2.3.0"
108
+ source = { registry = "https://pypi.org/simple" }
109
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
110
+ wheels = [
111
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
112
+ ]
113
+
114
+ [[package]]
115
+ name = "packaging"
116
+ version = "26.0"
117
+ source = { registry = "https://pypi.org/simple" }
118
+ sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
119
+ wheels = [
120
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
121
+ ]
122
+
123
+ [[package]]
124
+ name = "pluggy"
125
+ version = "1.6.0"
126
+ source = { registry = "https://pypi.org/simple" }
127
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
128
+ wheels = [
129
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
130
+ ]
131
+
132
+ [[package]]
133
+ name = "pygments"
134
+ version = "2.19.2"
135
+ source = { registry = "https://pypi.org/simple" }
136
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
137
+ wheels = [
138
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
139
+ ]
140
+
141
+ [[package]]
142
+ name = "pytest"
143
+ version = "9.0.2"
144
+ source = { registry = "https://pypi.org/simple" }
145
+ dependencies = [
146
+ { name = "colorama", marker = "sys_platform == 'win32'" },
147
+ { name = "iniconfig" },
148
+ { name = "packaging" },
149
+ { name = "pluggy" },
150
+ { name = "pygments" },
151
+ ]
152
+ sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
153
+ wheels = [
154
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
155
+ ]
156
+
157
+ [[package]]
158
+ name = "requests"
159
+ version = "2.32.5"
160
+ source = { registry = "https://pypi.org/simple" }
161
+ dependencies = [
162
+ { name = "certifi" },
163
+ { name = "charset-normalizer" },
164
+ { name = "idna" },
165
+ { name = "urllib3" },
166
+ ]
167
+ sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
168
+ wheels = [
169
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
170
+ ]
171
+
172
+ [[package]]
173
+ name = "telegram-post-cli"
174
+ version = "0.1.0"
175
+ source = { editable = "." }
176
+ dependencies = [
177
+ { name = "requests" },
178
+ ]
179
+
180
+ [package.dev-dependencies]
181
+ dev = [
182
+ { name = "pytest" },
183
+ ]
184
+
185
+ [package.metadata]
186
+ requires-dist = [{ name = "requests" }]
187
+
188
+ [package.metadata.requires-dev]
189
+ dev = [{ name = "pytest", specifier = ">=9.0.2" }]
190
+
191
+ [[package]]
192
+ name = "urllib3"
193
+ version = "2.6.3"
194
+ source = { registry = "https://pypi.org/simple" }
195
+ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
196
+ wheels = [
197
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
198
+ ]