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.
Files changed (39) hide show
  1. aiopyrus-0.1.0/.gitignore +29 -0
  2. aiopyrus-0.1.0/PKG-INFO +15 -0
  3. aiopyrus-0.1.0/aiopyrus/__init__.py +211 -0
  4. aiopyrus-0.1.0/aiopyrus/api/__init__.py +3 -0
  5. aiopyrus-0.1.0/aiopyrus/api/session.py +324 -0
  6. aiopyrus-0.1.0/aiopyrus/bot/__init__.py +20 -0
  7. aiopyrus-0.1.0/aiopyrus/bot/bot.py +98 -0
  8. aiopyrus-0.1.0/aiopyrus/bot/dispatcher.py +280 -0
  9. aiopyrus-0.1.0/aiopyrus/bot/filters/__init__.py +32 -0
  10. aiopyrus-0.1.0/aiopyrus/bot/filters/base.py +64 -0
  11. aiopyrus-0.1.0/aiopyrus/bot/filters/builtin.py +217 -0
  12. aiopyrus-0.1.0/aiopyrus/bot/filters/magic.py +119 -0
  13. aiopyrus-0.1.0/aiopyrus/bot/middleware.py +38 -0
  14. aiopyrus-0.1.0/aiopyrus/bot/router.py +165 -0
  15. aiopyrus-0.1.0/aiopyrus/bot/webhook/__init__.py +3 -0
  16. aiopyrus-0.1.0/aiopyrus/bot/webhook/server.py +91 -0
  17. aiopyrus-0.1.0/aiopyrus/exceptions.py +39 -0
  18. aiopyrus-0.1.0/aiopyrus/py.typed +0 -0
  19. aiopyrus-0.1.0/aiopyrus/types/__init__.py +85 -0
  20. aiopyrus-0.1.0/aiopyrus/types/base.py +12 -0
  21. aiopyrus-0.1.0/aiopyrus/types/catalog.py +44 -0
  22. aiopyrus-0.1.0/aiopyrus/types/file.py +24 -0
  23. aiopyrus-0.1.0/aiopyrus/types/form.py +281 -0
  24. aiopyrus-0.1.0/aiopyrus/types/task.py +396 -0
  25. aiopyrus-0.1.0/aiopyrus/types/user.py +84 -0
  26. aiopyrus-0.1.0/aiopyrus/types/webhook.py +54 -0
  27. aiopyrus-0.1.0/aiopyrus/user/__init__.py +3 -0
  28. aiopyrus-0.1.0/aiopyrus/user/client.py +984 -0
  29. aiopyrus-0.1.0/aiopyrus/utils/__init__.py +3 -0
  30. aiopyrus-0.1.0/aiopyrus/utils/context.py +703 -0
  31. aiopyrus-0.1.0/aiopyrus/utils/crypto.py +20 -0
  32. aiopyrus-0.1.0/aiopyrus/utils/fields.py +179 -0
  33. aiopyrus-0.1.0/aiopyrus/utils/rate_limiter.py +91 -0
  34. aiopyrus-0.1.0/examples/01_quickstart.py +62 -0
  35. aiopyrus-0.1.0/examples/02_task_context.py +184 -0
  36. aiopyrus-0.1.0/examples/03_bot_webhook.py +216 -0
  37. aiopyrus-0.1.0/examples/04_bot_polling.py +115 -0
  38. aiopyrus-0.1.0/examples/05_data_management.py +238 -0
  39. 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
@@ -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,3 @@
1
+ from .session import PyrusSession
2
+
3
+ __all__ = ["PyrusSession"]
@@ -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
+ ]