Релиз

This commit is contained in:
2026-02-26 17:59:13 +03:00
commit d0c39c5930
12 changed files with 725 additions and 0 deletions

58
.gitea/workflows/dev.yaml Normal file
View File

@ -0,0 +1,58 @@
name: Verify Dev Build
run-name: ${{ github.actor }} verifying dev build
on:
push:
branches:
- dev
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache uv binary
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/uv
key: uv-${{ runner.os }}
restore-keys: uv-${{ runner.os }}
- name: Cache uv dependencies
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.cache/uv
key: uv-${{ runner.os }}
restore-keys: uv-${{ runner.os }}
- name: Cache pre-commit
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-cache-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
restore-keys: pre-commit-cache-${{ runner.os }}-
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.10.5"
enable-cache: true
cache-local-path: ${{ github.workspace }}/.cache/uv
tool-dir: ${{ github.workspace }}/.cache/uv
tool-bin-dir: ${{ github.workspace }}/.cache/uv
cache-dependency-glob: ""
- name: Set up Python
run: uv python install
- name: Install the project
run: uv sync --all-extras --no-install-project --cache-dir ${{ github.workspace }}/.cache/uv
- name: Linter & Formatter
run: uv run pre-commit run --all-files
- name: Build Package
run: uv build --cache-dir ${{ github.workspace }}/.cache/uv

View File

@ -0,0 +1,64 @@
name: Build And Publish Package
run-name: ${{ github.actor }} builds and publishes package to PyPI
on:
push:
branches:
- latest
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache uv binary
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/uv
key: uv-${{ runner.os }}
restore-keys: uv-${{ runner.os }}
- name: Cache uv dependencies
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.cache/uv
key: uv-${{ runner.os }}
restore-keys: uv-${{ runner.os }}
- name: Cache pre-commit
uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-cache-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
restore-keys: pre-commit-cache-${{ runner.os }}-
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.10.5"
enable-cache: true
cache-local-path: ${{ github.workspace }}/.cache/uv
tool-dir: ${{ github.workspace }}/.cache/uv
tool-bin-dir: ${{ github.workspace }}/.cache/uv
cache-dependency-glob: ""
- name: Set up Python
run: uv python install
- name: Install the project
run: uv sync --all-extras --no-install-project --cache-dir ${{ github.workspace }}/.cache/uv
- name: Linter & Formatter
run: uv run pre-commit run --all-files
- name: Build Package
run: uv build --cache-dir ${{ github.workspace }}/.cache/uv
- name: Publish to Gitea PyPI
run: |
uv publish \
--index OxideTwitch \
--token ${{ secrets.CI_TOKEN }}

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Ruff
.ruff_cache
# uv
uv.lock

34
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,34 @@
repos:
- repo: https://github.com/crate-ci/typos
rev: v1.42.0
hooks:
- id: typos
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.0
hooks:
- id: ruff
args: [ --fix ]
- id: ruff-format
- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.408
hooks:
- id: pyright
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: check-docstring-first
- id: check-added-large-files
- id: check-yaml
- id: debug-statements
- id: check-merge-conflict
- id: double-quote-string-fixer
- id: end-of-file-fixer
- repo: meta
hooks:
- id: check-hooks-apply
- id: check-useless-excludes

96
README.md Normal file
View File

@ -0,0 +1,96 @@
# 🧡 OxideDonationAlerts
**OxideDonationAlerts** is a high-performance Python client for the [DonationAlerts API](https://www.donationalerts.com/apidoc), built on the [OxideHTTP](https://git.miwory.dev/OxideHTTP/OxideHTTP) core. It leverages Rust-powered HTTP calls to provide a type-safe, rate-limited, and cached interface for managing donations and alerts.
---
## 🔥 Why OxideDonationAlerts?
Managing real-time donations requires reliability and speed. OxideDonationAlerts simplifies the integration:
* **⚡ Rust-Powered Core:** Utilizes `pyreqwest` via OxideHTTP for ultra-fast underlying networking.
* **🛡️ Distributed Rate Limiting:** Integrates with Redis to manage DonationAlerts' thresholds (60 requests/min) across multiple processes using the GCRA algorithm.
* **💾 Smart Caching:** Built-in support for caching API responses (like user profiles or donation history) to reduce latency and API load.
* **🏗️ Pydantic Validation:** All responses are fully validated against Pydantic models for perfect type hinting and data integrity.
---
## 📦 Installation
Configure your private registry in your `uv` environment, then install:
```bash
uv add oxidedonationalerts
```
---
## 🛠 Quick Start
### Basic Usage: Fetching Donations
OxideDonationAlerts manages the `base_url` and provides a clean interface for paginated data.
```python
import asyncio
from oxidedonationalerts.api import DonationAlertsAPIClient
async def main():
async with DonationAlertsAPIClient(
redis_url="redis://localhost:6379"
) as client:
# Fetch the first page of donations
donations = await client.alerts_donations(
access_token="your_token",
page=1
)
for donation in donations.data:
print(f"Donor: {donation.username} | Amount: {donation.amount} {donation.currency}")
asyncio.run(main())
```
### Subscribing to Real-time Events
The client supports Centrifuge subscription calls for handling WebSocket-based real-time alerts.
```python
async def setup_realtime(client, token):
# Get credentials for WebSocket connection
channels = ["$alerts:donation_741", "$goals:goal_123"]
subs = await client.centrifuge_subscribe(
access_token=token,
client="client_uuid_from_frontend",
subscriptions=channels
)
print(f"Subscription Token: {subs.channels[0].token}")
```
---
## ⚙️ Advanced: Configuration
Ensure your `pyproject.toml` points to the correct registry for both **OxideDonationAlerts** and its core dependency **OxideHTTP**:
```toml
[[tool.uv.index]]
name = "OxideHTTP"
url = "https://git.miwory.dev/api/packages/OxideHTTP/pypi/simple"
```
### API Implementation Status
The client is currently optimized for the following scopes:
* **User Data:** `oauth-user-show`
* **Donations:** `oauth-donation-index`
* **Real-time:** `oauth-centrifuge-subscribe`
---
Would you like me to generate the **Pydantic schemas** for the `UserOauth` or `AlertsDonations` models to match the official documentation?

101
pyproject.toml Normal file
View File

@ -0,0 +1,101 @@
[project]
name = "oxidedonationalerts"
version = "1.0.0"
description = "Client for DonationAlerts API"
readme = "README.md"
authors = [{ name = "Miwory", email = "miwory.uwu@gmail.com" }]
requires-python = ">=3.14"
dependencies = ["oxidehttp>=1.0.3,<=2.0.0", "pydantic[email]>=2.12,<=2.13"]
[build-system]
requires = ["uv_build>=0.9.2,<0.10.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"ty>=0.0.17",
"ruff>=0.15.0",
"pyright>=1.1.408",
"poethepoet>=0.40.0",
"pre-commit>=4.5.1",
]
[[tool.uv.index]]
name = "OxideHTTP"
url = "https://git.miwory.dev/api/packages/OxideHTTP/pypi/simple"
[[tool.uv.index]]
name = "OxideDonationAlerts"
url = "https://git.miwory.dev/api/packages/OxideHTTP/pypi/simple"
publish-url = "https://git.miwory.dev/api/packages/OxideHTTP/pypi"
explicit = true
[tool.poe.tasks]
_git = "git add ."
_lint = "pre-commit run --all-files"
lint = ["_git", "_lint"]
check = "uv pip ls --outdated"
major = "uv version --bump major"
minor = "uv version --bump minor"
patch = "uv version --bump patch"
[tool.pyright]
venvPath = "."
venv = ".venv"
strictListInference = true
strictDictionaryInference = true
strictSetInference = true
deprecateTypingAliases = true
typeCheckingMode = "strict"
pythonPlatform = "All"
stubPath = "typings"
[tool.ruff]
target-version = "py313"
line-length = 79
fix = true
[tool.ruff.lint]
preview = true
select = [
"E",
"W",
"F",
"UP",
"A",
"B",
"C4",
"SIM",
"I",
"S",
"G",
"FAST",
"ASYNC",
"BLE",
"INT",
"ISC",
"ICN",
"PYI",
"INP",
"RSE",
"PIE",
"SLOT",
"TID",
"LOG",
"FBT",
"DTZ",
"EM",
"PERF",
"RUF",
]
ignore = ["RUF002", "RUF029", "S101", "S104", "W505"]
[tool.ruff.lint.pydoclint]
ignore-one-line-docstrings = true
[tool.ruff.format]
quote-style = "single"
indent-style = "space"
docstring-code-format = true

View File

View File

@ -0,0 +1,145 @@
from typing import Literal, Never
from oxidehttp.client import OxideHTTP
from oxidehttp.schema import CachedResponse, Response
from pydantic import BaseModel
from . import schema as s
class DonationAlertsAPIClient(OxideHTTP):
def __init__(
self,
redis_url: str | None = None,
proxy_url: str | None = None,
) -> None:
self.base_uri = 'https://www.donationalerts.com/api/v1'
super().__init__(
base_url=self.base_uri,
redis_url=redis_url,
ratelimit_key='donation_alerts' if redis_url else None,
ratelimit_limit=60 if redis_url else None,
proxy_url=proxy_url,
)
def _auth(self, access_token: str) -> dict[str, str]:
return {'Authorization': f'Bearer {access_token}'}
async def _process[T: BaseModel](
self, req: Response | CachedResponse, schema: type[T]
) -> T:
if req.status_code >= 500:
raise s.InternalError(req.status_code, 'Internal Server Error')
if req.status_code >= 400 and req.status_code < 500:
data = await req.json()
message = data.get('message', 'DonationAlerts API Error')
raise s.ClientError(req.status_code, message)
data = await req.json()
return schema.model_validate(data)
async def user_oauth(
self, access_token: str, *, cache_ttl: int | None = None
) -> s.UserOauth:
req = await self.get(
'/user/oauth', None, self._auth(access_token), cache_ttl
)
return await self._process(req, s.UserOauth)
async def alerts_donations(
self, access_token: str, *, page: int = 1, cache_ttl: int | None = None
) -> s.AlertsDonations:
req = await self.get(
'/alerts/donations',
{'page': page},
self._auth(access_token),
cache_ttl,
)
return await self._process(req, s.AlertsDonations)
async def custom_alert(
self,
access_token: str,
external_id: str,
header: str,
message: str,
image_url: str,
sound_url: str,
is_shown: Literal[0, 1] = 0,
) -> Never:
raise NotImplementedError
async def merchandise(
self,
access_token: str,
merchant_identifier: str,
merchandise_identifier: str,
title: dict[str, str],
currency: str,
price_user: int,
price_service: str,
url: str,
img_url: str,
end_at_ts: int,
is_active: Literal[0, 1] = 0,
is_percentage: Literal[0, 1] = 0,
) -> Never:
raise NotImplementedError
async def update_merchandise(
self,
access_token: str,
merchant_identifier: str,
merchandise_identifier: str,
title: dict[str, str],
currency: str,
price_user: int,
price_service: str,
url: str,
img_url: str,
end_at_ts: int,
is_active: Literal[0, 1] = 0,
is_percentage: Literal[0, 1] = 0,
) -> Never:
raise NotImplementedError
async def get_user_data_from_promocode(
self,
access_token: str,
promocode: str,
*,
cache_ttl: int | None = None,
) -> Never:
raise NotImplementedError
async def send_sale_alerts(
self,
access_token: str,
user_id: int,
external_id: str,
merchant_identifier: str,
merchandise_identifier: str,
amount: float,
currency: str,
username: str,
message: str,
bought_amount: int = 1,
) -> Never:
raise NotImplementedError
async def centrifuge_subscribe(
self, access_token: str, client: str, subscriptions: list[str]
) -> s.CentrifugeSubscribe:
req = await self.post(
'/centrifuge/subscribe',
None,
self._auth(access_token),
{'client': client, 'channels': subscriptions},
)
return await self._process(req, s.CentrifugeSubscribe)

View File

@ -0,0 +1,78 @@
from oxidehttp.client import OxideHTTP
from oxidehttp.schema import CachedResponse, Response
from pydantic import BaseModel
from . import schema as s
from . import scopes as sp
class DonationAlertsAuthClient(OxideHTTP):
def __init__(
self,
client_id: str,
client_secret: str,
redirect_uri: str,
redis_url: str | None = None,
proxy_url: str | None = None,
) -> None:
self.base_uri = 'https://www.donationalerts.com/oauth'
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
super().__init__(
base_url=self.base_uri,
redis_url=redis_url,
ratelimit_key='donation_alerts' if redis_url else None,
ratelimit_limit=60 if redis_url else None,
proxy_url=proxy_url,
)
def _auth(self, access_token: str) -> dict[str, str]:
return {'Authorization': f'Bearer {access_token}'}
async def _process[T: BaseModel](
self, req: Response | CachedResponse, schema: type[T]
) -> T:
if req.status_code >= 500:
raise s.InternalError(req.status_code, 'Internal Server Error')
if req.status_code >= 400 and req.status_code < 500:
data = await req.json()
message = data.get('message', 'DonationAlerts API Error')
raise s.ClientError(req.status_code, message)
data = await req.json()
return schema.model_validate(data)
async def user_access_token(self, code: str) -> s.UserAccessToken:
req = await self.post(
'/token',
json={
'client_id': self.client_id,
'client_secret': self.client_secret,
'redirect_uri': self.redirect_uri,
'grant_type': 'authorization_code',
'code': code,
},
)
return await self._process(req, s.UserAccessToken)
async def refresh_access_token(
self, refresh_code: str, scopes: list[sp.Any]
) -> s.UserAccessToken:
req = await self.post(
'/token',
json={
'client_id': self.client_id,
'client_secret': self.client_secret,
'redirect_uri': self.redirect_uri,
'grant_type': 'refresh_token',
'refresh_token': refresh_code,
'scopes': ' '.join(scopes),
},
)
return await self._process(req, s.UserAccessToken)

View File

View File

@ -0,0 +1,115 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, HttpUrl
class Error(Exception):
status_code: int
error: str
def __init__(self, status_code: int, error: str) -> None:
self.status_code = status_code
self.error = error
super().__init__(f'{status_code}: {error}')
class ClientError(Error):
pass
class InternalError(Error):
pass
class BaseSchema(BaseModel):
model_config = ConfigDict(extra='forbid')
class UserOauthData(BaseSchema):
id: int
code: str
name: str
avatar: HttpUrl
email: EmailStr
socket_connection_token: str
class UserOauth(BaseSchema):
data: UserOauthData
class AlertsDonationsDataPayinSystem(BaseSchema):
title: str
class AlertsDonationsDataRecipient(BaseSchema):
user_id: int
code: str
name: str
avatar: HttpUrl
class AlertsDonationsData(BaseSchema):
id: int
name: str
username: str | None
message: str | None
message_type: Literal['text', 'audio']
payin_system: AlertsDonationsDataPayinSystem | None
amount: float
currency: str
is_shown: bool
amount_in_user_currency: float
recipient_name: str
recipient: AlertsDonationsDataRecipient
created_at: datetime
created_at_ts: int
shown_at: datetime | None
shown_at_ts: int | None
class AlertsDonationsLinks(BaseSchema):
first: HttpUrl
last: HttpUrl
prev: HttpUrl | None
next: HttpUrl | None
class MetaLink(BaseSchema):
url: HttpUrl | None
label: str
active: bool
class AlertsDonationsMeta(BaseSchema):
current_page: int
from_page: int | None = Field(alias='from')
last_page: int
links: list[MetaLink]
path: HttpUrl
per_page: int
to: int | None
total: int
class AlertsDonations(BaseSchema):
data: list[AlertsDonationsData]
links: AlertsDonationsLinks
meta: AlertsDonationsMeta
class CentrifugeSubscribeChannel(BaseSchema):
channel: str
token: str
class CentrifugeSubscribe(BaseSchema):
channels: list[CentrifugeSubscribeChannel]
class UserAccessToken(BaseSchema):
token_type: Literal['Bearer']
access_token: str
refresh_token: str
expires_in: int

View File

@ -0,0 +1,18 @@
from typing import Literal
OAUTH_USER_SHOW = 'oauth-user-show'
OAUTH_DONATION_SUBSCRIBE = 'oauth-donation-subscribe'
OAUTH_DONATION_INDEX = 'oauth-donation-index'
OAUTH_CUSTOM_ALERT_STORE = 'oauth-custom_alert-store'
OAUTH_GOAL_SUBSCRIBE = 'oauth-goal-subscribe'
OAUTH_POLL_SUBSCRIBE = 'oauth-poll-subscribe'
type Any = Literal[
'oauth-user-show',
'oauth-donation-subscribe',
'oauth-donation-index',
'oauth-custom_alert-store',
'oauth-goal-subscribe',
'oauth-poll-subscribe',
]