aiopyrus 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.
- aiopyrus-0.1.0/.gitignore +29 -0
- aiopyrus-0.1.0/PKG-INFO +15 -0
- aiopyrus-0.1.0/aiopyrus/__init__.py +211 -0
- aiopyrus-0.1.0/aiopyrus/api/__init__.py +3 -0
- aiopyrus-0.1.0/aiopyrus/api/session.py +324 -0
- aiopyrus-0.1.0/aiopyrus/bot/__init__.py +20 -0
- aiopyrus-0.1.0/aiopyrus/bot/bot.py +98 -0
- aiopyrus-0.1.0/aiopyrus/bot/dispatcher.py +280 -0
- aiopyrus-0.1.0/aiopyrus/bot/filters/__init__.py +32 -0
- aiopyrus-0.1.0/aiopyrus/bot/filters/base.py +64 -0
- aiopyrus-0.1.0/aiopyrus/bot/filters/builtin.py +217 -0
- aiopyrus-0.1.0/aiopyrus/bot/filters/magic.py +119 -0
- aiopyrus-0.1.0/aiopyrus/bot/middleware.py +38 -0
- aiopyrus-0.1.0/aiopyrus/bot/router.py +165 -0
- aiopyrus-0.1.0/aiopyrus/bot/webhook/__init__.py +3 -0
- aiopyrus-0.1.0/aiopyrus/bot/webhook/server.py +91 -0
- aiopyrus-0.1.0/aiopyrus/exceptions.py +39 -0
- aiopyrus-0.1.0/aiopyrus/py.typed +0 -0
- aiopyrus-0.1.0/aiopyrus/types/__init__.py +85 -0
- aiopyrus-0.1.0/aiopyrus/types/base.py +12 -0
- aiopyrus-0.1.0/aiopyrus/types/catalog.py +44 -0
- aiopyrus-0.1.0/aiopyrus/types/file.py +24 -0
- aiopyrus-0.1.0/aiopyrus/types/form.py +281 -0
- aiopyrus-0.1.0/aiopyrus/types/task.py +396 -0
- aiopyrus-0.1.0/aiopyrus/types/user.py +84 -0
- aiopyrus-0.1.0/aiopyrus/types/webhook.py +54 -0
- aiopyrus-0.1.0/aiopyrus/user/__init__.py +3 -0
- aiopyrus-0.1.0/aiopyrus/user/client.py +984 -0
- aiopyrus-0.1.0/aiopyrus/utils/__init__.py +3 -0
- aiopyrus-0.1.0/aiopyrus/utils/context.py +703 -0
- aiopyrus-0.1.0/aiopyrus/utils/crypto.py +20 -0
- aiopyrus-0.1.0/aiopyrus/utils/fields.py +179 -0
- aiopyrus-0.1.0/aiopyrus/utils/rate_limiter.py +91 -0
- aiopyrus-0.1.0/examples/01_quickstart.py +62 -0
- aiopyrus-0.1.0/examples/02_task_context.py +184 -0
- aiopyrus-0.1.0/examples/03_bot_webhook.py +216 -0
- aiopyrus-0.1.0/examples/04_bot_polling.py +115 -0
- aiopyrus-0.1.0/examples/05_data_management.py +238 -0
- aiopyrus-0.1.0/pyproject.toml +34 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
|
|
12
|
+
# Virtual environments
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
env/
|
|
16
|
+
|
|
17
|
+
# IDE
|
|
18
|
+
.idea/
|
|
19
|
+
.vscode/
|
|
20
|
+
*.swp
|
|
21
|
+
*.swo
|
|
22
|
+
|
|
23
|
+
# OS
|
|
24
|
+
.DS_Store
|
|
25
|
+
Thumbs.db
|
|
26
|
+
|
|
27
|
+
# Secrets / local config
|
|
28
|
+
.env
|
|
29
|
+
test_live.py
|
aiopyrus-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiopyrus
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python library for Pyrus API — Aiogram-style, HTTPX-powered
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: api,async,bot,httpx,pyrus
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: aiofiles>=23.0.0
|
|
9
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
10
|
+
Requires-Dist: httpx>=0.27.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: respx>=0.21.0; extra == 'dev'
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""aiopyrus — Async Pyrus API library, aiogram-style, HTTPX-powered.
|
|
2
|
+
|
|
3
|
+
Work with Pyrus tasks using **human-readable field names** (exactly as shown in
|
|
4
|
+
the Pyrus UI) without knowing field IDs, choice_id values, or person_id numbers.
|
|
5
|
+
|
|
6
|
+
Работайте с задачами Pyrus через **имена полей**, как в интерфейсе —
|
|
7
|
+
без знания ID, choice_id или person_id.
|
|
8
|
+
|
|
9
|
+
Quick start / Быстрый старт
|
|
10
|
+
----------------------------
|
|
11
|
+
|
|
12
|
+
**User client — one-shot automation / Разовые операции**::
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from aiopyrus import UserClient
|
|
16
|
+
|
|
17
|
+
async def main():
|
|
18
|
+
async with UserClient(login="user@example.com", security_key="KEY") as client:
|
|
19
|
+
ctx = await client.task_context(12345678)
|
|
20
|
+
|
|
21
|
+
# Read fields by name (as shown in the Pyrus UI)
|
|
22
|
+
# Читаем поля по имени из интерфейса
|
|
23
|
+
status = ctx["Статус задачи"] # → "Открыта" / "Open"
|
|
24
|
+
executor = ctx["Исполнитель"] # → "Иванов Иван"
|
|
25
|
+
|
|
26
|
+
# Lazy write + send / Запись (ленивая) + отправка
|
|
27
|
+
ctx.set("Статус задачи", "В работе").set("Исполнитель", "ivanov")
|
|
28
|
+
await ctx.answer("Задача принята в работу") # or any text
|
|
29
|
+
|
|
30
|
+
# Time tracking / Трекинг времени
|
|
31
|
+
await ctx.log_time(60, "Incident analysis")
|
|
32
|
+
|
|
33
|
+
# Reassign / Переназначить
|
|
34
|
+
await ctx.reassign("Иванов Иван", "Passing this to you")
|
|
35
|
+
|
|
36
|
+
# Reply to a comment / Ответить на комментарий
|
|
37
|
+
first = ctx.task.comments[0]
|
|
38
|
+
await ctx.reply(first.id, "Please clarify the details")
|
|
39
|
+
|
|
40
|
+
# Finish / Завершить
|
|
41
|
+
ctx.set("Статус задачи", "Выполнена")
|
|
42
|
+
await ctx.approve("Processing complete")
|
|
43
|
+
|
|
44
|
+
asyncio.run(main())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
**Bot — webhook-driven, aiogram-style / Вебхук-бот**::
|
|
48
|
+
|
|
49
|
+
import asyncio
|
|
50
|
+
from aiopyrus import PyrusBot, Dispatcher, Router
|
|
51
|
+
from aiopyrus.bot import FormFilter, StepFilter
|
|
52
|
+
from aiopyrus.utils.context import TaskContext
|
|
53
|
+
|
|
54
|
+
bot = PyrusBot(login="bot@example", security_key="SECRET")
|
|
55
|
+
dp = Dispatcher()
|
|
56
|
+
router = Router()
|
|
57
|
+
|
|
58
|
+
@router.task_received(FormFilter(321), StepFilter(1))
|
|
59
|
+
async def on_invoice(ctx: TaskContext):
|
|
60
|
+
amount = float(ctx["Сумма"])
|
|
61
|
+
if amount > 100_000:
|
|
62
|
+
await ctx.reject("Сумма превышает лимит — отклонено.")
|
|
63
|
+
else:
|
|
64
|
+
ctx.set("Статус задачи", "Одобрено")
|
|
65
|
+
await ctx.approve("Одобрено автоматически.")
|
|
66
|
+
|
|
67
|
+
dp.include_router(router)
|
|
68
|
+
|
|
69
|
+
asyncio.run(dp.start_webhook(bot, host="0.0.0.0", port=8080, path="/pyrus"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
TaskContext — method reference / Справочник методов
|
|
73
|
+
----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
Full docs: ``aiopyrus.utils.context`` (module docstring).
|
|
76
|
+
|
|
77
|
+
+-----------------------------------+---------------------------------------+
|
|
78
|
+
| Method / Метод | Description / Описание |
|
|
79
|
+
+===================================+=======================================+
|
|
80
|
+
| ``ctx["Field"]`` | Read field value (human-readable) |
|
|
81
|
+
+-----------------------------------+---------------------------------------+
|
|
82
|
+
| ``ctx.get("Field", default)`` | Read with default / с дефолтом |
|
|
83
|
+
+-----------------------------------+---------------------------------------+
|
|
84
|
+
| ``ctx.set("Field", value)`` | Schedule write (lazy) / ленивая |
|
|
85
|
+
+-----------------------------------+---------------------------------------+
|
|
86
|
+
| ``ctx.discard()`` | Drop uncommitted set()-s |
|
|
87
|
+
+-----------------------------------+---------------------------------------+
|
|
88
|
+
| ``await ctx.answer("text")`` | Comment + flush all set()-s |
|
|
89
|
+
+-----------------------------------+---------------------------------------+
|
|
90
|
+
| ``await ctx.approve("text")`` | Approve approval step / утвердить |
|
|
91
|
+
+-----------------------------------+---------------------------------------+
|
|
92
|
+
| ``await ctx.reject("text")`` | Reject approval step / отклонить |
|
|
93
|
+
+-----------------------------------+---------------------------------------+
|
|
94
|
+
| ``await ctx.finish("text")`` | Finish the task / завершить задачу |
|
|
95
|
+
+-----------------------------------+---------------------------------------+
|
|
96
|
+
| ``await ctx.reassign("Name")`` | Reassign (string → person_id auto) |
|
|
97
|
+
+-----------------------------------+---------------------------------------+
|
|
98
|
+
| ``await ctx.log_time(min)`` | Log time spent / трекинг времени |
|
|
99
|
+
+-----------------------------------+---------------------------------------+
|
|
100
|
+
| ``await ctx.reply(id, "text")`` | Reply to a comment / ответить |
|
|
101
|
+
+-----------------------------------+---------------------------------------+
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
from .bot.bot import PyrusBot
|
|
105
|
+
from .bot.dispatcher import Dispatcher
|
|
106
|
+
from .bot.filters import F, FormFilter, StepFilter, ResponsibleFilter, TextFilter, EventFilter, FieldValueFilter
|
|
107
|
+
from .bot.middleware import BaseMiddleware
|
|
108
|
+
from .bot.router import Router
|
|
109
|
+
from .exceptions import (
|
|
110
|
+
PyrusAPIError,
|
|
111
|
+
PyrusAuthError,
|
|
112
|
+
PyrusError,
|
|
113
|
+
PyrusNotFoundError,
|
|
114
|
+
PyrusPermissionError,
|
|
115
|
+
PyrusRateLimitError,
|
|
116
|
+
PyrusWebhookSignatureError,
|
|
117
|
+
)
|
|
118
|
+
from .types import (
|
|
119
|
+
Announcement,
|
|
120
|
+
ApprovalChoice,
|
|
121
|
+
ApprovalEntry,
|
|
122
|
+
Attachment,
|
|
123
|
+
BotResponse,
|
|
124
|
+
Catalog,
|
|
125
|
+
CatalogFieldValue,
|
|
126
|
+
CatalogItem,
|
|
127
|
+
CatalogSyncResult,
|
|
128
|
+
Channel,
|
|
129
|
+
ChannelContact,
|
|
130
|
+
ChannelType,
|
|
131
|
+
Comment,
|
|
132
|
+
CommentChannel,
|
|
133
|
+
ContactsResponse,
|
|
134
|
+
Form,
|
|
135
|
+
FormField,
|
|
136
|
+
FormLinkValue,
|
|
137
|
+
InboxResponse,
|
|
138
|
+
MultipleChoiceValue,
|
|
139
|
+
Person,
|
|
140
|
+
Profile,
|
|
141
|
+
RegisterResponse,
|
|
142
|
+
Role,
|
|
143
|
+
SubscriberEntry,
|
|
144
|
+
TableRow,
|
|
145
|
+
Task,
|
|
146
|
+
TitleValue,
|
|
147
|
+
UploadedFile,
|
|
148
|
+
WebhookPayload,
|
|
149
|
+
)
|
|
150
|
+
from .user.client import UserClient
|
|
151
|
+
from .utils.context import TaskContext
|
|
152
|
+
|
|
153
|
+
__version__ = "0.1.0"
|
|
154
|
+
_CODENAME = "Перезрелая груша с кривым API" # 🍐
|
|
155
|
+
__all__ = [
|
|
156
|
+
# Clients & context
|
|
157
|
+
"UserClient",
|
|
158
|
+
"TaskContext",
|
|
159
|
+
"PyrusBot",
|
|
160
|
+
# Bot infrastructure
|
|
161
|
+
"Dispatcher",
|
|
162
|
+
"Router",
|
|
163
|
+
"BaseMiddleware",
|
|
164
|
+
# Filters
|
|
165
|
+
"F",
|
|
166
|
+
"FormFilter",
|
|
167
|
+
"StepFilter",
|
|
168
|
+
"ResponsibleFilter",
|
|
169
|
+
"TextFilter",
|
|
170
|
+
"EventFilter",
|
|
171
|
+
"FieldValueFilter",
|
|
172
|
+
# Types
|
|
173
|
+
"Task",
|
|
174
|
+
"Comment",
|
|
175
|
+
"ApprovalChoice",
|
|
176
|
+
"ApprovalEntry",
|
|
177
|
+
"SubscriberEntry",
|
|
178
|
+
"Channel",
|
|
179
|
+
"ChannelType",
|
|
180
|
+
"ChannelContact",
|
|
181
|
+
"CommentChannel",
|
|
182
|
+
"Form",
|
|
183
|
+
"FormField",
|
|
184
|
+
"CatalogFieldValue",
|
|
185
|
+
"MultipleChoiceValue",
|
|
186
|
+
"TitleValue",
|
|
187
|
+
"FormLinkValue",
|
|
188
|
+
"TableRow",
|
|
189
|
+
"Person",
|
|
190
|
+
"Role",
|
|
191
|
+
"Profile",
|
|
192
|
+
"Catalog",
|
|
193
|
+
"CatalogItem",
|
|
194
|
+
"CatalogSyncResult",
|
|
195
|
+
"Attachment",
|
|
196
|
+
"UploadedFile",
|
|
197
|
+
"ContactsResponse",
|
|
198
|
+
"InboxResponse",
|
|
199
|
+
"RegisterResponse",
|
|
200
|
+
"Announcement",
|
|
201
|
+
"WebhookPayload",
|
|
202
|
+
"BotResponse",
|
|
203
|
+
# Exceptions
|
|
204
|
+
"PyrusError",
|
|
205
|
+
"PyrusAPIError",
|
|
206
|
+
"PyrusAuthError",
|
|
207
|
+
"PyrusNotFoundError",
|
|
208
|
+
"PyrusPermissionError",
|
|
209
|
+
"PyrusRateLimitError",
|
|
210
|
+
"PyrusWebhookSignatureError",
|
|
211
|
+
]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from aiopyrus.exceptions import (
|
|
11
|
+
PyrusAPIError,
|
|
12
|
+
PyrusAuthError,
|
|
13
|
+
PyrusNotFoundError,
|
|
14
|
+
PyrusPermissionError,
|
|
15
|
+
PyrusRateLimitError,
|
|
16
|
+
)
|
|
17
|
+
from aiopyrus.utils.rate_limiter import RateLimiter
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("aiopyrus.session")
|
|
20
|
+
|
|
21
|
+
_DEFAULT_AUTH_URL = "https://accounts.pyrus.com/api/v4/auth"
|
|
22
|
+
_DEFAULT_API_URL = "https://api.pyrus.com/v4/"
|
|
23
|
+
_DEFAULT_FILES_URL = "https://files.pyrus.com/"
|
|
24
|
+
|
|
25
|
+
# 401 codes that mean the token is permanently invalid — re-auth won't help
|
|
26
|
+
_PERMANENT_AUTH_ERRORS = frozenset({"revoked_token", "account_blocked"})
|
|
27
|
+
|
|
28
|
+
# Proxy / server errors that are transient and worth a single retry
|
|
29
|
+
_TRANSIENT_STATUS = frozenset({502, 503, 504})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _retry_wait(response: httpx.Response, default: float) -> float:
|
|
33
|
+
"""Return how many seconds to wait before retrying.
|
|
34
|
+
|
|
35
|
+
Checks ``Retry-After`` (standard) and ``X-RateLimit-Reset`` (Pyrus)
|
|
36
|
+
headers; falls back to *default* if neither is present or parseable.
|
|
37
|
+
"""
|
|
38
|
+
for header in ("Retry-After", "X-RateLimit-Reset"):
|
|
39
|
+
value = response.headers.get(header)
|
|
40
|
+
if value:
|
|
41
|
+
try:
|
|
42
|
+
return max(0.0, float(value))
|
|
43
|
+
except ValueError:
|
|
44
|
+
pass
|
|
45
|
+
return default
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PyrusSession:
|
|
49
|
+
"""Low-level async HTTPX session for Pyrus API.
|
|
50
|
+
|
|
51
|
+
Handles authentication, token refresh, and raw HTTP calls.
|
|
52
|
+
|
|
53
|
+
For corporate / self-hosted Pyrus instances supply ``api_url`` and
|
|
54
|
+
``auth_url`` explicitly, e.g.::
|
|
55
|
+
|
|
56
|
+
session = PyrusSession(
|
|
57
|
+
login="user@example.com",
|
|
58
|
+
security_key="KEY",
|
|
59
|
+
auth_url="https://pyrus.example.com/api/v4/auth",
|
|
60
|
+
api_url="https://pyrus.example.com/api/v4/",
|
|
61
|
+
)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
login: str,
|
|
67
|
+
security_key: str,
|
|
68
|
+
person_id: int | None = None,
|
|
69
|
+
*,
|
|
70
|
+
timeout: float = 30.0,
|
|
71
|
+
auth_url: str | None = None,
|
|
72
|
+
api_url: str | None = None,
|
|
73
|
+
files_url: str | None = None,
|
|
74
|
+
proxy: str | None = None,
|
|
75
|
+
requests_per_second: int | None = None,
|
|
76
|
+
requests_per_minute: int | None = None,
|
|
77
|
+
requests_per_10min: int = 5000,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._login = login
|
|
80
|
+
self._security_key = security_key
|
|
81
|
+
self._person_id = person_id
|
|
82
|
+
self._timeout = timeout
|
|
83
|
+
self._proxy = proxy
|
|
84
|
+
self._rate_limiter = RateLimiter(
|
|
85
|
+
requests_per_second=requests_per_second,
|
|
86
|
+
requests_per_minute=requests_per_minute,
|
|
87
|
+
requests_per_10min=requests_per_10min,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Custom URLs override defaults; api_url is also updated from auth response
|
|
91
|
+
self._auth_url: str = auth_url or _DEFAULT_AUTH_URL
|
|
92
|
+
self._access_token: str | None = None
|
|
93
|
+
self._api_url: str = api_url or _DEFAULT_API_URL
|
|
94
|
+
self._files_url: str = files_url or _DEFAULT_FILES_URL
|
|
95
|
+
# Track whether api_url was explicitly set (don't override with auth response then)
|
|
96
|
+
self._api_url_explicit: bool = api_url is not None
|
|
97
|
+
|
|
98
|
+
self._client: httpx.AsyncClient | None = None
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Client lifecycle
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _build_client(self) -> httpx.AsyncClient:
|
|
105
|
+
kwargs: dict[str, Any] = {
|
|
106
|
+
"timeout": self._timeout,
|
|
107
|
+
"follow_redirects": True,
|
|
108
|
+
}
|
|
109
|
+
if self._proxy:
|
|
110
|
+
kwargs["proxy"] = self._proxy
|
|
111
|
+
log.debug("Using proxy: %s", self._proxy)
|
|
112
|
+
return httpx.AsyncClient(**kwargs)
|
|
113
|
+
|
|
114
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
115
|
+
if self._client is None or self._client.is_closed:
|
|
116
|
+
self._client = self._build_client()
|
|
117
|
+
return self._client
|
|
118
|
+
|
|
119
|
+
async def close(self) -> None:
|
|
120
|
+
"""Close the underlying HTTPX client."""
|
|
121
|
+
if self._client and not self._client.is_closed:
|
|
122
|
+
await self._client.aclose()
|
|
123
|
+
self._client = None
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Authentication
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
async def auth(self) -> str:
|
|
130
|
+
"""Authenticate with the Pyrus API and store the access token.
|
|
131
|
+
|
|
132
|
+
Returns the access_token string.
|
|
133
|
+
"""
|
|
134
|
+
payload: dict[str, Any] = {
|
|
135
|
+
"login": self._login,
|
|
136
|
+
"security_key": self._security_key,
|
|
137
|
+
}
|
|
138
|
+
if self._person_id is not None:
|
|
139
|
+
payload["person_id"] = self._person_id
|
|
140
|
+
|
|
141
|
+
client = await self._get_client()
|
|
142
|
+
response = await client.post(self._auth_url, json=payload)
|
|
143
|
+
|
|
144
|
+
data = response.json()
|
|
145
|
+
|
|
146
|
+
if response.status_code != 200 or "access_token" not in data:
|
|
147
|
+
error = data.get("error", "Authentication failed")
|
|
148
|
+
error_code = data.get("error_code")
|
|
149
|
+
raise PyrusAuthError(error, error_code, response.status_code)
|
|
150
|
+
|
|
151
|
+
token: str = data["access_token"]
|
|
152
|
+
self._access_token = token
|
|
153
|
+
|
|
154
|
+
# Only update api_url / files_url from the response if not explicitly set
|
|
155
|
+
# and if the server returns them (standard cloud Pyrus does; corp instances may not).
|
|
156
|
+
if not self._api_url_explicit:
|
|
157
|
+
if "api_url" in data:
|
|
158
|
+
self._api_url = data["api_url"]
|
|
159
|
+
elif "api_url" not in data:
|
|
160
|
+
# Corp instance: derive api_url from auth_url (strip /auth suffix)
|
|
161
|
+
base = self._auth_url
|
|
162
|
+
if base.endswith("/auth"):
|
|
163
|
+
self._api_url = base[: -len("auth")] # keep trailing slash
|
|
164
|
+
if "files_url" in data:
|
|
165
|
+
self._files_url = data["files_url"]
|
|
166
|
+
|
|
167
|
+
log.debug("Authenticated as %s (api_url=%s)", self._login, self._api_url)
|
|
168
|
+
return token
|
|
169
|
+
|
|
170
|
+
def set_token(self, token: str, api_url: str | None = None) -> None:
|
|
171
|
+
"""Manually set the access token (e.g. from a webhook payload)."""
|
|
172
|
+
self._access_token = token
|
|
173
|
+
if api_url:
|
|
174
|
+
self._api_url = api_url
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def is_authenticated(self) -> bool:
|
|
178
|
+
return self._access_token is not None
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
# Request helpers
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
185
|
+
if not self._access_token:
|
|
186
|
+
raise PyrusAuthError("Not authenticated. Call auth() first.", status_code=401)
|
|
187
|
+
return {"Authorization": f"Bearer {self._access_token}"}
|
|
188
|
+
|
|
189
|
+
async def _handle_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
190
|
+
"""Parse the response and raise appropriate exceptions on errors."""
|
|
191
|
+
try:
|
|
192
|
+
data: dict = response.json()
|
|
193
|
+
except Exception:
|
|
194
|
+
data = {}
|
|
195
|
+
|
|
196
|
+
remaining = response.headers.get("X-RateLimit-Remaining")
|
|
197
|
+
log.debug(
|
|
198
|
+
" status=%d keys=%s rl_remaining=%s",
|
|
199
|
+
response.status_code,
|
|
200
|
+
list(data.keys()) if isinstance(data, dict) else type(data).__name__,
|
|
201
|
+
remaining,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if response.status_code == 200:
|
|
205
|
+
return data
|
|
206
|
+
|
|
207
|
+
error = data.get("error", response.text or "Unknown error")
|
|
208
|
+
error_code = data.get("error_code")
|
|
209
|
+
|
|
210
|
+
if response.status_code == 401:
|
|
211
|
+
raise PyrusAuthError(error, error_code, 401)
|
|
212
|
+
if response.status_code == 403:
|
|
213
|
+
raise PyrusPermissionError(error, error_code, 403)
|
|
214
|
+
if response.status_code == 404:
|
|
215
|
+
raise PyrusNotFoundError(error, error_code, 404)
|
|
216
|
+
if response.status_code == 429:
|
|
217
|
+
raise PyrusRateLimitError(error, error_code, 429)
|
|
218
|
+
|
|
219
|
+
raise PyrusAPIError(error, error_code, response.status_code)
|
|
220
|
+
|
|
221
|
+
async def request(
|
|
222
|
+
self,
|
|
223
|
+
method: str,
|
|
224
|
+
path: str,
|
|
225
|
+
*,
|
|
226
|
+
json: Any = None,
|
|
227
|
+
params: dict | None = None,
|
|
228
|
+
data: Any = None,
|
|
229
|
+
files: Any = None,
|
|
230
|
+
headers: dict | None = None,
|
|
231
|
+
use_files_url: bool = False,
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
"""Make an authenticated API request.
|
|
234
|
+
|
|
235
|
+
Auto-retries once on 401 by re-authenticating.
|
|
236
|
+
Blocks if the configured rate limit is reached.
|
|
237
|
+
"""
|
|
238
|
+
base = self._files_url if use_files_url else self._api_url
|
|
239
|
+
url = f"{base.rstrip('/')}/{path.lstrip('/')}"
|
|
240
|
+
|
|
241
|
+
client = await self._get_client()
|
|
242
|
+
|
|
243
|
+
# Lazy auth: authenticate on first API call if no token yet
|
|
244
|
+
if not self._access_token:
|
|
245
|
+
await self.auth()
|
|
246
|
+
|
|
247
|
+
# Rate limiting: block here if needed (counted once per logical request)
|
|
248
|
+
await self._rate_limiter.acquire()
|
|
249
|
+
|
|
250
|
+
req_headers = {**self._auth_headers()}
|
|
251
|
+
if headers:
|
|
252
|
+
req_headers.update(headers)
|
|
253
|
+
|
|
254
|
+
log.debug("→ %s %s body=%s", method, url, list(json.keys()) if isinstance(json, dict) else json)
|
|
255
|
+
|
|
256
|
+
async def _do_request() -> httpx.Response:
|
|
257
|
+
return await client.request(
|
|
258
|
+
method, url,
|
|
259
|
+
json=json, params=params, data=data, files=files,
|
|
260
|
+
headers=req_headers,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
t0 = time.perf_counter()
|
|
264
|
+
response = await _do_request()
|
|
265
|
+
|
|
266
|
+
# --- 401: token expired → re-auth and retry once (skip if permanently revoked)
|
|
267
|
+
if response.status_code == 401 and self._login:
|
|
268
|
+
try:
|
|
269
|
+
err_code = response.json().get("error_code", "")
|
|
270
|
+
except Exception:
|
|
271
|
+
err_code = ""
|
|
272
|
+
if err_code not in _PERMANENT_AUTH_ERRORS:
|
|
273
|
+
log.debug("Token expired (%s), re-authenticating …", err_code or "unknown")
|
|
274
|
+
await self.auth()
|
|
275
|
+
req_headers.update(self._auth_headers())
|
|
276
|
+
t0 = time.perf_counter()
|
|
277
|
+
response = await _do_request()
|
|
278
|
+
|
|
279
|
+
# --- 429: server-side rate limit → honour Retry-After and retry once
|
|
280
|
+
elif response.status_code == 429:
|
|
281
|
+
wait = _retry_wait(response, default=60.0)
|
|
282
|
+
log.warning("Server rate limit (429), waiting %.0fs before retry …", wait)
|
|
283
|
+
await asyncio.sleep(wait)
|
|
284
|
+
t0 = time.perf_counter()
|
|
285
|
+
response = await _do_request()
|
|
286
|
+
|
|
287
|
+
# --- 502 / 503 / 504: transient proxy error (Angie / NGINX) → retry once
|
|
288
|
+
elif response.status_code in _TRANSIENT_STATUS:
|
|
289
|
+
wait = _retry_wait(response, default=5.0)
|
|
290
|
+
log.warning(
|
|
291
|
+
"Transient proxy error %d, retrying in %.0fs …",
|
|
292
|
+
response.status_code, wait,
|
|
293
|
+
)
|
|
294
|
+
await asyncio.sleep(wait)
|
|
295
|
+
t0 = time.perf_counter()
|
|
296
|
+
response = await _do_request()
|
|
297
|
+
|
|
298
|
+
elapsed = time.perf_counter() - t0
|
|
299
|
+
log.debug("← %s %s %.0fms", method, path, elapsed * 1000)
|
|
300
|
+
return await self._handle_response(response)
|
|
301
|
+
|
|
302
|
+
# Convenience shorthands
|
|
303
|
+
async def get(self, path: str, *, params: dict | None = None) -> dict:
|
|
304
|
+
return await self.request("GET", path, params=params)
|
|
305
|
+
|
|
306
|
+
async def post(self, path: str, *, json: Any = None, files: Any = None, data: Any = None) -> dict:
|
|
307
|
+
return await self.request("POST", path, json=json, files=files, data=data)
|
|
308
|
+
|
|
309
|
+
async def put(self, path: str, *, json: Any = None) -> dict:
|
|
310
|
+
return await self.request("PUT", path, json=json)
|
|
311
|
+
|
|
312
|
+
async def delete(self, path: str) -> dict:
|
|
313
|
+
return await self.request("DELETE", path)
|
|
314
|
+
|
|
315
|
+
# ------------------------------------------------------------------
|
|
316
|
+
# Context manager support
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
async def __aenter__(self) -> "PyrusSession":
|
|
320
|
+
await self._get_client()
|
|
321
|
+
return self
|
|
322
|
+
|
|
323
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
324
|
+
await self.close()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .bot import PyrusBot
|
|
2
|
+
from .dispatcher import Dispatcher
|
|
3
|
+
from .filters import F, EventFilter, FieldValueFilter, FormFilter, ResponsibleFilter, StepFilter, TextFilter
|
|
4
|
+
from .middleware import BaseMiddleware
|
|
5
|
+
from .router import Router
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PyrusBot",
|
|
9
|
+
"Dispatcher",
|
|
10
|
+
"Router",
|
|
11
|
+
"BaseMiddleware",
|
|
12
|
+
# Filters
|
|
13
|
+
"F",
|
|
14
|
+
"FormFilter",
|
|
15
|
+
"StepFilter",
|
|
16
|
+
"ResponsibleFilter",
|
|
17
|
+
"TextFilter",
|
|
18
|
+
"EventFilter",
|
|
19
|
+
"FieldValueFilter",
|
|
20
|
+
]
|