telethon-floodgate 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.
Files changed (27) hide show
  1. telethon_floodgate-0.1.0/.github/workflows/ci.yml +38 -0
  2. telethon_floodgate-0.1.0/.gitignore +19 -0
  3. telethon_floodgate-0.1.0/CHANGELOG.md +15 -0
  4. telethon_floodgate-0.1.0/LICENSE +21 -0
  5. telethon_floodgate-0.1.0/PKG-INFO +182 -0
  6. telethon_floodgate-0.1.0/README.md +148 -0
  7. telethon_floodgate-0.1.0/pyproject.toml +63 -0
  8. telethon_floodgate-0.1.0/scripts/release_pypi.sh +117 -0
  9. telethon_floodgate-0.1.0/setup.cfg +4 -0
  10. telethon_floodgate-0.1.0/src/telethon_floodgate/__init__.py +97 -0
  11. telethon_floodgate-0.1.0/src/telethon_floodgate/_datetime.py +37 -0
  12. telethon_floodgate-0.1.0/src/telethon_floodgate/flood_breaker.py +238 -0
  13. telethon_floodgate-0.1.0/src/telethon_floodgate/flood_wait.py +222 -0
  14. telethon_floodgate-0.1.0/src/telethon_floodgate/peer.py +93 -0
  15. telethon_floodgate-0.1.0/src/telethon_floodgate/py.typed +0 -0
  16. telethon_floodgate-0.1.0/src/telethon_floodgate/rate_limit_gate.py +251 -0
  17. telethon_floodgate-0.1.0/src/telethon_floodgate/rate_limiter.py +134 -0
  18. telethon_floodgate-0.1.0/src/telethon_floodgate.egg-info/PKG-INFO +182 -0
  19. telethon_floodgate-0.1.0/src/telethon_floodgate.egg-info/SOURCES.txt +25 -0
  20. telethon_floodgate-0.1.0/src/telethon_floodgate.egg-info/dependency_links.txt +1 -0
  21. telethon_floodgate-0.1.0/src/telethon_floodgate.egg-info/requires.txt +10 -0
  22. telethon_floodgate-0.1.0/src/telethon_floodgate.egg-info/top_level.txt +1 -0
  23. telethon_floodgate-0.1.0/tests/test_flood_breaker.py +206 -0
  24. telethon_floodgate-0.1.0/tests/test_flood_wait.py +231 -0
  25. telethon_floodgate-0.1.0/tests/test_peer.py +76 -0
  26. telethon_floodgate-0.1.0/tests/test_rate_limit_gate.py +220 -0
  27. telethon_floodgate-0.1.0/tests/test_resolve_rate_limiter.py +110 -0
@@ -0,0 +1,38 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.11"
16
+ - name: Install
17
+ run: pip install -e ".[dev]"
18
+ - name: Ruff
19
+ run: ruff check src tests
20
+ - name: Build + twine check
21
+ run: |
22
+ python -m build
23
+ python -m twine check dist/*
24
+
25
+ tests:
26
+ runs-on: ubuntu-latest
27
+ strategy:
28
+ matrix:
29
+ python-version: ["3.11", "3.12", "3.13"]
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/setup-python@v5
33
+ with:
34
+ python-version: ${{ matrix.python-version }}
35
+ - name: Install
36
+ run: pip install -e ".[dev]"
37
+ - name: Pytest
38
+ run: python -m pytest -q
@@ -0,0 +1,19 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .eggs/
8
+
9
+ # Environments
10
+ .venv/
11
+ venv/
12
+ .env
13
+
14
+ # Tooling caches
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ .mypy_cache/
18
+ .coverage
19
+ htmlcov/
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ Initial release — 1:1 extraction of the flood stack from the tg_content_factory
6
+ project, plus per-peer send limits.
7
+
8
+ - `TelegramRateLimitGate`: proactive per-(phone, category) sliding windows
9
+ plus per-peer send buckets `(phone, category, peer)` — independent from the
10
+ category budget, with `peer_key()` extraction from Telethon entities
11
+ (`peer.py`) and `TelegramPeerRateLimitedError`
12
+ - `FloodCircuitBreaker`: pybreaker-based suspension per (operation, phone)
13
+ - `ResolveRateLimiter`: sliding-window limiter for `auth.resolveUsername`
14
+ - flood-wait helpers: `run_with_flood_wait`, `run_with_flood_wait_retry`,
15
+ transient/blocking classification, sleep helpers, `FloodWaitInfo`
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Axisrow
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 OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: telethon-floodgate
3
+ Version: 0.1.0
4
+ Summary: Flood-wait handling, proactive rate-limit gate and circuit breaker for Telethon user accounts
5
+ Author: Axisrow
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/axisrow/telethon-floodgate
8
+ Project-URL: Repository, https://github.com/axisrow/telethon-floodgate
9
+ Project-URL: Changelog, https://github.com/axisrow/telethon-floodgate/blob/main/CHANGELOG.md
10
+ Keywords: telegram,telethon,flood-wait,rate-limit,floodwait,mtproto
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Communications :: Chat
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: telethon<2,>=1.36
25
+ Requires-Dist: pydantic<3,>=2.0
26
+ Requires-Dist: pybreaker>=1.4.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
30
+ Requires-Dist: ruff>=0.8; extra == "dev"
31
+ Requires-Dist: build>=1.2; extra == "dev"
32
+ Requires-Dist: twine>=6.0; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # telethon-floodgate
36
+
37
+ Flood-wait handling for Telethon user accounts: a proactive rate-limit gate, a
38
+ reactive circuit breaker, and flood-wait sleep/retry helpers — three layers
39
+ that keep a working account out of Telegram's multi-hour FLOOD_WAIT bans.
40
+
41
+ Telegram does not publish rate limits. This library is the production flood
42
+ stack of a multi-account Telegram content collector, calibrated against real
43
+ incidents: a fresh session firing 160+ `auth.resolveUsername` calls in seconds
44
+ escalated into 15–18 hour bans, and repeated `getDialogs` sweeps ran a account
45
+ into a 14.8-hour ban. The defaults are deliberately conservative and every
46
+ bucket is configurable.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install telethon-floodgate
52
+ ```
53
+
54
+ Requires Python 3.11+, [Telethon](https://github.com/LonamiWebs/Telethon) 1.x,
55
+ pydantic 2.x and pybreaker.
56
+
57
+ ## The three layers
58
+
59
+ ### 1. Proactive rate-limit gate — before the call
60
+
61
+ ```python
62
+ from telethon_floodgate import TelegramRateLimitGate, TelegramRateLimitedError
63
+
64
+ gate = TelegramRateLimitGate() # create once, next to your client pool
65
+
66
+ def before_telegram_call(phone: str, operation: str) -> None:
67
+ category = gate.category_for(operation) # "send", "history", "dialogs", ...
68
+ retry_after = gate.try_acquire(phone, category)
69
+ if retry_after > 0:
70
+ raise TelegramRateLimitedError(phone, category, retry_after)
71
+ ```
72
+
73
+ Buckets are independent sliding windows keyed by `(phone, category)`:
74
+ `dialogs` (1/min — the #1330 incident), `dialog_sweep`, `history`,
75
+ `admin_action`, `send` (30/min), `channel_lifecycle` (3 per 5 min), plus a
76
+ permissive `default`. Override any of them:
77
+
78
+ ```python
79
+ from telethon_floodgate import RateLimitSpec, TelegramRateLimitGate
80
+
81
+ gate = TelegramRateLimitGate(
82
+ category_limits={"send": RateLimitSpec(max_calls=10, window_sec=60.0)}
83
+ )
84
+ ```
85
+
86
+ `resolve` and `reaction` are intentionally no-op categories: in the origin
87
+ project those paths have dedicated limiters (see `ResolveRateLimiter`, also
88
+ shipped here).
89
+
90
+ ### Per-peer send limits
91
+
92
+ Telegram throttles *sending* per peer, not just per account: roughly one
93
+ message per second to the same private chat and about twenty per minute into
94
+ the same group or channel. Pass a peer key to `try_acquire` and the gate
95
+ checks a second, independent `(phone, category, peer)` bucket before the
96
+ category one:
97
+
98
+ ```python
99
+ from telethon_floodgate import peer_key
100
+
101
+ retry_after = gate.try_acquire(phone, "send", peer=peer_key(entity))
102
+ if retry_after > 0:
103
+ raise TelegramPeerRateLimitedError(phone, peer_key(entity), retry_after)
104
+ ```
105
+
106
+ - A per-peer refusal does **not** consume the account-wide category slot, so a
107
+ burst aimed at one peer cannot burn the account budget.
108
+ - `peer_key()` derives `"user:123"` / `"channel:-100123"` / `"chat:-456"` /
109
+ `"username:durov"` / `"id:123"` from ints, strings, Telethon TL peers, input
110
+ peers and full entities — purely local attribute access, never a network
111
+ round-trip.
112
+ - Only `send:user`, `send:channel` and `send:chat` are configured by default;
113
+ unknown kinds pass through unthrottled. Override or disable via `peer_limits`
114
+ (a `"send:user"` entry with a permissive spec effectively turns it off) and
115
+ cap memory with `peer_max_buckets` (LRU, default 4096).
116
+
117
+ ```python
118
+ gate = TelegramRateLimitGate(
119
+ peer_limits={"send:user": RateLimitSpec(max_calls=1, window_sec=5.0)},
120
+ peer_max_buckets=4096,
121
+ )
122
+ ```
123
+
124
+ `TelegramPeerRateLimitedError` subclasses `TelegramRateLimitedError`, so one
125
+ "operation unavailable, move on" handler covers every layer.
126
+
127
+ ### 2. Reactive circuit breaker — when Telegram answers anyway
128
+
129
+ ```python
130
+ from telethon_floodgate import FloodCircuitBreaker
131
+
132
+ breaker = FloodCircuitBreaker(threshold=3, cooldown_seconds=300)
133
+
134
+ breaker.check(operation, phone) # raises TelegramOperationSuspendedError
135
+ # while the pair is suspended
136
+ try:
137
+ await client.some_call()
138
+ breaker.record_success(operation, phone)
139
+ except telethon.errors.FloodWaitError:
140
+ breaker.record_flood(operation, phone)
141
+ ```
142
+
143
+ After `threshold` flood waits on the same `(operation, phone)` the pair is
144
+ suspended for `cooldown_seconds`, then exactly one half-open trial call is
145
+ allowed. `TelegramOperationSuspendedError` subclasses
146
+ `TelegramRateLimitedError`, so a single "operation unavailable, move on"
147
+ handler covers both layers.
148
+
149
+ ### 3. Flood-wait helpers — classify, sleep, retry, report
150
+
151
+ ```python
152
+ from telethon_floodgate import (
153
+ HandledFloodWaitError, FloodWaitInfo,
154
+ handle_flood_wait, run_with_flood_wait, run_with_flood_wait_retry,
155
+ is_transient_flood_wait_seconds, is_blocking_flood_wait_until,
156
+ )
157
+
158
+ # wraps one awaitable: FloodWaitError -> HandledFloodWaitError (+ pool report)
159
+ await run_with_flood_wait(client.get_dialogs(), operation="warm", phone=phone, pool=pool)
160
+
161
+ # retries transient waits (<=60s) under a total budget (120s default)
162
+ await run_with_flood_wait_retry(factory, operation="fetch", phone=phone, pool=pool)
163
+ ```
164
+
165
+ `handle_flood_wait` reports the wait back to the pool via
166
+ `await pool.report_flood(phone, seconds)` so account rotation can skip the
167
+ flooded account — persist that to your own storage; the library is
168
+ storage-agnostic.
169
+
170
+ ## Design notes
171
+
172
+ - Everything runs on one event loop; the limiter state is plain in-memory
173
+ deques, no locks, no DB.
174
+ - `try_acquire` never sleeps and never raises — it returns seconds to defer,
175
+ so callers decide whether to reschedule, skip or await.
176
+ - Atomic multi-slot reservations (`try_acquire(..., slots=n)`) are supported
177
+ for compound operations.
178
+
179
+ ## License
180
+
181
+ MIT — see [LICENSE](LICENSE). Unofficial third-party library; not affiliated
182
+ with the Telethon project.
@@ -0,0 +1,148 @@
1
+ # telethon-floodgate
2
+
3
+ Flood-wait handling for Telethon user accounts: a proactive rate-limit gate, a
4
+ reactive circuit breaker, and flood-wait sleep/retry helpers — three layers
5
+ that keep a working account out of Telegram's multi-hour FLOOD_WAIT bans.
6
+
7
+ Telegram does not publish rate limits. This library is the production flood
8
+ stack of a multi-account Telegram content collector, calibrated against real
9
+ incidents: a fresh session firing 160+ `auth.resolveUsername` calls in seconds
10
+ escalated into 15–18 hour bans, and repeated `getDialogs` sweeps ran a account
11
+ into a 14.8-hour ban. The defaults are deliberately conservative and every
12
+ bucket is configurable.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install telethon-floodgate
18
+ ```
19
+
20
+ Requires Python 3.11+, [Telethon](https://github.com/LonamiWebs/Telethon) 1.x,
21
+ pydantic 2.x and pybreaker.
22
+
23
+ ## The three layers
24
+
25
+ ### 1. Proactive rate-limit gate — before the call
26
+
27
+ ```python
28
+ from telethon_floodgate import TelegramRateLimitGate, TelegramRateLimitedError
29
+
30
+ gate = TelegramRateLimitGate() # create once, next to your client pool
31
+
32
+ def before_telegram_call(phone: str, operation: str) -> None:
33
+ category = gate.category_for(operation) # "send", "history", "dialogs", ...
34
+ retry_after = gate.try_acquire(phone, category)
35
+ if retry_after > 0:
36
+ raise TelegramRateLimitedError(phone, category, retry_after)
37
+ ```
38
+
39
+ Buckets are independent sliding windows keyed by `(phone, category)`:
40
+ `dialogs` (1/min — the #1330 incident), `dialog_sweep`, `history`,
41
+ `admin_action`, `send` (30/min), `channel_lifecycle` (3 per 5 min), plus a
42
+ permissive `default`. Override any of them:
43
+
44
+ ```python
45
+ from telethon_floodgate import RateLimitSpec, TelegramRateLimitGate
46
+
47
+ gate = TelegramRateLimitGate(
48
+ category_limits={"send": RateLimitSpec(max_calls=10, window_sec=60.0)}
49
+ )
50
+ ```
51
+
52
+ `resolve` and `reaction` are intentionally no-op categories: in the origin
53
+ project those paths have dedicated limiters (see `ResolveRateLimiter`, also
54
+ shipped here).
55
+
56
+ ### Per-peer send limits
57
+
58
+ Telegram throttles *sending* per peer, not just per account: roughly one
59
+ message per second to the same private chat and about twenty per minute into
60
+ the same group or channel. Pass a peer key to `try_acquire` and the gate
61
+ checks a second, independent `(phone, category, peer)` bucket before the
62
+ category one:
63
+
64
+ ```python
65
+ from telethon_floodgate import peer_key
66
+
67
+ retry_after = gate.try_acquire(phone, "send", peer=peer_key(entity))
68
+ if retry_after > 0:
69
+ raise TelegramPeerRateLimitedError(phone, peer_key(entity), retry_after)
70
+ ```
71
+
72
+ - A per-peer refusal does **not** consume the account-wide category slot, so a
73
+ burst aimed at one peer cannot burn the account budget.
74
+ - `peer_key()` derives `"user:123"` / `"channel:-100123"` / `"chat:-456"` /
75
+ `"username:durov"` / `"id:123"` from ints, strings, Telethon TL peers, input
76
+ peers and full entities — purely local attribute access, never a network
77
+ round-trip.
78
+ - Only `send:user`, `send:channel` and `send:chat` are configured by default;
79
+ unknown kinds pass through unthrottled. Override or disable via `peer_limits`
80
+ (a `"send:user"` entry with a permissive spec effectively turns it off) and
81
+ cap memory with `peer_max_buckets` (LRU, default 4096).
82
+
83
+ ```python
84
+ gate = TelegramRateLimitGate(
85
+ peer_limits={"send:user": RateLimitSpec(max_calls=1, window_sec=5.0)},
86
+ peer_max_buckets=4096,
87
+ )
88
+ ```
89
+
90
+ `TelegramPeerRateLimitedError` subclasses `TelegramRateLimitedError`, so one
91
+ "operation unavailable, move on" handler covers every layer.
92
+
93
+ ### 2. Reactive circuit breaker — when Telegram answers anyway
94
+
95
+ ```python
96
+ from telethon_floodgate import FloodCircuitBreaker
97
+
98
+ breaker = FloodCircuitBreaker(threshold=3, cooldown_seconds=300)
99
+
100
+ breaker.check(operation, phone) # raises TelegramOperationSuspendedError
101
+ # while the pair is suspended
102
+ try:
103
+ await client.some_call()
104
+ breaker.record_success(operation, phone)
105
+ except telethon.errors.FloodWaitError:
106
+ breaker.record_flood(operation, phone)
107
+ ```
108
+
109
+ After `threshold` flood waits on the same `(operation, phone)` the pair is
110
+ suspended for `cooldown_seconds`, then exactly one half-open trial call is
111
+ allowed. `TelegramOperationSuspendedError` subclasses
112
+ `TelegramRateLimitedError`, so a single "operation unavailable, move on"
113
+ handler covers both layers.
114
+
115
+ ### 3. Flood-wait helpers — classify, sleep, retry, report
116
+
117
+ ```python
118
+ from telethon_floodgate import (
119
+ HandledFloodWaitError, FloodWaitInfo,
120
+ handle_flood_wait, run_with_flood_wait, run_with_flood_wait_retry,
121
+ is_transient_flood_wait_seconds, is_blocking_flood_wait_until,
122
+ )
123
+
124
+ # wraps one awaitable: FloodWaitError -> HandledFloodWaitError (+ pool report)
125
+ await run_with_flood_wait(client.get_dialogs(), operation="warm", phone=phone, pool=pool)
126
+
127
+ # retries transient waits (<=60s) under a total budget (120s default)
128
+ await run_with_flood_wait_retry(factory, operation="fetch", phone=phone, pool=pool)
129
+ ```
130
+
131
+ `handle_flood_wait` reports the wait back to the pool via
132
+ `await pool.report_flood(phone, seconds)` so account rotation can skip the
133
+ flooded account — persist that to your own storage; the library is
134
+ storage-agnostic.
135
+
136
+ ## Design notes
137
+
138
+ - Everything runs on one event loop; the limiter state is plain in-memory
139
+ deques, no locks, no DB.
140
+ - `try_acquire` never sleeps and never raises — it returns seconds to defer,
141
+ so callers decide whether to reschedule, skip or await.
142
+ - Atomic multi-slot reservations (`try_acquire(..., slots=n)`) are supported
143
+ for compound operations.
144
+
145
+ ## License
146
+
147
+ MIT — see [LICENSE](LICENSE). Unofficial third-party library; not affiliated
148
+ with the Telethon project.
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "telethon-floodgate"
7
+ version = "0.1.0"
8
+ description = "Flood-wait handling, proactive rate-limit gate and circuit breaker for Telethon user accounts"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ authors = [{name = "Axisrow"}]
12
+ requires-python = ">=3.11"
13
+ keywords = ["telegram", "telethon", "flood-wait", "rate-limit", "floodwait", "mtproto"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Communications :: Chat",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "telethon>=1.36,<2",
28
+ "pydantic>=2.0,<3",
29
+ "pybreaker>=1.4.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/axisrow/telethon-floodgate"
34
+ Repository = "https://github.com/axisrow/telethon-floodgate"
35
+ Changelog = "https://github.com/axisrow/telethon-floodgate/blob/main/CHANGELOG.md"
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "pytest>=8.0",
40
+ "pytest-asyncio>=0.24",
41
+ "ruff>=0.8",
42
+ "build>=1.2",
43
+ "twine>=6.0",
44
+ ]
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
48
+
49
+ [tool.setuptools.package-data]
50
+ telethon_floodgate = ["py.typed"]
51
+
52
+ [tool.ruff]
53
+ target-version = "py311"
54
+ line-length = 120
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "N", "W"]
58
+
59
+ [tool.pytest.ini_options]
60
+ asyncio_mode = "auto"
61
+ asyncio_default_fixture_loop_scope = "function"
62
+ testpaths = ["tests"]
63
+ filterwarnings = ["error"]
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
6
+ ENV_FILE="${ROOT_DIR}/.env"
7
+
8
+ usage() {
9
+ cat <<'EOF'
10
+ Usage:
11
+ scripts/release_pypi.sh testpypi
12
+ scripts/release_pypi.sh pypi
13
+ scripts/release_pypi.sh all
14
+
15
+ Behavior:
16
+ - loads .env from the repository root when present
17
+ - rebuilds dist artifacts from scratch
18
+ - runs twine checks before upload
19
+ - uploads to TestPyPI, PyPI, or both
20
+
21
+ Required .env variables:
22
+ TWINE_USERNAME=__token__
23
+ TEST_PYPI_TOKEN=pypi-... # for testpypi/all
24
+ PYPI_TOKEN=pypi-... # for pypi/all
25
+ EOF
26
+ }
27
+
28
+ if [[ $# -ne 1 ]]; then
29
+ usage
30
+ exit 1
31
+ fi
32
+
33
+ TARGET="$1"
34
+
35
+ case "${TARGET}" in
36
+ testpypi|pypi|all)
37
+ ;;
38
+ *)
39
+ usage
40
+ exit 1
41
+ ;;
42
+ esac
43
+
44
+ if [[ -f "${ENV_FILE}" ]]; then
45
+ set -a
46
+ # shellcheck disable=SC1090
47
+ source "${ENV_FILE}"
48
+ set +a
49
+ fi
50
+
51
+ TWINE_USERNAME="${TWINE_USERNAME:-__token__}"
52
+
53
+ require_var() {
54
+ local name="$1"
55
+ if [[ -z "${!name:-}" ]]; then
56
+ echo "Missing required environment variable: ${name}" >&2
57
+ exit 1
58
+ fi
59
+ }
60
+
61
+ require_command() {
62
+ local name="$1"
63
+ if ! command -v "${name}" >/dev/null 2>&1; then
64
+ echo "Required command not found: ${name}" >&2
65
+ exit 1
66
+ fi
67
+ }
68
+
69
+ build_artifacts() {
70
+ require_command python3
71
+
72
+ echo "Cleaning old build artifacts"
73
+ rm -rf "${ROOT_DIR}/dist" "${ROOT_DIR}/build" "${ROOT_DIR}"/*.egg-info
74
+
75
+ echo "Building package"
76
+ (
77
+ cd "${ROOT_DIR}"
78
+ python3 -m build --no-isolation
79
+ )
80
+
81
+ echo "Checking artifacts with twine"
82
+ (
83
+ cd "${ROOT_DIR}"
84
+ python3 -m twine check dist/*
85
+ )
86
+ }
87
+
88
+ upload_target() {
89
+ local repo_url="$1"
90
+ local password_var="$2"
91
+ local label="$3"
92
+
93
+ require_var "${password_var}"
94
+
95
+ echo "Uploading to ${label}"
96
+ (
97
+ cd "${ROOT_DIR}"
98
+ TWINE_USERNAME="${TWINE_USERNAME}" \
99
+ TWINE_PASSWORD="${!password_var}" \
100
+ python3 -m twine upload --non-interactive --repository-url "${repo_url}" dist/*
101
+ )
102
+ }
103
+
104
+ build_artifacts
105
+
106
+ case "${TARGET}" in
107
+ testpypi)
108
+ upload_target "https://test.pypi.org/legacy/" "TEST_PYPI_TOKEN" "TestPyPI"
109
+ ;;
110
+ pypi)
111
+ upload_target "https://upload.pypi.org/legacy/" "PYPI_TOKEN" "PyPI"
112
+ ;;
113
+ all)
114
+ upload_target "https://test.pypi.org/legacy/" "TEST_PYPI_TOKEN" "TestPyPI"
115
+ upload_target "https://upload.pypi.org/legacy/" "PYPI_TOKEN" "PyPI"
116
+ ;;
117
+ esac
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+