Skip to content
Merged
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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ MKDOCS_ENV=DISABLE_MKDOCS_2_WARNING=true NO_MKDOCS_2_WARNING=1

check: test quality

quality: typecheck lint swagger-lint architecture-lint async-parity-lint docstring-lint build
quality: typecheck lint python-guidelines-lint swagger-lint architecture-lint async-parity-lint docstring-lint build

build: clean
poetry build
Expand All @@ -32,6 +32,9 @@ fmt:
lint:
poetry run ruff check .

python-guidelines-lint:
poetry run python scripts/lint_python_guidelines.py

swagger-update:
poetry run python scripts/download_avito_api_specs.py --clean

Expand Down
31 changes: 31 additions & 0 deletions STYLEGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,37 @@ Principles are listed in descending priority order when they conflict.
- **IDE-first design**: autocomplete, go-to-definition, and type inference are the primary discovery surfaces. The public API must be useful without reading the source code.
- **Backward compatibility is a feature**: breaking changes are deliberate, preceded by a deprecation period, and documented in `CHANGELOG.md`. Users must not be forced to change working code to upgrade a minor version.

## Python Guidelines Compliance

`.ai/python-guidelines.md` is a mandatory companion standard for all Python code
in this repository. Every new Python file and every changed Python block must
strictly satisfy those rules unless this `STYLEGUIDE.md` explicitly defines a
more specific SDK rule.

Rules:

- Imports must stay at module top level. Runtime imports inside functions,
methods, or classes are forbidden unless the exception is required and
documented next to the import.
- Code must fail fast. Do not swallow exceptions, do not use bare `except`, and
do not catch broad `Exception` unless the handler re-raises.
- Known typed objects must use direct attribute access instead of defensive
`getattr(..., default)`.
- Mutable default arguments, leaked file handles, builtin-name shadowing,
singleton comparisons with `==` / `!=`, runtime validation through `assert`,
collection mutation while iterating, string concatenation in loops, and
late-binding closures in loops are forbidden.
- Public API model fields that intentionally mirror upstream names such as `id`
or `type` are allowed only when they are part of a documented Avito contract.
Do not introduce local variables, helper parameters, or internal DTO fields
with builtin-shadowing names.
- The automated gate for these rules is `make python-guidelines-lint`; it is
included in `make quality` and therefore in `make check`.

If a Python guideline rule cannot be encoded in Ruff, mypy, or an existing
project linter, add or extend a dedicated static lint script. Do not enforce
style-only rules through pytest.

## Target Package Architecture

Avito API sections are organized as packages. The target architecture for new and
Expand Down
10 changes: 2 additions & 8 deletions avito/_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from json import JSONDecodeError, loads
from pathlib import Path

from avito.core.exceptions import ConfigurationError


def read_dotenv(env_file: str | Path | None) -> dict[str, str]:
"""Читает простой `.env` файл без побочных эффектов."""
Expand Down Expand Up @@ -67,8 +69,6 @@ def _first_present(source: Mapping[str, str], aliases: tuple[str, ...]) -> str |
def parse_env_int(value: str, *, field_name: str) -> int:
"""Преобразует env-значение в `int` с typed-ошибкой."""

from avito.core.exceptions import ConfigurationError

try:
return int(value)
except ValueError as exc:
Expand All @@ -80,8 +80,6 @@ def parse_env_int(value: str, *, field_name: str) -> int:
def parse_env_float(value: str, *, field_name: str) -> float:
"""Преобразует env-значение в `float` с typed-ошибкой."""

from avito.core.exceptions import ConfigurationError

try:
return float(value)
except ValueError as exc:
Expand All @@ -93,8 +91,6 @@ def parse_env_float(value: str, *, field_name: str) -> float:
def parse_env_bool(value: str, *, field_name: str) -> bool:
"""Преобразует env-значение в `bool` с typed-ошибкой."""

from avito.core.exceptions import ConfigurationError

normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
Expand All @@ -108,8 +104,6 @@ def parse_env_bool(value: str, *, field_name: str) -> bool:
def parse_env_str_tuple(value: str, *, field_name: str) -> tuple[str, ...]:
"""Преобразует env-значение в кортеж строк."""

from avito.core.exceptions import ConfigurationError

stripped = value.strip()
if not stripped:
return ()
Expand Down
3 changes: 1 addition & 2 deletions avito/auth/async_token_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from avito.auth.provider import CLIENT_CREDENTIALS_GRANT, REFRESH_TOKEN_GRANT
from avito.auth.settings import AuthSettings
from avito.config import AvitoSettings
from avito.core.async_transport import AsyncTransport
from avito.core.exceptions import AuthenticationError, AvitoError
from avito.core.swagger import swagger_operation
from avito.core.types import RequestContext
Expand Down Expand Up @@ -87,8 +88,6 @@ async def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResp

async def _request_token(self, payload: dict[str, str]) -> TokenResponse:
"""Run the request token helper."""
from avito.core.async_transport import AsyncTransport

transport = AsyncTransport(
self.sdk_settings or AvitoSettings(auth=self.settings),
auth_provider=None,
Expand Down
3 changes: 1 addition & 2 deletions avito/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport
from avito.ads.models import CallStats, ListingStats, ListingStatus, SpendingRecord
from avito.auth import AlternateTokenClient, AuthProvider, TokenClient
from avito.auth.settings import AuthSettings
from avito.autoteka import (
AutotekaMonitoring,
AutotekaReport,
Expand Down Expand Up @@ -136,8 +137,6 @@ def __init__(
) -> None:
"""Initialize AvitoClient."""
if client_id is not None or client_secret is not None:
from avito.auth.settings import AuthSettings

auth = AuthSettings(client_id=client_id, client_secret=client_secret)
settings = AvitoSettings(auth=auth)
self._closed = False
Expand Down
3 changes: 1 addition & 2 deletions avito/core/swagger_linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from collections.abc import Callable, Mapping, Sequence
from types import ModuleType

from avito.async_client import AsyncAvitoClient
from avito.client import AvitoClient
from avito.core.deprecation import DeprecatedSdkSymbol
from avito.core.operations import OperationSpec
Expand Down Expand Up @@ -551,8 +552,6 @@ def _validate_factory(binding: DiscoveredSwaggerBinding) -> tuple[SwaggerReportE

client_type: type[object]
if binding.variant == "async":
from avito.async_client import AsyncAvitoClient

client_type = AsyncAvitoClient
else:
client_type = AvitoClient
Expand Down
31 changes: 11 additions & 20 deletions avito/testing/swagger_fake_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from avito.auth import AuthSettings
from avito.auth.models import ClientCredentialsRequest, RefreshTokenRequest
from avito.auth.provider import AlternateTokenClient, TokenClient
from avito.autoteka.models import MonitoringEventsQuery
from avito.client import AvitoClient
from avito.core.swagger_discovery import DiscoveredSwaggerBinding
from avito.core.swagger_names import swagger_field_aliases
Expand All @@ -33,6 +34,10 @@
SwaggerSchemaPathError,
resolve_body_path,
)
from avito.jobs.models import ApplicationIdsQuery, ResumeSearchQuery, VacanciesQuery
from avito.messenger.models import UploadImageFile
from avito.ratings.models import ReviewsQuery
from avito.realty.models import RealtyInterval
from avito.testing.fake_transport import FakeTransport, JsonValue, RecordedRequest

SdkValue = object
Expand Down Expand Up @@ -354,8 +359,6 @@ def _value_for_argument(
if argument_name == "query":
return self._query_value(annotation)
if argument_name == "files" or "UploadImageFile" in annotation:
from avito.messenger.models import UploadImageFile

return [
UploadImageFile(
field_name="image",
Expand Down Expand Up @@ -404,24 +407,14 @@ def _value_for_expression(

def _query_value(self, annotation: str) -> object:
if "MonitoringEventsQuery" in annotation:
from avito.autoteka.models import MonitoringEventsQuery

return MonitoringEventsQuery(limit=2)
if "ApplicationIdsQuery" in annotation:
from avito.jobs.models import ApplicationIdsQuery

return ApplicationIdsQuery(updated_at_from="2026-04-01T00:00:00+00:00")
if "ResumeSearchQuery" in annotation:
from avito.jobs.models import ResumeSearchQuery

return ResumeSearchQuery(query="python")
if "VacanciesQuery" in annotation:
from avito.jobs.models import VacanciesQuery

return VacanciesQuery(query="python")
if "ReviewsQuery" in annotation:
from avito.ratings.models import ReviewsQuery

return ReviewsQuery(offset=0, limit=10)
return self._value_for_name("query")

Expand Down Expand Up @@ -583,8 +576,6 @@ def _should_supply_optional_argument(

def _value_for_name(self, name: str) -> object:
if name == "intervals":
from avito.realty.models import RealtyInterval

return [RealtyInterval(date="2026-05-01", available=True)]
if name == "blocked_dates":
return ["2026-05-01"]
Expand Down Expand Up @@ -689,15 +680,15 @@ def _extract_path_values(self, template: str, path: str) -> Mapping[str, str]:
return match.groupdict() if match is not None else {}

def _path_pattern(self, template: str) -> re.Pattern[str]:
pattern = "^"
pattern_parts = ["^"]
position = 0
for match in _PATH_PARAMETER_RE.finditer(template):
pattern += re.escape(template[position : match.start()])
pattern += f"(?P<{match.group(1)}>[^/]+)"
pattern_parts.append(re.escape(template[position : match.start()]))
pattern_parts.append(f"(?P<{match.group(1)}>[^/]+)")
position = match.end()
pattern += re.escape(template[position:])
pattern += "$"
return re.compile(pattern)
pattern_parts.append(re.escape(template[position:]))
pattern_parts.append("$")
return re.compile("".join(pattern_parts))

def _normalize_swagger_path(self, path: str) -> str:
if path != "/":
Expand Down
3 changes: 1 addition & 2 deletions docs/site/assets/_gen_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import mkdocs_gen_files

from avito import AvitoClient
from avito.core.domain import AsyncDomainObject, DomainObject
from avito.core.swagger_discovery import discover_swagger_bindings
from avito.core.swagger_linter import lint_swagger_bindings
Expand Down Expand Up @@ -327,8 +328,6 @@ def write_summary(domain_pages: list[str]) -> None:


def ensure_debug_info_exists() -> None:
from avito import AvitoClient

debug_info = getattr(AvitoClient, "debug_info", None)
if debug_info is None or not callable(debug_info):
raise RuntimeError("AvitoClient.debug_info отсутствует в публичном reference-контракте.")
Expand Down
3 changes: 1 addition & 2 deletions scripts/lint_async_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pkgutil
from collections.abc import Iterator

import avito
from avito.core.domain import AsyncDomainObject

EXCLUDED_PACKAGES = {"auth", "core", "summary", "testing"}
Expand All @@ -15,8 +16,6 @@
def iter_async_classes() -> Iterator[type[AsyncDomainObject]]:
"""Yield all public async domain classes in stable order."""

import avito

package_paths = getattr(avito, "__path__", ())
classes: list[type[AsyncDomainObject]] = []
for info in pkgutil.iter_modules(package_paths):
Expand Down
Loading
Loading