{"id":45498646,"url":"https://github.com/timmekhw/aiopyrus","last_synced_at":"2026-03-03T23:00:28.042Z","repository":{"id":339984382,"uuid":"1164074275","full_name":"TimmekHW/aiopyrus","owner":"TimmekHW","description":"Асинхронная Python-библиотека для Pyrus API. Стиль — как у aiogram.","archived":false,"fork":false,"pushed_at":"2026-03-03T21:53:01.000Z","size":276,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-03T22:59:17.133Z","etag":null,"topics":["aiogram-style","async","asyncio","bot","framework","httpx","polling","pyrus","pyrus-api","python","task-management","workflow-automation"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/TimmekHW.png","metadata":{"files":{"readme":"README.en.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-22T15:50:31.000Z","updated_at":"2026-03-03T21:53:19.000Z","dependencies_parsed_at":null,"dependency_job_id":"881dc637-ce7e-4418-89a9-1783f5f6ddd1","html_url":"https://github.com/TimmekHW/aiopyrus","commit_stats":null,"previous_names":["timmekhw/aiopyrus"],"tags_count":17,"template":false,"template_full_name":null,"purl":"pkg:github/TimmekHW/aiopyrus","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TimmekHW%2Faiopyrus","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TimmekHW%2Faiopyrus/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TimmekHW%2Faiopyrus/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TimmekHW%2Faiopyrus/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/TimmekHW","download_url":"https://codeload.github.com/TimmekHW/aiopyrus/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TimmekHW%2Faiopyrus/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":30064764,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-03T18:21:05.932Z","status":"ssl_error","status_checked_at":"2026-03-03T18:20:59.341Z","response_time":61,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aiogram-style","async","asyncio","bot","framework","httpx","polling","pyrus","pyrus-api","python","task-management","workflow-automation"],"created_at":"2026-02-22T18:07:25.113Z","updated_at":"2026-03-03T23:00:28.033Z","avatar_url":"https://github.com/TimmekHW.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# aiopyrus\n\n[![PyPI](https://img.shields.io/pypi/v/aiopyrus)](https://pypi.org/project/aiopyrus/)\n[![Python](https://img.shields.io/pypi/pyversions/aiopyrus)](https://pypi.org/project/aiopyrus/)\n[![CI](https://github.com/TimmekHW/aiopyrus/actions/workflows/ci.yml/badge.svg)](https://github.com/TimmekHW/aiopyrus/actions/workflows/ci.yml)\n[![Downloads](https://static.pepy.tech/badge/aiopyrus/month)](https://pepy.tech/projects/aiopyrus)\n[![License](https://img.shields.io/github/license/TimmekHW/aiopyrus)](LICENSE)\n\nAsync Python library for the [Pyrus API](https://pyrus.com/en/help/api).\nAiogram-style architecture. Powered by HTTPX.\n\n\u003e **[Русская версия](README.md)**\n\n## Three Modes of Operation\n\n### UserClient — async scripts under your own account\n\nTask automation, data exports, bulk operations — **under your own Pyrus account**.\nNo bot registration needed, no public server required.\n\n```python\nimport asyncio\nfrom aiopyrus import UserClient\n\nasync def main():\n    async with UserClient(login=\"user@example.com\", security_key=\"KEY\") as client:\n        profile = await client.get_profile()\n        print(f\"Hello, {profile.first_name}!\")\n\n        ctx = await client.task_context(12345678)\n        print(ctx.get(\"Task Status\", \"not set\"))\n\nasyncio.run(main())\n```\n\n### SyncClient — simple scripts without async/await\n\nSame functionality as `UserClient`, but without `async/await`.\nFor scripts, Jupyter notebooks, and simple integrations.\n\n```python\nfrom aiopyrus import SyncClient\n\nwith SyncClient(login=\"user@example.com\", security_key=\"KEY\") as client:\n    profile = client.get_profile()\n    print(f\"Hello, {profile.first_name}!\")\n\n    ctx = client.task_context(12345678)\n    print(ctx[\"Task Status\"])\n```\n\n### PyrusBot — webhook / polling bot\n\nIncoming task processing, automatic approvals, routing — aiogram-style.\n\n```python\nbot = PyrusBot(login=\"bot@example\", security_key=\"SECRET\")\ndp = Dispatcher()\n```\n\n\u003e More about bots — [below](#webhook-bot).\n\n---\n\n## How to Get Your security_key\n\n1. In Pyrus, click **Settings** (gear icon, bottom left)\n2. Go to **Authorization** ([pyrus.com/t#authorize](https://pyrus.com/t#authorize))\n3. Copy the **Secret API Key**\n\nNow you can run scripts under your own account:\n\n```python\nclient = UserClient(login=\"you@company.com\", security_key=\"\u003cyour key\u003e\")\n```\n\n---\n\n## Key Feature — TaskContext\n\nWork with tasks using **field names as they appear in the Pyrus UI** — no `field_id`, `choice_id`, or `person_id` needed.\n\n```python\nctx = await client.task_context(12345678)\n\nstatus   = ctx[\"Task Status\"]           # multiple_choice → str\nexecutor = ctx[\"Assignee\"]              # person → \"First Last\"\n\nctx.set(\"Task Status\", \"In Progress\")   # choice name → choice_id automatically\nctx.set(\"Assignee\", \"John Smith\")       # name → person_id automatically\nawait ctx.answer(\"Accepted\")\n```\n\n## Installation\n\n```bash\npip install aiopyrus\n```\n\nPython 3.10+\n\n## TaskContext — Method Reference\n\n| Method | Description |\n|---|---|\n| `ctx[\"Field\"]` | Read (KeyError if missing) |\n| `ctx.get(\"Field\", default)` | Read with default |\n| `ctx.raw(\"Field\")` | Raw `FormField` object |\n| `ctx.find(\"%pattern%\")` | Wildcard search (SQL LIKE style) |\n| `ctx.set(\"Field\", value)` | Lazy write (chainable) |\n| `ctx.discard()` | Cancel pending `set()` calls |\n| `ctx.pending_count()` | Number of pending `set()` calls |\n| `await ctx.answer(\"text\")` | Comment + flush all `set()` calls |\n| `await ctx.answer(\"text\", private=True)` | Private comment |\n| `await ctx.approve(\"text\")` | Approve an approval step |\n| `await ctx.reject(\"text\")` | Reject an approval step |\n| `await ctx.finish(\"text\")` | Finish (close) the task |\n| `await ctx.reassign(\"Name\")` | Reassign (name → person_id auto) |\n| `await ctx.log_time(90, \"text\")` | Log time spent (minutes) |\n| `await ctx.reply(comment_id, \"text\")` | Reply to a comment (threaded) |\n\n## Webhook Bot\n\n```python\nimport asyncio\nfrom aiopyrus import PyrusBot, Dispatcher, Router, FormFilter, StepFilter\nfrom aiopyrus.utils.context import TaskContext\n\nbot = PyrusBot(login=\"bot@example\", security_key=\"SECRET\")\ndp = Dispatcher()\nrouter = Router()\n\n@router.task_received(FormFilter(321), StepFilter(2))\nasync def on_invoice(ctx: TaskContext):\n    amount = float(ctx.get(\"Amount\", \"0\"))\n    if amount \u003e 100_000:\n        await ctx.reject(\"Amount exceeds limit.\")\n    else:\n        ctx.set(\"Status\", \"Approved\")\n        await ctx.approve(\"Auto-approved.\")\n\ndp.include_router(router)\nasyncio.run(dp.start_webhook(bot, host=\"0.0.0.0\", port=8080, path=\"/pyrus\"))\n```\n\n## Polling Bot (no public server needed)\n\n```python\nasyncio.run(\n    dp.start_polling(\n        bot,\n        form_id=321,\n        steps=2,\n        interval=30.0,       # seconds between polls\n        skip_old=True,        # skip existing tasks on startup\n    )\n)\n```\n\nWorks behind firewalls, no public URL required.\n\n### Polling: guarding against self-triggering\n\nPolling tracks `last_modified_date` per task. If a handler **modifies** the task (`ctx.set(...)`, `ctx.answer(...)`), `last_modified_date` changes — and the next poll re-dispatches it. This can cause duplicate comments.\n\nGuard with `FieldValueFilter` in the decorator:\n\n```python\n@dp.task_received(\n    FormFilter(321), StepFilter(2),\n    FieldValueFilter(field_name=\"Status\", value=\"Open\"),\n    FieldValueFilter(field_name=\"Assignee\", value=None),\n)\nasync def on_new_task(ctx: TaskContext):\n    ctx.set(\"Status\", \"In Progress\")\n    ctx.set(\"Assignee\", \"John Smith\")\n    await ctx.approve(\"Accepted\")\n    # After this the status is no longer \"Open\" — the filter rejects on next poll\n```\n\n## Filters\n\n```python\nfrom aiopyrus import FormFilter, StepFilter, FieldValueFilter, EventFilter, F\n\n# Classic\n@router.task_received(FormFilter(321), StepFilter(2))\n\n# By field value\n@router.task_received(FieldValueFilter(field_name=\"Type\", value=\"Bug\"))\n\n# Magic F\n@router.task_received(F.form_id.in_([321, 322]), F.text.contains(\"urgent\"))\n\n# Composition: \u0026, |, ~\n@router.task_received(FormFilter(321) \u0026 StepFilter(2) \u0026 ~FieldValueFilter(field_name=\"Status\", value=\"Closed\"))\n\n# Time-based (useful for polling)\nfrom aiopyrus.bot.filters import ModifiedAfterFilter, CreatedAfterFilter\n@router.task_received(ModifiedAfterFilter())  # only tasks modified after bot start\n```\n\n## Middleware\n\n```python\nfrom aiopyrus import BaseMiddleware\n\nclass LoggingMiddleware(BaseMiddleware):\n    async def __call__(self, handler, payload, bot, data):\n        print(f\"Task {payload.task_id}\")\n        return await handler(payload, bot, data)\n\ndp.middleware(LoggingMiddleware())\n```\n\n## Organization Data\n\n```python\nasync with UserClient(login=LOGIN, security_key=KEY) as client:\n    # Register with filters\n    tasks = await client.get_register(321, steps=[1, 2], due_filter=\"overdue\")\n\n    # CSV export\n    csv_text = await client.get_register_csv(321, steps=[1, 2])\n\n    # Multiple form registers in parallel\n    regs = await client.get_registers([321, 322, 323])\n    for form_id, tasks in regs.items():\n        print(f\"Form {form_id}: {len(tasks)} tasks\")\n\n    # Stream large register (10 000+ tasks, no full load into memory)\n    async for task in client.stream_register(321, steps=[1, 2]):\n        print(task.id, task.current_step)\n\n    # Client-side filtering during streaming (for conditions the server can't filter)\n    async for task in client.stream_register(321, predicate=lambda t: t.text):\n        print(task.id)\n\n    # Parallel search across multiple forms\n    all_tasks = await client.search_tasks({321: [1, 2], 322: None})\n\n    # Task lists (projects / kanban boards)\n    lists = await client.get_lists()\n    list_tasks = await client.get_task_list(lists[0].id)\n\n    # Catalogs\n    catalogs = await client.get_catalogs()\n    cat = await client.get_catalog(999)\n    item = cat.find_item(\"Moscow\")\n\n    # Members\n    person = await client.find_member(\"John Smith\")\n    members = await client.get_members()\n\n    # Find by email\n    person = await client.find_member_by_email(\"john@corp.com\")\n    found = await client.find_members_by_emails([\"alice@corp.com\", \"bob@corp.com\"])\n\n    # Avatar\n    uploaded = await client.upload_file(\"photo.jpg\")\n    await client.set_avatar(person.id, uploaded.guid)\n\n    # Roles\n    roles = await client.get_roles()\n\n    # Files\n    uploaded = await client.upload_file(\"/path/to/file.pdf\")\n    content = await client.download_file(\"guid\")\n\n    # Attach file to a comment\n    await client.comment_task(task_id, text=\"Document\", attachments=[uploaded.guid])\n\n    # Attach file to a file-type field\n    await client.comment_task(task_id, field_updates=[\n        {\"id\": 686, \"value\": [{\"guid\": uploaded.guid}]},\n    ])\n\n    # Print forms (PDF)\n    pdf = await client.download_print_form(task_id=12345678, print_form_id=1)\n\n    # Announcements\n    announcements = await client.get_announcements()\n```\n\n## Batch Operations\n\nParallel execution via `asyncio.gather`:\n\n```python\nasync with UserClient(login=LOGIN, security_key=KEY) as client:\n    # Fetch multiple tasks in parallel (errors are skipped)\n    tasks = await client.get_tasks([1001, 1002, 1003])\n\n    # Create multiple tasks (typed models)\n    from aiopyrus import NewTask, NewRole, MemberUpdate\n    results = await client.create_tasks([\n        NewTask(form_id=321, fields=[{\"id\": 1, \"value\": \"A\"}]),\n        NewTask(text=\"Simple task\"),\n    ])\n\n    # Comment on multiple tasks via TaskContext\n    ctxs = await client.task_contexts([1001, 1002])\n    ctxs[0].set(\"Status\", \"Done\")\n    ctxs[1].set(\"Status\", \"Rejected\")\n    await asyncio.gather(\n        ctxs[0].approve(\"Approved\"),\n        ctxs[1].reject(\"Rejected\"),\n    )\n\n    # Multiple form registers in parallel\n    regs = await client.get_registers([321, 322, 323])\n\n    # Batch role and member operations\n    await client.create_roles([NewRole(name=\"Admins\", member_ids=[1, 2]), NewRole(name=\"Users\")])\n    await client.update_members([MemberUpdate(member_id=100, position=\"Lead\"), MemberUpdate(member_id=200, position=\"Dev\")])\n```\n\n## Utilities\n\n### FieldUpdate — field update builder\n\n```python\nfrom aiopyrus import FieldUpdate\n\n# Manual factories\nupdates = [\n    FieldUpdate.text(field_id=1, value=\"Moscow\"),\n    FieldUpdate.choice(field_id=2, choice_id=3),\n    FieldUpdate.person(field_id=3, person_id=100500),\n    FieldUpdate.checkmark(field_id=4, checked=True),\n    FieldUpdate.catalog(field_id=5, item_id=42),\n]\n\n# Auto-detect format by field type\ntask = await client.get_task(12345678)\nupdates = [\n    FieldUpdate.from_field(task.get_field(\"Status\"), 3),          # choice_id\n    FieldUpdate.from_field(task.get_field(\"Assignee\"), 100500),    # person_id\n    FieldUpdate.from_field(task.get_field(\"Description\"), \"Text\"), # text\n]\nawait client.comment_task(task.id, field_updates=updates)\n```\n\n### URL Helpers\n\n```python\n# Browser link to a task\nurl = client.get_task_url(12345678)\n# → \"https://pyrus.com/t#id12345678\"\n\n# Browser link to a form\nurl = client.get_form_url(321)\n# → \"https://pyrus.com/form/321\"\n```\n\nWorks for on-premise too: `https://pyrus.mycompany.com/t#id12345678`.\n\n### Other utilities\n\n```python\nfrom aiopyrus import get_flat_fields, format_mention, select_fields\n\n# Recursive flatten of nested fields (title sections, tables)\nflat = get_flat_fields(task.fields)\n\n# HTML @mention for formatted_text fields\nhtml = format_mention(100500, header=\"John Smith\")\nawait client.comment_task(task_id, formatted_text=html)\n\n# Client-side field projection from Pydantic models\ntasks = await client.get_register(321)\nslim = select_fields(tasks, {\"id\", \"current_step\", \"fields\"})\n```\n\n## Testing\n\n```python\nfrom aiopyrus import create_mock_client\nfrom aiopyrus.types import Task\n\n# AsyncMock with spec=UserClient\nmock = create_mock_client(\n    get_task=Task(id=12345678, text=\"Test\"),\n    get_members=[],\n)\n\ntask = await mock.get_task(12345678)\nassert task.id == 12345678\nmock.get_task.assert_awaited_once_with(12345678)\n\n# Async context manager support\nasync with mock as client:\n    await client.get_inbox()\n```\n\n## Approval Step Management\n\n```python\n# Re-request approval (reset step to \"waiting\")\nawait client.comment_task(task_id, approvals_rerequested=[[141636]])\n\n# Add approver to a step\nawait client.comment_task(task_id, approvals_added=[[{\"id\": 141636}]])\n\n# Remove approver from a step\nawait client.comment_task(task_id, approvals_removed=[{\"id\": 141636}])\n```\n\nPyrus bots combine `approvals_removed` + `approvals_added` to switch tasks between workflow steps.\n\n## Approval Helpers on Task\n\nThe `Task` model provides methods for querying approval steps:\n\n```python\ntask = await client.get_task(12345678)\n\n# All approvers on step 1\nentries = task.get_approvals(1)\n\n# Only those who approved\napproved = task.get_approvals(1, choice=\"approved\")\n\n# Waiting approvers\nwaiting = task.get_approvals(2, choice=ApprovalChoice.waiting)\n\n# Dict {step_number: [ApprovalEntry, ...]}\nby_step = task.approvals_by_step\n\n# Convenience methods\nnames  = task.get_approver_names(1)                    # [\"John Smith\", \"Jane Doe\"]\nemails = task.get_approver_emails(1, choice=\"approved\") # [\"john@corp.com\"]\nids    = task.get_approver_ids(2)                       # [100500]\n```\n\n## Event Log (on-premise)\n\nAudit endpoints available only on Pyrus server (on-premise) instances. All return CSV.\n\n```python\n# Security event log (logins, password changes, roles — 113 event types)\ncsv = await client.get_event_history(after=1000, count=500)\n\n# File access history\ncsv = await client.get_file_access_history(count=1000)\n\n# Task access / task export / registry download history\ncsv = await client.get_task_access_history()\ncsv = await client.get_task_export_history()\ncsv = await client.get_registry_download_history()\n```\n\nDetails: https://pyrus.com/ru/help/api/event-log\n\n## Rate Limiting\n\n```python\nbot = PyrusBot(\n    login=\"bot@example\",\n    security_key=\"SECRET\",\n    requests_per_minute=30,\n    requests_per_10min=4000,\n)\n```\n\nBuilt-in rate limiter with exponential backoff. Pyrus API limits: 5000 requests / 10 min.\n\n## On-premise\n\n```python\nclient = UserClient(\n    login=\"user@corp.com\",\n    security_key=\"KEY\",\n    base_url=\"https://pyrus.mycompany.com\",\n    ssl_verify=False,  # self-signed certificates\n)\n```\n\n## Proxy\n\n```python\nclient = UserClient(\n    login=\"user@example.com\",\n    security_key=\"KEY\",\n    proxy=\"http://proxy.corp:8080\",\n)\n```\n\n## Examples\n\nSee [`examples/`](examples/) — 12 files from simple to advanced:\n\n| File | Topic |\n|---|---|\n| [`01_quickstart.py`](examples/01_quickstart.py) | Connection, profile, inbox, TaskContext |\n| [`02_task_context.py`](examples/02_task_context.py) | All read/write methods, approval, time tracking |\n| [`03_bot_webhook.py`](examples/03_bot_webhook.py) | Webhook bot, routers, filters, middleware |\n| [`04_bot_polling.py`](examples/04_bot_polling.py) | Polling mode, skip_old, lifecycle hooks |\n| [`05_data_management.py`](examples/05_data_management.py) | Registers, catalogs, members, roles, files |\n| [`06_approval_bot.py`](examples/06_approval_bot.py) | Approval monitoring bot, `enrich`, inbox polling |\n| [`07_middleware_errors.py`](examples/07_middleware_errors.py) | Middleware, error handling, nested routers |\n| [`08_inbox_vs_register.py`](examples/08_inbox_vs_register.py) | Inbox vs Register: choosing the right approach |\n| [`09_auto_processing.py`](examples/09_auto_processing.py) | UserClient: task processing by link |\n| [`10_polling_auto_approve.py`](examples/10_polling_auto_approve.py) | Polling + FormFilter + StepFilter + ApprovalPendingFilter |\n| [`11_http_integration.py`](examples/11_http_integration.py) | HTTP server for external systems (PHP, 1C, etc.) |\n| [`12_embed_in_project.py`](examples/12_embed_in_project.py) | Embedding aiopyrus into FastAPI / Django / Celery |\n\n## FAQ\n\n### How does aiopyrus differ from the official pyrus-api?\n\n[pyrus-api](https://pypi.org/project/pyrus-api/) is a synchronous wrapper by Pyrus built on `requests`. aiopyrus is a fully async framework on `httpx` with a router/filter/middleware system inspired by aiogram. Fields are accessed by their UI names, not by `field_id`.\n\n### Do I need a public server to run a bot?\n\nNo. There is a polling mode (`dp.start_polling(...)`) — the bot polls Pyrus on a timer. Works behind firewalls, NAT, VPN.\n\n### Are on-premise Pyrus installations supported?\n\nYes. Pass `base_url` when creating the client:\n\n```python\nclient = UserClient(\n    login=\"user@corp.com\",\n    security_key=\"KEY\",\n    base_url=\"https://pyrus.mycompany.com\",\n    ssl_verify=False,  # for self-signed certificates\n)\n```\n\n### Can I use it without a bot, just as an API client?\n\nYes, that's exactly what `UserClient` is for — scripts under your own account, no bot registration needed.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftimmekhw%2Faiopyrus","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftimmekhw%2Faiopyrus","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftimmekhw%2Faiopyrus/lists"}