Skip to content

Commit 2d8f71f

Browse files
authored
python styleguide (#9)
Styleguide
1 parent 1a8e8db commit 2d8f71f

13 files changed

Lines changed: 462 additions & 1736 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ MKDOCS_ENV=DISABLE_MKDOCS_2_WARNING=true NO_MKDOCS_2_WARNING=1
66

77
check: test quality
88

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

1111
build: clean
1212
poetry build
@@ -32,6 +32,9 @@ fmt:
3232
lint:
3333
poetry run ruff check .
3434

35+
python-guidelines-lint:
36+
poetry run python scripts/lint_python_guidelines.py
37+
3538
swagger-update:
3639
poetry run python scripts/download_avito_api_specs.py --clean
3740

STYLEGUIDE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,37 @@ Principles are listed in descending priority order when they conflict.
3232
- **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.
3333
- **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.
3434

35+
## Python Guidelines Compliance
36+
37+
`.ai/python-guidelines.md` is a mandatory companion standard for all Python code
38+
in this repository. Every new Python file and every changed Python block must
39+
strictly satisfy those rules unless this `STYLEGUIDE.md` explicitly defines a
40+
more specific SDK rule.
41+
42+
Rules:
43+
44+
- Imports must stay at module top level. Runtime imports inside functions,
45+
methods, or classes are forbidden unless the exception is required and
46+
documented next to the import.
47+
- Code must fail fast. Do not swallow exceptions, do not use bare `except`, and
48+
do not catch broad `Exception` unless the handler re-raises.
49+
- Known typed objects must use direct attribute access instead of defensive
50+
`getattr(..., default)`.
51+
- Mutable default arguments, leaked file handles, builtin-name shadowing,
52+
singleton comparisons with `==` / `!=`, runtime validation through `assert`,
53+
collection mutation while iterating, string concatenation in loops, and
54+
late-binding closures in loops are forbidden.
55+
- Public API model fields that intentionally mirror upstream names such as `id`
56+
or `type` are allowed only when they are part of a documented Avito contract.
57+
Do not introduce local variables, helper parameters, or internal DTO fields
58+
with builtin-shadowing names.
59+
- The automated gate for these rules is `make python-guidelines-lint`; it is
60+
included in `make quality` and therefore in `make check`.
61+
62+
If a Python guideline rule cannot be encoded in Ruff, mypy, or an existing
63+
project linter, add or extend a dedicated static lint script. Do not enforce
64+
style-only rules through pytest.
65+
3566
## Target Package Architecture
3667

3768
Avito API sections are organized as packages. The target architecture for new and

avito/_env.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from json import JSONDecodeError, loads
88
from pathlib import Path
99

10+
from avito.core.exceptions import ConfigurationError
11+
1012

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

70-
from avito.core.exceptions import ConfigurationError
71-
7272
try:
7373
return int(value)
7474
except ValueError as exc:
@@ -80,8 +80,6 @@ def parse_env_int(value: str, *, field_name: str) -> int:
8080
def parse_env_float(value: str, *, field_name: str) -> float:
8181
"""Преобразует env-значение в `float` с typed-ошибкой."""
8282

83-
from avito.core.exceptions import ConfigurationError
84-
8583
try:
8684
return float(value)
8785
except ValueError as exc:
@@ -93,8 +91,6 @@ def parse_env_float(value: str, *, field_name: str) -> float:
9391
def parse_env_bool(value: str, *, field_name: str) -> bool:
9492
"""Преобразует env-значение в `bool` с typed-ошибкой."""
9593

96-
from avito.core.exceptions import ConfigurationError
97-
9894
normalized = value.strip().lower()
9995
if normalized in {"1", "true", "yes", "on"}:
10096
return True
@@ -108,8 +104,6 @@ def parse_env_bool(value: str, *, field_name: str) -> bool:
108104
def parse_env_str_tuple(value: str, *, field_name: str) -> tuple[str, ...]:
109105
"""Преобразует env-значение в кортеж строк."""
110106

111-
from avito.core.exceptions import ConfigurationError
112-
113107
stripped = value.strip()
114108
if not stripped:
115109
return ()

avito/auth/async_token_client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from avito.auth.provider import CLIENT_CREDENTIALS_GRANT, REFRESH_TOKEN_GRANT
1212
from avito.auth.settings import AuthSettings
1313
from avito.config import AvitoSettings
14+
from avito.core.async_transport import AsyncTransport
1415
from avito.core.exceptions import AuthenticationError, AvitoError
1516
from avito.core.swagger import swagger_operation
1617
from avito.core.types import RequestContext
@@ -87,8 +88,6 @@ async def request_refresh_token(self, request: RefreshTokenRequest) -> TokenResp
8788

8889
async def _request_token(self, payload: dict[str, str]) -> TokenResponse:
8990
"""Run the request token helper."""
90-
from avito.core.async_transport import AsyncTransport
91-
9291
transport = AsyncTransport(
9392
self.sdk_settings or AvitoSettings(auth=self.settings),
9493
auth_provider=None,

avito/client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from avito.ads import Ad, AdPromotion, AdStats, AutoloadArchive, AutoloadProfile, AutoloadReport
1212
from avito.ads.models import CallStats, ListingStats, ListingStatus, SpendingRecord
1313
from avito.auth import AlternateTokenClient, AuthProvider, TokenClient
14+
from avito.auth.settings import AuthSettings
1415
from avito.autoteka import (
1516
AutotekaMonitoring,
1617
AutotekaReport,
@@ -136,8 +137,6 @@ def __init__(
136137
) -> None:
137138
"""Initialize AvitoClient."""
138139
if client_id is not None or client_secret is not None:
139-
from avito.auth.settings import AuthSettings
140-
141140
auth = AuthSettings(client_id=client_id, client_secret=client_secret)
142141
settings = AvitoSettings(auth=auth)
143142
self._closed = False

avito/core/swagger_linter.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from collections.abc import Callable, Mapping, Sequence
1212
from types import ModuleType
1313

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

552553
client_type: type[object]
553554
if binding.variant == "async":
554-
from avito.async_client import AsyncAvitoClient
555-
556555
client_type = AsyncAvitoClient
557556
else:
558557
client_type = AvitoClient

avito/testing/swagger_fake_transport.py

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from avito.auth import AuthSettings
2020
from avito.auth.models import ClientCredentialsRequest, RefreshTokenRequest
2121
from avito.auth.provider import AlternateTokenClient, TokenClient
22+
from avito.autoteka.models import MonitoringEventsQuery
2223
from avito.client import AvitoClient
2324
from avito.core.swagger_discovery import DiscoveredSwaggerBinding
2425
from avito.core.swagger_names import swagger_field_aliases
@@ -33,6 +34,10 @@
3334
SwaggerSchemaPathError,
3435
resolve_body_path,
3536
)
37+
from avito.jobs.models import ApplicationIdsQuery, ResumeSearchQuery, VacanciesQuery
38+
from avito.messenger.models import UploadImageFile
39+
from avito.ratings.models import ReviewsQuery
40+
from avito.realty.models import RealtyInterval
3641
from avito.testing.fake_transport import FakeTransport, JsonValue, RecordedRequest
3742

3843
SdkValue = object
@@ -354,8 +359,6 @@ def _value_for_argument(
354359
if argument_name == "query":
355360
return self._query_value(annotation)
356361
if argument_name == "files" or "UploadImageFile" in annotation:
357-
from avito.messenger.models import UploadImageFile
358-
359362
return [
360363
UploadImageFile(
361364
field_name="image",
@@ -404,24 +407,14 @@ def _value_for_expression(
404407

405408
def _query_value(self, annotation: str) -> object:
406409
if "MonitoringEventsQuery" in annotation:
407-
from avito.autoteka.models import MonitoringEventsQuery
408-
409410
return MonitoringEventsQuery(limit=2)
410411
if "ApplicationIdsQuery" in annotation:
411-
from avito.jobs.models import ApplicationIdsQuery
412-
413412
return ApplicationIdsQuery(updated_at_from="2026-04-01T00:00:00+00:00")
414413
if "ResumeSearchQuery" in annotation:
415-
from avito.jobs.models import ResumeSearchQuery
416-
417414
return ResumeSearchQuery(query="python")
418415
if "VacanciesQuery" in annotation:
419-
from avito.jobs.models import VacanciesQuery
420-
421416
return VacanciesQuery(query="python")
422417
if "ReviewsQuery" in annotation:
423-
from avito.ratings.models import ReviewsQuery
424-
425418
return ReviewsQuery(offset=0, limit=10)
426419
return self._value_for_name("query")
427420

@@ -583,8 +576,6 @@ def _should_supply_optional_argument(
583576

584577
def _value_for_name(self, name: str) -> object:
585578
if name == "intervals":
586-
from avito.realty.models import RealtyInterval
587-
588579
return [RealtyInterval(date="2026-05-01", available=True)]
589580
if name == "blocked_dates":
590581
return ["2026-05-01"]
@@ -689,15 +680,15 @@ def _extract_path_values(self, template: str, path: str) -> Mapping[str, str]:
689680
return match.groupdict() if match is not None else {}
690681

691682
def _path_pattern(self, template: str) -> re.Pattern[str]:
692-
pattern = "^"
683+
pattern_parts = ["^"]
693684
position = 0
694685
for match in _PATH_PARAMETER_RE.finditer(template):
695-
pattern += re.escape(template[position : match.start()])
696-
pattern += f"(?P<{match.group(1)}>[^/]+)"
686+
pattern_parts.append(re.escape(template[position : match.start()]))
687+
pattern_parts.append(f"(?P<{match.group(1)}>[^/]+)")
697688
position = match.end()
698-
pattern += re.escape(template[position:])
699-
pattern += "$"
700-
return re.compile(pattern)
689+
pattern_parts.append(re.escape(template[position:]))
690+
pattern_parts.append("$")
691+
return re.compile("".join(pattern_parts))
701692

702693
def _normalize_swagger_path(self, path: str) -> str:
703694
if path != "/":

docs/site/assets/_gen_reference.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import mkdocs_gen_files
1010

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

328329

329330
def ensure_debug_info_exists() -> None:
330-
from avito import AvitoClient
331-
332331
debug_info = getattr(AvitoClient, "debug_info", None)
333332
if debug_info is None or not callable(debug_info):
334333
raise RuntimeError("AvitoClient.debug_info отсутствует в публичном reference-контракте.")

scripts/lint_async_parity.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pkgutil
88
from collections.abc import Iterator
99

10+
import avito
1011
from avito.core.domain import AsyncDomainObject
1112

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

18-
import avito
19-
2019
package_paths = getattr(avito, "__path__", ())
2120
classes: list[type[AsyncDomainObject]] = []
2221
for info in pkgutil.iter_modules(package_paths):

0 commit comments

Comments
 (0)