FIAS-RU 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fias_ru-0.1.2/FIAS_RU/SPAS/__init__.py +23 -0
- fias_ru-0.1.2/FIAS_RU/SPAS/base.py +158 -0
- fias_ru-0.1.2/FIAS_RU/SPAS/client.py +460 -0
- fias_ru-0.1.2/FIAS_RU/SPAS/exceptions.py +35 -0
- fias_ru-0.1.2/FIAS_RU/SPAS/models.py +359 -0
- fias_ru-0.1.2/FIAS_RU/__init__.py +55 -0
- fias_ru-0.1.2/FIAS_RU.egg-info/PKG-INFO +499 -0
- fias_ru-0.1.2/FIAS_RU.egg-info/SOURCES.txt +14 -0
- fias_ru-0.1.2/FIAS_RU.egg-info/dependency_links.txt +1 -0
- fias_ru-0.1.2/FIAS_RU.egg-info/requires.txt +10 -0
- fias_ru-0.1.2/FIAS_RU.egg-info/top_level.txt +1 -0
- fias_ru-0.1.2/LICENSE +21 -0
- fias_ru-0.1.2/PKG-INFO +499 -0
- fias_ru-0.1.2/README.md +461 -0
- fias_ru-0.1.2/pyproject.toml +105 -0
- fias_ru-0.1.2/setup.cfg +4 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FIAS_RU/FIAS_RU/SPAS/__init__.py
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .client import SPAS
|
|
6
|
+
from .models import (
|
|
7
|
+
AddressType,
|
|
8
|
+
AddressItem,
|
|
9
|
+
SearchHint,
|
|
10
|
+
AddressDetails,
|
|
11
|
+
AddressObject,
|
|
12
|
+
StructuredAddress,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"SPAS",
|
|
17
|
+
"AddressType",
|
|
18
|
+
"AddressItem",
|
|
19
|
+
"SearchHint",
|
|
20
|
+
"AddressDetails",
|
|
21
|
+
"AddressObject",
|
|
22
|
+
"StructuredAddress",
|
|
23
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FIAS_RU/FIAS_RU/SPAS/base.py
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
import httpx
|
|
8
|
+
import time
|
|
9
|
+
from threading import Lock
|
|
10
|
+
from collections import deque
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RateLimiter:
|
|
14
|
+
"""Простой rate limiter для предотвращения перегрузки API"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, max_requests: int = 100, time_window: float = 60.0):
|
|
17
|
+
"""
|
|
18
|
+
Args:
|
|
19
|
+
max_requests: Максимум запросов
|
|
20
|
+
time_window: Временное окно в секундах
|
|
21
|
+
"""
|
|
22
|
+
self.max_requests = max_requests
|
|
23
|
+
self.time_window = time_window
|
|
24
|
+
self.requests = deque()
|
|
25
|
+
self.lock = Lock()
|
|
26
|
+
|
|
27
|
+
def acquire(self):
|
|
28
|
+
"""Ждём, пока не сможем сделать запрос"""
|
|
29
|
+
with self.lock:
|
|
30
|
+
now = time.time()
|
|
31
|
+
|
|
32
|
+
# Удаляем старые записи
|
|
33
|
+
while self.requests and self.requests[0] < now - self.time_window:
|
|
34
|
+
self.requests.popleft()
|
|
35
|
+
|
|
36
|
+
# Если превышен лимит, ждём
|
|
37
|
+
if len(self.requests) >= self.max_requests:
|
|
38
|
+
sleep_time = self.requests[0] + self.time_window - now
|
|
39
|
+
if sleep_time > 0:
|
|
40
|
+
time.sleep(sleep_time)
|
|
41
|
+
# Рекурсивно пробуем снова
|
|
42
|
+
return self.acquire()
|
|
43
|
+
|
|
44
|
+
# Добавляем текущий запрос
|
|
45
|
+
self.requests.append(now)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FIASClient:
|
|
49
|
+
"""
|
|
50
|
+
Улучшенный базовый клиент для HTTP запросов к ФИАС API
|
|
51
|
+
|
|
52
|
+
Новые возможности:
|
|
53
|
+
- Connection pooling для повторного использования соединений
|
|
54
|
+
- Rate limiting для предотвращения блокировки
|
|
55
|
+
- Настраиваемые retry и таймауты
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
base_url: str,
|
|
61
|
+
timeout: float = 30.0,
|
|
62
|
+
max_connections: int = 100,
|
|
63
|
+
max_keepalive_connections: int = 20,
|
|
64
|
+
rate_limit_requests: int = 100,
|
|
65
|
+
rate_limit_window: float = 60.0
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
Args:
|
|
69
|
+
base_url: Базовый URL API
|
|
70
|
+
timeout: Таймаут запросов в секундах
|
|
71
|
+
max_connections: Максимум одновременных соединений
|
|
72
|
+
max_keepalive_connections: Максимум keep-alive соединений
|
|
73
|
+
rate_limit_requests: Максимум запросов в окне
|
|
74
|
+
rate_limit_window: Размер окна rate limit (секунды)
|
|
75
|
+
"""
|
|
76
|
+
self.base_url = base_url.rstrip('/')
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
self._sync_client: Optional[httpx.Client] = None
|
|
79
|
+
self._async_client: Optional[httpx.AsyncClient] = None
|
|
80
|
+
|
|
81
|
+
# Rate limiter
|
|
82
|
+
self.rate_limiter = RateLimiter(rate_limit_requests, rate_limit_window)
|
|
83
|
+
|
|
84
|
+
# Настройки connection pooling
|
|
85
|
+
self.limits = httpx.Limits(
|
|
86
|
+
max_connections=max_connections,
|
|
87
|
+
max_keepalive_connections=max_keepalive_connections
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def sync_client(self) -> httpx.Client:
|
|
92
|
+
"""Ленивая инициализация синхронного клиента с connection pooling"""
|
|
93
|
+
if self._sync_client is None:
|
|
94
|
+
self._sync_client = httpx.Client(
|
|
95
|
+
base_url=self.base_url,
|
|
96
|
+
timeout=self.timeout,
|
|
97
|
+
limits=self.limits,
|
|
98
|
+
follow_redirects=True
|
|
99
|
+
)
|
|
100
|
+
return self._sync_client
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def async_client(self) -> httpx.AsyncClient:
|
|
104
|
+
"""Ленивая инициализация асинхронного клиента с connection pooling"""
|
|
105
|
+
if self._async_client is None:
|
|
106
|
+
self._async_client = httpx.AsyncClient(
|
|
107
|
+
base_url=self.base_url,
|
|
108
|
+
timeout=self.timeout,
|
|
109
|
+
limits=self.limits,
|
|
110
|
+
follow_redirects=True
|
|
111
|
+
)
|
|
112
|
+
return self._async_client
|
|
113
|
+
|
|
114
|
+
def close(self):
|
|
115
|
+
"""Закрыть соединения корректно"""
|
|
116
|
+
if self._sync_client:
|
|
117
|
+
self._sync_client.close()
|
|
118
|
+
self._sync_client = None
|
|
119
|
+
if self._async_client:
|
|
120
|
+
import asyncio
|
|
121
|
+
try:
|
|
122
|
+
loop = asyncio.get_event_loop()
|
|
123
|
+
if loop.is_running():
|
|
124
|
+
loop.create_task(self._async_client.aclose())
|
|
125
|
+
else:
|
|
126
|
+
loop.run_until_complete(self._async_client.aclose())
|
|
127
|
+
except RuntimeError:
|
|
128
|
+
asyncio.run(self._async_client.aclose())
|
|
129
|
+
finally:
|
|
130
|
+
self._async_client = None
|
|
131
|
+
|
|
132
|
+
async def aclose(self):
|
|
133
|
+
"""Асинхронно закрыть соединения"""
|
|
134
|
+
if self._async_client:
|
|
135
|
+
await self._async_client.aclose()
|
|
136
|
+
self._async_client = None
|
|
137
|
+
if self._sync_client:
|
|
138
|
+
self._sync_client.close()
|
|
139
|
+
self._sync_client = None
|
|
140
|
+
|
|
141
|
+
def __enter__(self):
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, *args):
|
|
145
|
+
self.close()
|
|
146
|
+
|
|
147
|
+
async def __aenter__(self):
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
async def __aexit__(self, *args):
|
|
151
|
+
await self.aclose()
|
|
152
|
+
|
|
153
|
+
def __del__(self):
|
|
154
|
+
"""Убедимся, что соединения закрыты при удалении объекта"""
|
|
155
|
+
try:
|
|
156
|
+
self.close()
|
|
157
|
+
except:
|
|
158
|
+
pass
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FIAS_RU/FIAS_RU/SPAS/client.py
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional, Union
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import logging
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from .base import FIASClient
|
|
11
|
+
from .models import AddressType, AddressItem, SearchHint, AddressDetails
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
FIASValidationError,
|
|
14
|
+
FIASAPIError,
|
|
15
|
+
FIASTimeoutError,
|
|
16
|
+
FIASNetworkError
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Константы
|
|
22
|
+
DEFAULT_BASE_URL = "https://fias-public-service.nalog.ru"
|
|
23
|
+
GUID_PATTERN = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
|
|
24
|
+
CADASTRAL_PATTERN = re.compile(r'^\d{2}:\d{2}:\d{6,7}:\d+$')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def auto_retry(func):
|
|
28
|
+
"""Декоратор для автоматических повторов с логированием"""
|
|
29
|
+
@wraps(func)
|
|
30
|
+
def wrapper(self, *args, **kwargs):
|
|
31
|
+
max_attempts = self.max_retries
|
|
32
|
+
for attempt in range(max_attempts):
|
|
33
|
+
try:
|
|
34
|
+
return func(self, *args, **kwargs)
|
|
35
|
+
except (FIASTimeoutError, FIASNetworkError) as e:
|
|
36
|
+
if attempt < max_attempts - 1:
|
|
37
|
+
logger.warning(f"Попытка {attempt + 1}/{max_attempts} провалилась, повтор...")
|
|
38
|
+
else:
|
|
39
|
+
raise
|
|
40
|
+
except FIASAPIError:
|
|
41
|
+
raise # Не повторяем при API ошибках
|
|
42
|
+
return wrapper
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SPAS(FIASClient):
|
|
46
|
+
"""
|
|
47
|
+
🚀 Максимально простой клиент для работы с ФИАС
|
|
48
|
+
|
|
49
|
+
Быстрый старт:
|
|
50
|
+
>>> from FIAS_RU import SPAS
|
|
51
|
+
>>>
|
|
52
|
+
>>> # Вариант 1: Автоматически из переменных окружения
|
|
53
|
+
>>> spas = SPAS() # Использует FIAS_TOKEN из env
|
|
54
|
+
>>>
|
|
55
|
+
>>> # Вариант 2: Явно указать токен
|
|
56
|
+
>>> spas = SPAS(token="your_token")
|
|
57
|
+
>>>
|
|
58
|
+
>>> # Вариант 3: Полная настройка
|
|
59
|
+
>>> spas = SPAS(base_url="https://...", token="...")
|
|
60
|
+
>>>
|
|
61
|
+
>>> # Поиск (автоопределение типа)
|
|
62
|
+
>>> address = spas.search("Москва, Тверская 1")
|
|
63
|
+
>>> address = spas.search("77000000000000000000000") # по GUID
|
|
64
|
+
>>> address = spas.search("77:01:0001001:1") # по кадастру
|
|
65
|
+
>>> address = spas.search(123456) # по ID
|
|
66
|
+
>>>
|
|
67
|
+
>>> # Автокомплит
|
|
68
|
+
>>> hints = spas.autocomplete("Москва, Тв")
|
|
69
|
+
>>>
|
|
70
|
+
>>> # Работа с результатами
|
|
71
|
+
>>> print(address.full_name)
|
|
72
|
+
>>> print(address.postal_code) # Быстрый доступ к деталям
|
|
73
|
+
>>> print(address.oktmo)
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
base_url: Optional[str] = None,
|
|
79
|
+
token: Optional[str] = None,
|
|
80
|
+
timeout: float = 30.0,
|
|
81
|
+
max_retries: int = 3,
|
|
82
|
+
default_address_type: AddressType = AddressType.ADMINISTRATIVE,
|
|
83
|
+
**kwargs
|
|
84
|
+
):
|
|
85
|
+
"""
|
|
86
|
+
Инициализация клиента SPAS
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
base_url: URL API (по умолчанию: публичный API ФНС)
|
|
90
|
+
token: Токен авторизации (по умолчанию: из FIAS_TOKEN env)
|
|
91
|
+
timeout: Таймаут запросов в секундах
|
|
92
|
+
max_retries: Количество повторных попыток
|
|
93
|
+
default_address_type: Тип адреса по умолчанию
|
|
94
|
+
**kwargs: Дополнительные параметры (max_connections, rate_limit и т.д.)
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
FIASValidationError: Если токен не указан и не найден в переменных окружения
|
|
98
|
+
|
|
99
|
+
Examples:
|
|
100
|
+
>>> # Минимальная конфигурация
|
|
101
|
+
>>> spas = SPAS() # Токен из FIAS_TOKEN
|
|
102
|
+
>>>
|
|
103
|
+
>>> # С явным токеном
|
|
104
|
+
>>> spas = SPAS(token="your_token_here")
|
|
105
|
+
>>>
|
|
106
|
+
>>> # Полная конфигурация
|
|
107
|
+
>>> spas = SPAS(
|
|
108
|
+
... base_url="https://custom-api.com",
|
|
109
|
+
... token="token",
|
|
110
|
+
... timeout=60,
|
|
111
|
+
... max_retries=5
|
|
112
|
+
... )
|
|
113
|
+
"""
|
|
114
|
+
# Автоопределение URL
|
|
115
|
+
if base_url is None:
|
|
116
|
+
base_url = os.getenv("FIAS_BASE_URL", DEFAULT_BASE_URL)
|
|
117
|
+
|
|
118
|
+
# Автоопределение токена
|
|
119
|
+
if token is None:
|
|
120
|
+
token = os.getenv("FIAS_TOKEN")
|
|
121
|
+
if not token:
|
|
122
|
+
raise FIASValidationError(
|
|
123
|
+
"Токен не найден! Укажите токен одним из способов:\n"
|
|
124
|
+
"1. SPAS(token='your_token')\n"
|
|
125
|
+
"2. Установите переменную окружения: export FIAS_TOKEN='your_token'\n"
|
|
126
|
+
"3. Создайте .env файл с FIAS_TOKEN=your_token"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
super().__init__(base_url, timeout, **kwargs)
|
|
130
|
+
self.token = token
|
|
131
|
+
self.max_retries = max_retries
|
|
132
|
+
self.default_address_type = default_address_type
|
|
133
|
+
|
|
134
|
+
logger.info(f"SPAS клиент инициализирован: {base_url}")
|
|
135
|
+
|
|
136
|
+
def _get_headers(self) -> dict:
|
|
137
|
+
"""Получить заголовки с токеном"""
|
|
138
|
+
return {
|
|
139
|
+
"accept": "application/json",
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
"master-token": self.token # ФИАС использует master-token header
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def _handle_response(self, response, error_prefix: str = "Ошибка API"):
|
|
145
|
+
"""Обработка ответа с понятными сообщениями"""
|
|
146
|
+
try:
|
|
147
|
+
response.raise_for_status()
|
|
148
|
+
return response.json()
|
|
149
|
+
except Exception as e:
|
|
150
|
+
status = getattr(response, 'status_code', None)
|
|
151
|
+
|
|
152
|
+
# Красивые сообщения об ошибках
|
|
153
|
+
if status == 403:
|
|
154
|
+
raise FIASAPIError(
|
|
155
|
+
"❌ Доступ запрещён. Проверьте токен авторизации.\n"
|
|
156
|
+
"Получите токен на https://fias.nalog.ru/"
|
|
157
|
+
)
|
|
158
|
+
elif status == 404:
|
|
159
|
+
return None # Не найдено - это нормально
|
|
160
|
+
elif status == 429:
|
|
161
|
+
raise FIASAPIError("⏱️ Превышен лимит запросов. Подождите немного.")
|
|
162
|
+
elif status in (500, 502, 503):
|
|
163
|
+
raise FIASAPIError(f"🔧 Сервер временно недоступен ({status})")
|
|
164
|
+
elif status == 408:
|
|
165
|
+
raise FIASTimeoutError("⏰ Таймаут запроса. Попробуйте увеличить timeout")
|
|
166
|
+
else:
|
|
167
|
+
text = getattr(response, 'text', str(e))[:200]
|
|
168
|
+
raise FIASNetworkError(f"{error_prefix} ({status}): {text}")
|
|
169
|
+
|
|
170
|
+
def _detect_query_type(self, query: Union[str, int]) -> tuple:
|
|
171
|
+
"""
|
|
172
|
+
🧠 Умное определение типа запроса
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
(query_type, normalized_query)
|
|
176
|
+
query_type: 'id' | 'guid' | 'cadastral' | 'string'
|
|
177
|
+
"""
|
|
178
|
+
if isinstance(query, int):
|
|
179
|
+
return ('id', query)
|
|
180
|
+
|
|
181
|
+
if not isinstance(query, str):
|
|
182
|
+
raise FIASValidationError(f"Запрос должен быть строкой или числом: {type(query)}")
|
|
183
|
+
|
|
184
|
+
query = query.strip()
|
|
185
|
+
|
|
186
|
+
# GUID
|
|
187
|
+
if GUID_PATTERN.match(query):
|
|
188
|
+
return ('guid', query)
|
|
189
|
+
|
|
190
|
+
# Кадастровый номер
|
|
191
|
+
if CADASTRAL_PATTERN.match(query):
|
|
192
|
+
return ('cadastral', query)
|
|
193
|
+
|
|
194
|
+
# ID (строка с числом)
|
|
195
|
+
if query.isdigit():
|
|
196
|
+
return ('id', int(query))
|
|
197
|
+
|
|
198
|
+
# Обычная строка
|
|
199
|
+
return ('string', query)
|
|
200
|
+
|
|
201
|
+
# =================================================================
|
|
202
|
+
# ГЛАВНЫЕ МЕТОДЫ (максимально простые)
|
|
203
|
+
# =================================================================
|
|
204
|
+
|
|
205
|
+
@auto_retry
|
|
206
|
+
def search(
|
|
207
|
+
self,
|
|
208
|
+
query: Union[str, int],
|
|
209
|
+
address_type: Optional[AddressType] = None
|
|
210
|
+
) -> Optional[AddressItem]:
|
|
211
|
+
"""
|
|
212
|
+
🔍 Умный поиск - автоматически определяет тип запроса
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
query: Что искать (строка, ID, GUID, кадастровый номер)
|
|
216
|
+
address_type: Тип адреса (по умолчанию из конфига)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Найденный адрес или None
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
>>> spas = SPAS()
|
|
223
|
+
>>>
|
|
224
|
+
>>> # Поиск по строке
|
|
225
|
+
>>> addr = spas.search("Москва, Тверская 1")
|
|
226
|
+
>>>
|
|
227
|
+
>>> # Поиск по GUID
|
|
228
|
+
>>> addr = spas.search("77000000-0000-0000-0000-000000000000")
|
|
229
|
+
>>>
|
|
230
|
+
>>> # Поиск по кадастровому номеру
|
|
231
|
+
>>> addr = spas.search("77:01:0001001:1")
|
|
232
|
+
>>>
|
|
233
|
+
>>> # Поиск по ID
|
|
234
|
+
>>> addr = spas.search(123456)
|
|
235
|
+
>>> addr = spas.search("123456") # Тоже работает
|
|
236
|
+
"""
|
|
237
|
+
query_type, normalized_query = self._detect_query_type(query)
|
|
238
|
+
address_type = address_type or self.default_address_type
|
|
239
|
+
|
|
240
|
+
self.rate_limiter.acquire()
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
# Роутинг по типу запроса
|
|
244
|
+
if query_type == 'id':
|
|
245
|
+
return self._search_by_id(normalized_query, address_type)
|
|
246
|
+
elif query_type == 'guid':
|
|
247
|
+
return self._search_by_guid(normalized_query, address_type)
|
|
248
|
+
elif query_type == 'cadastral':
|
|
249
|
+
return self._search_by_cadastral(normalized_query, address_type)
|
|
250
|
+
else: # string
|
|
251
|
+
return self._search_by_string(normalized_query, address_type)
|
|
252
|
+
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.error(f"Не удалось найти '{query}': {e}")
|
|
255
|
+
raise
|
|
256
|
+
|
|
257
|
+
def _search_by_string(self, query: str, address_type: AddressType) -> Optional[AddressItem]:
|
|
258
|
+
"""Поиск по строке"""
|
|
259
|
+
if len(query) < 3:
|
|
260
|
+
raise FIASValidationError("Минимальная длина запроса - 3 символа")
|
|
261
|
+
|
|
262
|
+
response = self.sync_client.get(
|
|
263
|
+
"/api/spas/v2.0/SearchAddressItem",
|
|
264
|
+
params={"search_string": query, "address_type": address_type.value},
|
|
265
|
+
headers=self._get_headers()
|
|
266
|
+
)
|
|
267
|
+
data = self._handle_response(response, f"Ошибка поиска '{query}'")
|
|
268
|
+
return AddressItem(**data) if data else None
|
|
269
|
+
|
|
270
|
+
def _search_by_id(self, object_id: int, address_type: AddressType) -> Optional[AddressItem]:
|
|
271
|
+
"""Поиск по ID"""
|
|
272
|
+
response = self.sync_client.get(
|
|
273
|
+
"/api/spas/v2.0/GetAddressItemById",
|
|
274
|
+
params={"object_id": object_id, "address_type": address_type.value},
|
|
275
|
+
headers=self._get_headers()
|
|
276
|
+
)
|
|
277
|
+
data = self._handle_response(response, f"Ошибка поиска ID={object_id}")
|
|
278
|
+
addresses = data.get("addresses", []) if data else []
|
|
279
|
+
return AddressItem(**addresses[0]) if addresses else None
|
|
280
|
+
|
|
281
|
+
def _search_by_guid(self, guid: str, address_type: AddressType) -> Optional[AddressItem]:
|
|
282
|
+
"""Поиск по GUID"""
|
|
283
|
+
response = self.sync_client.get(
|
|
284
|
+
"/api/spas/v2.0/GetAddressItemByGuid",
|
|
285
|
+
params={"object_guid": guid, "address_type": address_type.value},
|
|
286
|
+
headers=self._get_headers()
|
|
287
|
+
)
|
|
288
|
+
data = self._handle_response(response, f"Ошибка поиска GUID={guid}")
|
|
289
|
+
addresses = data.get("addresses", []) if data else []
|
|
290
|
+
return AddressItem(**addresses[0]) if addresses else None
|
|
291
|
+
|
|
292
|
+
def _search_by_cadastral(self, cadastral: str, address_type: AddressType) -> Optional[AddressItem]:
|
|
293
|
+
"""Поиск по кадастровому номеру"""
|
|
294
|
+
response = self.sync_client.get(
|
|
295
|
+
"/api/spas/v2.0/GetAddressItemByCadastralNumber",
|
|
296
|
+
params={"cadastral_number": cadastral, "address_type": address_type.value},
|
|
297
|
+
headers=self._get_headers()
|
|
298
|
+
)
|
|
299
|
+
data = self._handle_response(response, f"Ошибка поиска кадастра={cadastral}")
|
|
300
|
+
addresses = data.get("addresses", []) if data else []
|
|
301
|
+
return AddressItem(**addresses[0]) if addresses else None
|
|
302
|
+
|
|
303
|
+
@auto_retry
|
|
304
|
+
def autocomplete(
|
|
305
|
+
self,
|
|
306
|
+
partial_address: str,
|
|
307
|
+
limit: int = 10,
|
|
308
|
+
address_type: Optional[AddressType] = None,
|
|
309
|
+
up_to_level: Optional[int] = None
|
|
310
|
+
) -> List[SearchHint]:
|
|
311
|
+
"""
|
|
312
|
+
💡 Автокомплит адреса (как в Яндекс/Google картах)
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
partial_address: Неполный адрес (минимум 1 символ)
|
|
316
|
+
limit: Максимум подсказок (по умолчанию 10)
|
|
317
|
+
address_type: Тип адреса
|
|
318
|
+
up_to_level: До какого уровня искать
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Список подсказок для автокомплита
|
|
322
|
+
|
|
323
|
+
Examples:
|
|
324
|
+
>>> spas = SPAS()
|
|
325
|
+
>>>
|
|
326
|
+
>>> # Простой автокомплит
|
|
327
|
+
>>> hints = spas.autocomplete("Москва, Тв")
|
|
328
|
+
>>> for hint in hints:
|
|
329
|
+
... print(hint.full_name)
|
|
330
|
+
>>>
|
|
331
|
+
>>> # С ограничением результатов
|
|
332
|
+
>>> hints = spas.autocomplete("Санкт", limit=5)
|
|
333
|
+
>>>
|
|
334
|
+
>>> # До определённого уровня (например, только улицы)
|
|
335
|
+
>>> hints = spas.autocomplete("Москва, Тверская", up_to_level=7)
|
|
336
|
+
"""
|
|
337
|
+
if not partial_address or len(partial_address.strip()) < 1:
|
|
338
|
+
raise FIASValidationError("Минимальная длина запроса - 1 символ")
|
|
339
|
+
|
|
340
|
+
address_type = address_type or self.default_address_type
|
|
341
|
+
self.rate_limiter.acquire()
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
payload = {
|
|
345
|
+
"searchString": partial_address.strip(),
|
|
346
|
+
"addressType": address_type.value
|
|
347
|
+
}
|
|
348
|
+
if up_to_level is not None:
|
|
349
|
+
payload["upToLevel"] = up_to_level
|
|
350
|
+
|
|
351
|
+
response = self.sync_client.post(
|
|
352
|
+
"/api/spas/v2.0/GetAddressHint",
|
|
353
|
+
json=payload,
|
|
354
|
+
headers=self._get_headers()
|
|
355
|
+
)
|
|
356
|
+
data = self._handle_response(response, f"Ошибка автокомплита '{partial_address}'")
|
|
357
|
+
hints = [SearchHint(**hint) for hint in data.get("hints", [])]
|
|
358
|
+
|
|
359
|
+
return hints[:limit]
|
|
360
|
+
except Exception as e:
|
|
361
|
+
logger.error(f"Автокомплит провалился для '{partial_address}': {e}")
|
|
362
|
+
raise
|
|
363
|
+
|
|
364
|
+
@auto_retry
|
|
365
|
+
def get_regions(self) -> List[AddressItem]:
|
|
366
|
+
"""
|
|
367
|
+
🗺️ Получить все регионы РФ
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Список всех регионов
|
|
371
|
+
|
|
372
|
+
Example:
|
|
373
|
+
>>> spas = SPAS()
|
|
374
|
+
>>> regions = spas.get_regions()
|
|
375
|
+
>>> for region in regions[:5]:
|
|
376
|
+
... print(f"{region.region_code}: {region.full_name}")
|
|
377
|
+
"""
|
|
378
|
+
self.rate_limiter.acquire()
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
response = self.sync_client.get(
|
|
382
|
+
"/api/spas/v2.0/GetRegions",
|
|
383
|
+
headers=self._get_headers()
|
|
384
|
+
)
|
|
385
|
+
data = self._handle_response(response, "Ошибка получения регионов")
|
|
386
|
+
return [AddressItem(**addr) for addr in data.get("addresses", [])]
|
|
387
|
+
except Exception as e:
|
|
388
|
+
logger.error(f"Не удалось получить регионы: {e}")
|
|
389
|
+
raise
|
|
390
|
+
|
|
391
|
+
@auto_retry
|
|
392
|
+
def get_details(self, address: Union[AddressItem, int]) -> Optional[AddressDetails]:
|
|
393
|
+
"""
|
|
394
|
+
ℹ️ Получить детали адреса (ОКТМО, ИФНС, почтовый индекс и т.д.)
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
address: AddressItem или object_id
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Детали адреса
|
|
401
|
+
|
|
402
|
+
Examples:
|
|
403
|
+
>>> spas = SPAS()
|
|
404
|
+
>>>
|
|
405
|
+
>>> # Вариант 1: Передать AddressItem
|
|
406
|
+
>>> addr = spas.search("Москва, Тверская 1")
|
|
407
|
+
>>> details = spas.get_details(addr)
|
|
408
|
+
>>>
|
|
409
|
+
>>> # Вариант 2: Передать ID напрямую
|
|
410
|
+
>>> details = spas.get_details(123456)
|
|
411
|
+
>>>
|
|
412
|
+
>>> print(details.postal_code)
|
|
413
|
+
>>> print(details.oktmo)
|
|
414
|
+
"""
|
|
415
|
+
object_id = address.object_id if isinstance(address, AddressItem) else address
|
|
416
|
+
|
|
417
|
+
if not isinstance(object_id, int) or object_id <= 0:
|
|
418
|
+
raise FIASValidationError(f"ID должен быть положительным числом: {object_id}")
|
|
419
|
+
|
|
420
|
+
self.rate_limiter.acquire()
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
response = self.sync_client.get(
|
|
424
|
+
"/api/spas/v2.0/GetDetails",
|
|
425
|
+
params={"object_id": object_id},
|
|
426
|
+
headers=self._get_headers()
|
|
427
|
+
)
|
|
428
|
+
data = self._handle_response(response, f"Ошибка получения деталей ID={object_id}")
|
|
429
|
+
|
|
430
|
+
if data and "address_details" in data:
|
|
431
|
+
return AddressDetails(**data["address_details"])
|
|
432
|
+
return None
|
|
433
|
+
except Exception as e:
|
|
434
|
+
logger.error(f"Не удалось получить детали {object_id}: {e}")
|
|
435
|
+
raise
|
|
436
|
+
|
|
437
|
+
# =================================================================
|
|
438
|
+
# УДОБНЫЕ АЛИАСЫ
|
|
439
|
+
# =================================================================
|
|
440
|
+
|
|
441
|
+
def find(self, query: Union[str, int], **kwargs) -> Optional[AddressItem]:
|
|
442
|
+
"""Алиас для search()"""
|
|
443
|
+
return self.search(query, **kwargs)
|
|
444
|
+
|
|
445
|
+
def complete(self, partial: str, **kwargs) -> List[SearchHint]:
|
|
446
|
+
"""Алиас для autocomplete()"""
|
|
447
|
+
return self.autocomplete(partial, **kwargs)
|
|
448
|
+
|
|
449
|
+
# =================================================================
|
|
450
|
+
# CONTEXT MANAGER
|
|
451
|
+
# =================================================================
|
|
452
|
+
|
|
453
|
+
def __enter__(self):
|
|
454
|
+
return self
|
|
455
|
+
|
|
456
|
+
def __exit__(self, *args):
|
|
457
|
+
self.close()
|
|
458
|
+
|
|
459
|
+
def __repr__(self):
|
|
460
|
+
return f"<SPAS(base_url='{self.base_url}', token='***')>"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FIAS_RU/FIAS_RU/SPAS/exceptions.py
|
|
3
|
+
|
|
4
|
+
Кастомные исключения для FIAS SDK
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FIASError(Exception):
|
|
9
|
+
"""Базовое исключение для всех ошибок FIAS SDK"""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FIASValidationError(FIASError):
|
|
14
|
+
"""Ошибка валидации входных данных"""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FIASAPIError(FIASError):
|
|
19
|
+
"""Ошибка API (5xx, ошибки сервера)"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FIASNetworkError(FIASError):
|
|
24
|
+
"""Сетевая ошибка (проблемы с соединением)"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FIASTimeoutError(FIASError):
|
|
29
|
+
"""Таймаут запроса"""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FIASNotFoundError(FIASError):
|
|
34
|
+
"""Объект не найден (404)"""
|
|
35
|
+
pass
|