Skip to content
Merged

V3 #2

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
"WebSearch",
"WebFetch(domain:azure.github.io)",
"WebFetch(domain:medium.com)",
"WebFetch(domain:newsletter.pragmaticengineer.com)"
"WebFetch(domain:newsletter.pragmaticengineer.com)",
"Bash(awk '{print $NF}')",
"Bash(grep \"| $\" /Users/n.baryshnikov/Projects/avito_python_api/docs/inventory.md)",
"Bash(grep -r \"def.*:$\" /Users/n.baryshnikov/Projects/avito_python_api/avito --include=\"*.py\")"
]
}
}
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.

## [Unreleased]

### Changed
- Централизовано выполнение схемы `request + map` через `Transport.request_public_model`.
- Убраны прямые обращения доменных клиентов к `request_json` и приватному `Transport._auth_provider`.
- Секционные клиенты переведены на `@dataclass(slots=True, frozen=True)`.
- Иерархия исключений упрощена до frozen dataclass без кастомного `__setattr__`.
- Публичные сигнатуры `accounts`, `ads`, `autoteka`, `cpa`, `jobs`, `messenger`, `orders`, `promotion`, `ratings` и `realty` переведены с `request`-DTO на keyword-only примитивы и коллекции.
- Transport получил поддержку `Idempotency-Key`; публичные write-методы во всех доменах принимают `idempotency_key`, а dry-run/write-контракт promotion покрыт тестами.
- Во всех доменных пакетах добавлены `enums.py`; `accounts`, `ads`, `autoteka`, `jobs`, `messenger`, `orders`, `promotion`, `ratings`, `realty` и `tariffs` переведены на typed enums с fallback на `UNKNOWN` и warning-логом ровно один раз на неизвестное upstream-значение.

## [1.0.2] - 2026-04-21

### Added
- Первый публичный релиз changelog для `avito-py`.

### Changed
- Зафиксирована базовая структура истории изменений для следующих фаз исправления STYLEGUIDE.
17 changes: 1 addition & 16 deletions STYLEGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ All HTTP must go through a single transport layer.
Rules:

- Direct calls to `httpx.get()`/`httpx.post()` inside section clients are forbidden.
- Use `httpx.Client` or `httpx.AsyncClient` as an internal dependency of the transport layer.
- Use `httpx.Client` as an internal dependency of the transport layer.
- Timeouts are set explicitly.
- Authorization headers are injected by the transport/auth layer, not by business methods.
- URL construction, error handling, retries, and logging are concentrated in the transport.
Expand All @@ -349,7 +349,6 @@ Recommendation:

- Build a high-quality sync SDK first.
- The SDK is synchronous — this must be explicitly documented in the README and public API.
- An async version should be added as a separate layer, not mixed with sync in the same classes.

### User-Agent and Client Identification

Expand All @@ -363,19 +362,6 @@ Recommendation:
- Overrides must not mutate the client or shared state. The transport layer resolves the effective policy as `override or client_default` without writing back.
- The list of supported per-operation overrides is part of the public contract and must be documented on each public method.

## Async and Sync Parity

When an async surface is added it must be a separate, parallel layer, not a retrofit of sync classes.

Rules:

- The async namespace is `avito.aio` with mirrored module paths: `avito.aio.client`, `avito.aio.<domain>.client`.
- Async client classes have the same names as their sync counterparts (`AvitoClient`, `AdClient`, ...) but live in the `aio` namespace.
- Public method names and parameter lists must be identical between sync and async. Only the call-site keyword (`await`) and the context-manager form (`async with`) differ.
- Mixing `async def` and `def` on the same class is forbidden.
- Return types are the same public dataclasses in both layers. `PaginatedList[T]` has an `AsyncPaginatedList[T]` sibling with matching semantics and matching `materialize()`.
- Feature parity between sync and async is part of the public contract: a method that exists in sync must exist in async within the same release, or be explicitly marked sync-only in the documentation.

## Authorization

Authorization must be fully abstracted away from API methods.
Expand Down Expand Up @@ -885,6 +871,5 @@ Rules:
- Retrying non-idempotent writes without an idempotency key or an explicit per-operation opt-in.
- Raising on expected "not found" for probe methods (`exists()` must return `False`, not throw).
- Exposing `FakeTransport` only through internal test imports instead of `avito.testing`.
- Mixing sync and async surfaces in the same class or module (`avito.aio` is the only home for async).
- Public methods that swallow upstream `request_id` or retry `attempt` in raised exceptions.
- Documentation that covers only reference or only tutorials (all four Diátaxis modes are mandatory).
32 changes: 31 additions & 1 deletion avito/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,35 @@
from avito.auth.settings import AuthSettings
from avito.client import AvitoClient
from avito.config import AvitoSettings
from avito.core.exceptions import (
AuthenticationError,
AuthorizationError,
AvitoError,
ConfigurationError,
ConflictError,
RateLimitError,
ResponseMappingError,
TransportError,
UnsupportedOperationError,
UpstreamApiError,
ValidationError,
)
from avito.core.pagination import PaginatedList

__all__ = ("AuthSettings", "AvitoClient", "AvitoSettings")
__all__ = (
"AuthSettings",
"AuthenticationError",
"AuthorizationError",
"AvitoClient",
"AvitoError",
"AvitoSettings",
"ConfigurationError",
"ConflictError",
"PaginatedList",
"RateLimitError",
"ResponseMappingError",
"TransportError",
"UnsupportedOperationError",
"UpstreamApiError",
"ValidationError",
)
10 changes: 10 additions & 0 deletions avito/accounts/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
"""Пакет accounts."""

from avito.accounts.domain import Account, AccountHierarchy
from avito.accounts.enums import (
AccountHierarchyRole,
EmployeeItemStatus,
OperationStatus,
OperationType,
)
from avito.accounts.models import (
AccountActionResult,
AccountBalance,
Expand All @@ -19,12 +25,16 @@
"AccountActionResult",
"AccountBalance",
"AccountHierarchy",
"AccountHierarchyRole",
"AccountProfile",
"AhUserStatus",
"CompanyPhone",
"CompanyPhonesResult",
"Employee",
"EmployeeItem",
"EmployeeItemStatus",
"EmployeesResult",
"OperationRecord",
"OperationStatus",
"OperationType",
)
56 changes: 42 additions & 14 deletions avito/accounts/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from avito.core.mapping import request_public_model


@dataclass(slots=True)
@dataclass(slots=True, frozen=True)
class AccountsClient:
"""Выполняет HTTP-операции по разделу информации о пользователе."""

Expand Down Expand Up @@ -59,18 +59,25 @@ def get_balance(self, *, user_id: int) -> AccountBalance:
mapper=map_account_balance,
)

def get_operations_history(self, request: OperationsHistoryRequest) -> PaginatedList[OperationRecord]:
def get_operations_history(
self,
*,
date_from: str | None = None,
date_to: str | None = None,
limit: int | None = None,
offset: int | None = None,
) -> PaginatedList[OperationRecord]:
"""Получает историю операций пользователя."""

page_size = request.limit or 25
base_offset = request.offset or 0
page_size = limit or 25
base_offset = offset or 0

def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[OperationRecord]:
current_page = page or 1
current_offset = base_offset + (current_page - 1) * page_size
paged_request = OperationsHistoryRequest(
date_from=request.date_from,
date_to=request.date_to,
date_from=date_from,
date_to=date_to,
limit=page_size,
offset=current_offset,
)
Expand All @@ -92,7 +99,7 @@ def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[OperationRecor
return Paginator(fetch_page).as_list(first_page=fetch_page(1, None))


@dataclass(slots=True)
@dataclass(slots=True, frozen=True)
class HierarchyClient:
"""Выполняет HTTP-операции по иерархии аккаунтов."""

Expand Down Expand Up @@ -131,28 +138,49 @@ def list_company_phones(self) -> CompanyPhonesResult:
mapper=map_company_phones,
)

def link_items(self, request: EmployeeItemLinkRequest) -> AccountActionResult:
def link_items(
self,
*,
employee_id: int,
item_ids: list[int],
source_employee_id: int | None = None,
idempotency_key: str | None = None,
) -> AccountActionResult:
"""Прикрепляет объявления к сотруднику."""

return request_public_model(
self.transport,
"POST",
"/linkItemsV1",
context=RequestContext("accounts.hierarchy.link_items", allow_retry=True),
context=RequestContext(
"accounts.hierarchy.link_items",
allow_retry=idempotency_key is not None,
),
mapper=map_action_result,
json_body=request.to_payload(),
json_body=EmployeeItemLinkRequest(
employee_id=employee_id,
item_ids=item_ids,
source_employee_id=source_employee_id,
).to_payload(),
idempotency_key=idempotency_key,
)

def list_items_by_employee(self, request: EmployeeItemsRequest) -> PaginatedList[EmployeeItem]:
def list_items_by_employee(
self,
*,
employee_id: int,
limit: int | None = None,
offset: int | None = None,
) -> PaginatedList[EmployeeItem]:
"""Получает список объявлений по сотруднику."""

page_size = request.limit or 25
page_size = limit or 25

def fetch_page(page: int | None, _cursor: str | None) -> JsonPage[EmployeeItem]:
current_page = page or 1
current_offset = (request.offset or 0) + (current_page - 1) * page_size
current_offset = (offset or 0) + (current_page - 1) * page_size
paged_request = EmployeeItemsRequest(
employee_id=request.employee_id,
employee_id=employee_id,
limit=page_size,
offset=current_offset,
)
Expand Down
27 changes: 12 additions & 15 deletions avito/accounts/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,8 @@
AhUserStatus,
CompanyPhonesResult,
EmployeeItem,
EmployeeItemLinkRequest,
EmployeeItemsRequest,
EmployeesResult,
OperationRecord,
OperationsHistoryRequest,
)
from avito.core import PaginatedList, ValidationError
from avito.core.domain import DomainObject
Expand Down Expand Up @@ -58,12 +55,10 @@ def get_operations_history(
"""Получает историю операций пользователя."""

return AccountsClient(self.transport).get_operations_history(
OperationsHistoryRequest(
date_from=_serialize_datetime(date_from),
date_to=_serialize_datetime(date_to),
limit=limit,
offset=offset,
)
date_from=_serialize_datetime(date_from),
date_to=_serialize_datetime(date_to),
limit=limit,
offset=offset,
)


Expand Down Expand Up @@ -94,15 +89,15 @@ def link_items(
employee_id: int,
item_ids: Sequence[int],
source_employee_id: int | None = None,
idempotency_key: str | None = None,
) -> AccountActionResult:
"""Прикрепляет объявления к сотруднику."""

return HierarchyClient(self.transport).link_items(
EmployeeItemLinkRequest(
employee_id=employee_id,
item_ids=list(item_ids),
source_employee_id=source_employee_id,
)
employee_id=employee_id,
item_ids=list(item_ids),
source_employee_id=source_employee_id,
idempotency_key=idempotency_key,
)

def list_items_by_employee(
Expand All @@ -115,7 +110,9 @@ def list_items_by_employee(
"""Получает список объявлений сотрудника."""

return HierarchyClient(self.transport).list_items_by_employee(
EmployeeItemsRequest(employee_id=employee_id, limit=limit, offset=offset)
employee_id=employee_id,
limit=limit,
offset=offset,
)


Expand Down
41 changes: 41 additions & 0 deletions avito/accounts/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Enum-значения раздела accounts."""

from __future__ import annotations

from enum import Enum


class OperationType(str, Enum):
"""Тип операции по аккаунту."""

UNKNOWN = "__unknown__"
PAYMENT = "payment"


class OperationStatus(str, Enum):
"""Статус операции по аккаунту."""

UNKNOWN = "__unknown__"
DONE = "done"


class AccountHierarchyRole(str, Enum):
"""Роль пользователя в иерархии аккаунтов."""

UNKNOWN = "__unknown__"
MANAGER = "manager"


class EmployeeItemStatus(str, Enum):
"""Статус объявления сотрудника."""

UNKNOWN = "__unknown__"
ACTIVE = "active"


__all__ = (
"AccountHierarchyRole",
"EmployeeItemStatus",
"OperationStatus",
"OperationType",
)
Loading
Loading