visicom-api-client 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,59 @@
1
+ """Unofficial Python client for the Visicom Data API."""
2
+
3
+ from .async_client import AsyncVisicomClient
4
+ from .client import VisicomClient
5
+ from .exceptions import (
6
+ VisicomAPIError,
7
+ VisicomAuthError,
8
+ VisicomError,
9
+ VisicomRateLimitError,
10
+ )
11
+ from .models import (
12
+ AdmCountryProperties,
13
+ AdmDistrictProperties,
14
+ AdmLevel1Properties,
15
+ AdmLevel2Properties,
16
+ AdmLevel3Properties,
17
+ AdmSettlementProperties,
18
+ AdrAddressProperties,
19
+ AdrStreetProperties,
20
+ BufferResponse,
21
+ DistanceMatrixResponse,
22
+ DistanceResult,
23
+ FeatureCollection,
24
+ GeocodeResult,
25
+ GeoFeature,
26
+ LocationResponse,
27
+ PoiProperties,
28
+ SnapToRoadResult,
29
+ TspResponse,
30
+ )
31
+
32
+ __all__ = [
33
+ "VisicomClient",
34
+ "AsyncVisicomClient",
35
+ "VisicomError",
36
+ "VisicomAPIError",
37
+ "VisicomAuthError",
38
+ "VisicomRateLimitError",
39
+ "GeoFeature",
40
+ "FeatureCollection",
41
+ "GeocodeResult",
42
+ "AdmCountryProperties",
43
+ "AdmDistrictProperties",
44
+ "AdmLevel1Properties",
45
+ "AdmLevel2Properties",
46
+ "AdmLevel3Properties",
47
+ "AdmSettlementProperties",
48
+ "AdrAddressProperties",
49
+ "AdrStreetProperties",
50
+ "PoiProperties",
51
+ "DistanceResult",
52
+ "DistanceMatrixResponse",
53
+ "TspResponse",
54
+ "SnapToRoadResult",
55
+ "BufferResponse",
56
+ "LocationResponse",
57
+ ]
58
+
59
+ __version__ = "0.1.0"
visicom_api/_base.py ADDED
@@ -0,0 +1,56 @@
1
+ """Shared helpers for query-parameter formatting.
2
+
3
+ Not part of the public API; import from `visicom_api` instead.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Iterable
9
+ from typing import Any, Union
10
+
11
+ BASE_URL = "https://api.visicom.ua/data-api/5.0"
12
+
13
+ # A point is either an object id (or WKT geometry) string, or an (lng, lat) tuple.
14
+ Point = Union[str, tuple[float, float]]
15
+
16
+
17
+ def _point_to_str(point: Point) -> str:
18
+ if isinstance(point, str):
19
+ return point
20
+ lng, lat = point
21
+ return f"{lng},{lat}"
22
+
23
+
24
+ def _points_param(points: str | Iterable[Point]) -> str:
25
+ """Format a list of points for a query parameter.
26
+
27
+ A pre-formatted string is passed through unchanged. Otherwise, the API
28
+ uses '|' to separate coordinate pairs and ',' to separate object ids, so
29
+ an iterable must be all-coordinates or all-ids; mixed lists must be
30
+ formatted by the caller and passed in as a raw string.
31
+ """
32
+ if isinstance(points, str):
33
+ return points
34
+ items = list(points)
35
+ if not items:
36
+ raise ValueError("points must not be empty")
37
+ if all(isinstance(p, str) for p in items):
38
+ return ",".join(items) # type: ignore[arg-type]
39
+ if all(not isinstance(p, str) for p in items):
40
+ return "|".join(_point_to_str(p) for p in items)
41
+ raise ValueError(
42
+ "cannot mix object ids and (lng, lat) tuples in one list; "
43
+ "pass a pre-formatted string instead"
44
+ )
45
+
46
+
47
+ def _clean_params(params: dict[str, Any]) -> dict[str, Any]:
48
+ """Drop None values and render booleans the way the API expects."""
49
+ cleaned: dict[str, Any] = {}
50
+ for key, value in params.items():
51
+ if value is None:
52
+ continue
53
+ if isinstance(value, bool):
54
+ value = "true" if value else "false"
55
+ cleaned[key] = value
56
+ return cleaned
@@ -0,0 +1,166 @@
1
+ """Query-parameter builders for every Visicom Data API endpoint.
2
+
3
+ This mixin is shared by the sync and async clients so the endpoint naming,
4
+ point formatting, and None-stripping logic lives in exactly one place.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+ from typing import Any, Literal
11
+
12
+ from ._base import Point, _clean_params, _point_to_str, _points_param
13
+
14
+ Format = Literal["json", "csv"]
15
+ Lang = Literal["uk", "ru", "en"]
16
+ Mode = Literal["driving", "driving-shortest", "direct"]
17
+ SnapMode = Literal["driving", "walking"]
18
+ Order = Literal["relevance", "distance"]
19
+
20
+ PathParams = tuple[str, dict[str, Any]]
21
+
22
+
23
+ class _Endpoints:
24
+ default_lang: Lang
25
+
26
+ def _geocode_path_params(
27
+ self,
28
+ *,
29
+ lang: Lang | None,
30
+ format: Format,
31
+ text: str | None,
32
+ word_text: str | None,
33
+ categories: str | None,
34
+ categories_exclude: str | None,
35
+ near: Point | None,
36
+ radius: float | None,
37
+ intersect: Point | None,
38
+ contains: Point | None,
39
+ order: Order | None,
40
+ zoom: int | None,
41
+ limit: int | None,
42
+ country: str | None,
43
+ boost_country: str | None,
44
+ ) -> PathParams:
45
+ path = f"{lang or self.default_lang}/geocode.{format}"
46
+ params = _clean_params(
47
+ {
48
+ "text": text,
49
+ "word_text": word_text,
50
+ "categories": categories,
51
+ "categories_exclude": categories_exclude,
52
+ "near": _point_to_str(near) if near is not None else None,
53
+ "radius": radius,
54
+ "intersect": _point_to_str(intersect) if intersect is not None else None,
55
+ "contains": _point_to_str(contains) if contains is not None else None,
56
+ "order": order,
57
+ "zoom": zoom,
58
+ "limit": limit,
59
+ "country": country,
60
+ "boost_country": boost_country,
61
+ }
62
+ )
63
+ return path, params
64
+
65
+ def _feature_path_params(
66
+ self,
67
+ *,
68
+ ids: str | Iterable[str],
69
+ lang: Lang | None,
70
+ format: Format,
71
+ geometry: bool,
72
+ ) -> PathParams:
73
+ id_param = ids if isinstance(ids, str) else ",".join(ids)
74
+ path = f"{lang or self.default_lang}/feature/{id_param}.{format}"
75
+ params = _clean_params({"geometry": None if geometry else "no"})
76
+ return path, params
77
+
78
+ def _distance_path_params(
79
+ self,
80
+ *,
81
+ origin: Point,
82
+ destination: Point,
83
+ waypoints: str | Iterable[Point] | None,
84
+ locks: str | Iterable[Point] | None,
85
+ mode: Mode,
86
+ geometry: Literal["no", "path"],
87
+ accuracy: float | None,
88
+ ) -> PathParams:
89
+ params = _clean_params(
90
+ {
91
+ "origin": _point_to_str(origin),
92
+ "destination": _point_to_str(destination),
93
+ "waypoints": _points_param(waypoints) if waypoints is not None else None,
94
+ "locks": _points_param(locks) if locks is not None else None,
95
+ "mode": mode,
96
+ "geometry": geometry,
97
+ "accuracy": accuracy,
98
+ }
99
+ )
100
+ return "core/distance.json", params
101
+
102
+ def _distancematrix_path_params(
103
+ self,
104
+ *,
105
+ origins: str | Iterable[Point],
106
+ destinations: str | Iterable[Point],
107
+ locks: str | Iterable[Point] | None,
108
+ mode: Mode,
109
+ ) -> PathParams:
110
+ params = _clean_params(
111
+ {
112
+ "origins": _points_param(origins),
113
+ "destinations": _points_param(destinations),
114
+ "locks": _points_param(locks) if locks is not None else None,
115
+ "mode": mode,
116
+ }
117
+ )
118
+ return "core/distancematrix.json", params
119
+
120
+ def _tsp_path_params(
121
+ self,
122
+ *,
123
+ waypoints: str | Iterable[Point],
124
+ round_trip: bool,
125
+ locks: str | Iterable[Point] | None,
126
+ mode: Mode,
127
+ ) -> PathParams:
128
+ params = _clean_params(
129
+ {
130
+ "waypoints": _points_param(waypoints),
131
+ "round_trip": round_trip,
132
+ "locks": _points_param(locks) if locks is not None else None,
133
+ "mode": mode,
134
+ }
135
+ )
136
+ return "core/tsp.json", params
137
+
138
+ def _snaptoroad_path_params(
139
+ self,
140
+ *,
141
+ points: str | Iterable[Point],
142
+ interpolate: bool,
143
+ mode: SnapMode,
144
+ separate: bool,
145
+ ) -> PathParams:
146
+ params = _clean_params(
147
+ {
148
+ "points": _points_param(points),
149
+ "interpolate": interpolate,
150
+ "mode": mode,
151
+ "separate": separate,
152
+ }
153
+ )
154
+ return "core/snaptoroad.json", params
155
+
156
+ def _location_path_params(
157
+ self, *, start: Point, azimut: float, distance: float, format: Format
158
+ ) -> PathParams:
159
+ params = _clean_params(
160
+ {"start": _point_to_str(start), "azimut": azimut, "distance": distance}
161
+ )
162
+ return f"core/location.{format}", params
163
+
164
+ def _buffer_path_params(self, *, near: Point, radius: float, format: Format) -> PathParams:
165
+ params = _clean_params({"near": _point_to_str(near), "radius": radius})
166
+ return f"core/buffer.{format}", params
@@ -0,0 +1,197 @@
1
+ """Asynchronous client for the Visicom Data API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from types import TracebackType
7
+ from typing import Any, Literal, cast
8
+
9
+ import httpx
10
+
11
+ from ._base import BASE_URL, Point
12
+ from ._endpoints import Format, Lang, Mode, Order, SnapMode, _Endpoints
13
+ from .exceptions import raise_for_status
14
+ from .models import (
15
+ BufferResponse,
16
+ DistanceMatrixResponse,
17
+ DistanceResult,
18
+ GeocodeResult,
19
+ LocationResponse,
20
+ SnapToRoadResult,
21
+ TspResponse,
22
+ )
23
+
24
+
25
+ class AsyncVisicomClient(_Endpoints):
26
+ """Asynchronous client for the Visicom Data API.
27
+
28
+ Example:
29
+ async with AsyncVisicomClient(api_key="YOUR_API_KEY") as client:
30
+ result = await client.geocode(text="Київ, Хрещатик, 26")
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: str,
36
+ *,
37
+ lang: Lang = "uk",
38
+ base_url: str = BASE_URL,
39
+ timeout: float = 10.0,
40
+ client: httpx.AsyncClient | None = None,
41
+ ) -> None:
42
+ self.api_key = api_key
43
+ self.default_lang: Lang = lang
44
+ self._client = client or httpx.AsyncClient(base_url=base_url, timeout=timeout)
45
+
46
+ async def aclose(self) -> None:
47
+ await self._client.aclose()
48
+
49
+ async def __aenter__(self) -> AsyncVisicomClient:
50
+ return self
51
+
52
+ async def __aexit__(
53
+ self,
54
+ exc_type: type[BaseException] | None,
55
+ exc: BaseException | None,
56
+ tb: TracebackType | None,
57
+ ) -> None:
58
+ await self.aclose()
59
+
60
+ async def _get(self, path: str, params: dict[str, Any]) -> Any:
61
+ response = await self._client.get(path, params={**params, "key": self.api_key})
62
+ raise_for_status(response)
63
+ return response.json()
64
+
65
+ async def geocode(
66
+ self,
67
+ *,
68
+ text: str | None = None,
69
+ word_text: str | None = None,
70
+ categories: str | None = None,
71
+ categories_exclude: str | None = None,
72
+ near: Point | None = None,
73
+ radius: float | None = None,
74
+ intersect: Point | None = None,
75
+ contains: Point | None = None,
76
+ order: Order | None = None,
77
+ zoom: int | None = None,
78
+ limit: int | None = None,
79
+ country: str | None = None,
80
+ boost_country: str | None = None,
81
+ lang: Lang | None = None,
82
+ format: Format = "json",
83
+ ) -> GeocodeResult:
84
+ """Search objects by name, or reverse-geocode near a point. See /geocode docs."""
85
+ path, params = self._geocode_path_params(
86
+ lang=lang,
87
+ format=format,
88
+ text=text,
89
+ word_text=word_text,
90
+ categories=categories,
91
+ categories_exclude=categories_exclude,
92
+ near=near,
93
+ radius=radius,
94
+ intersect=intersect,
95
+ contains=contains,
96
+ order=order,
97
+ zoom=zoom,
98
+ limit=limit,
99
+ country=country,
100
+ boost_country=boost_country,
101
+ )
102
+ return cast(GeocodeResult, await self._get(path, params))
103
+
104
+ async def feature(
105
+ self,
106
+ ids: str | Iterable[str],
107
+ *,
108
+ lang: Lang | None = None,
109
+ format: Format = "json",
110
+ geometry: bool = True,
111
+ ) -> GeocodeResult:
112
+ """Fetch full geometry and attributes for up to 250 object ids."""
113
+ path, params = self._feature_path_params(
114
+ ids=ids, lang=lang, format=format, geometry=geometry
115
+ )
116
+ return cast(GeocodeResult, await self._get(path, params))
117
+
118
+ async def distance(
119
+ self,
120
+ origin: Point,
121
+ destination: Point,
122
+ *,
123
+ waypoints: str | Iterable[Point] | None = None,
124
+ locks: str | Iterable[Point] | None = None,
125
+ mode: Mode = "driving",
126
+ geometry: Literal["no", "path"] = "no",
127
+ accuracy: float | None = None,
128
+ ) -> DistanceResult:
129
+ """Compute a route/distance between two points."""
130
+ path, params = self._distance_path_params(
131
+ origin=origin,
132
+ destination=destination,
133
+ waypoints=waypoints,
134
+ locks=locks,
135
+ mode=mode,
136
+ geometry=geometry,
137
+ accuracy=accuracy,
138
+ )
139
+ return cast(DistanceResult, await self._get(path, params))
140
+
141
+ async def distancematrix(
142
+ self,
143
+ origins: str | Iterable[Point],
144
+ destinations: str | Iterable[Point],
145
+ *,
146
+ locks: str | Iterable[Point] | None = None,
147
+ mode: Mode = "driving",
148
+ ) -> DistanceMatrixResponse:
149
+ """Compute a distance matrix between up to 25x25 points."""
150
+ path, params = self._distancematrix_path_params(
151
+ origins=origins, destinations=destinations, locks=locks, mode=mode
152
+ )
153
+ return cast(DistanceMatrixResponse, await self._get(path, params))
154
+
155
+ async def tsp(
156
+ self,
157
+ waypoints: str | Iterable[Point],
158
+ *,
159
+ round_trip: bool = True,
160
+ locks: str | Iterable[Point] | None = None,
161
+ mode: Mode = "driving",
162
+ ) -> TspResponse:
163
+ """Solve the travelling-salesman ordering for up to 50 waypoints."""
164
+ path, params = self._tsp_path_params(
165
+ waypoints=waypoints, round_trip=round_trip, locks=locks, mode=mode
166
+ )
167
+ return cast(TspResponse, await self._get(path, params))
168
+
169
+ async def snaptoroad(
170
+ self,
171
+ points: str | Iterable[Point],
172
+ *,
173
+ interpolate: bool = False,
174
+ mode: SnapMode = "driving",
175
+ separate: bool = False,
176
+ ) -> SnapToRoadResult:
177
+ """Snap up to 250 GNSS points onto the road network."""
178
+ path, params = self._snaptoroad_path_params(
179
+ points=points, interpolate=interpolate, mode=mode, separate=separate
180
+ )
181
+ return cast(SnapToRoadResult, await self._get(path, params))
182
+
183
+ async def location(
184
+ self, start: Point, azimut: float, distance: float, *, format: Format = "json"
185
+ ) -> LocationResponse:
186
+ """Direct geodesic problem: find a point given a start, azimuth, and distance."""
187
+ path, params = self._location_path_params(
188
+ start=start, azimut=azimut, distance=distance, format=format
189
+ )
190
+ return cast(LocationResponse, await self._get(path, params))
191
+
192
+ async def buffer(
193
+ self, near: Point, radius: float, *, format: Format = "json"
194
+ ) -> BufferResponse:
195
+ """Build a buffer polygon (up to 10000 m) around a point/geometry/object."""
196
+ path, params = self._buffer_path_params(near=near, radius=radius, format=format)
197
+ return cast(BufferResponse, await self._get(path, params))
visicom_api/client.py ADDED
@@ -0,0 +1,195 @@
1
+ """Synchronous client for the Visicom Data API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from types import TracebackType
7
+ from typing import Any, Literal, cast
8
+
9
+ import httpx
10
+
11
+ from ._base import BASE_URL, Point
12
+ from ._endpoints import Format, Lang, Mode, Order, SnapMode, _Endpoints
13
+ from .exceptions import raise_for_status
14
+ from .models import (
15
+ BufferResponse,
16
+ DistanceMatrixResponse,
17
+ DistanceResult,
18
+ GeocodeResult,
19
+ LocationResponse,
20
+ SnapToRoadResult,
21
+ TspResponse,
22
+ )
23
+
24
+
25
+ class VisicomClient(_Endpoints):
26
+ """Synchronous client for the Visicom Data API.
27
+
28
+ Example:
29
+ with VisicomClient(api_key="YOUR_API_KEY") as client:
30
+ result = client.geocode(text="Київ, Хрещатик, 26")
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: str,
36
+ *,
37
+ lang: Lang = "uk",
38
+ base_url: str = BASE_URL,
39
+ timeout: float = 10.0,
40
+ client: httpx.Client | None = None,
41
+ ) -> None:
42
+ self.api_key = api_key
43
+ self.default_lang: Lang = lang
44
+ self._client = client or httpx.Client(base_url=base_url, timeout=timeout)
45
+
46
+ def close(self) -> None:
47
+ self._client.close()
48
+
49
+ def __enter__(self) -> VisicomClient:
50
+ return self
51
+
52
+ def __exit__(
53
+ self,
54
+ exc_type: type[BaseException] | None,
55
+ exc: BaseException | None,
56
+ tb: TracebackType | None,
57
+ ) -> None:
58
+ self.close()
59
+
60
+ def _get(self, path: str, params: dict[str, Any]) -> Any:
61
+ response = self._client.get(path, params={**params, "key": self.api_key})
62
+ raise_for_status(response)
63
+ return response.json()
64
+
65
+ def geocode(
66
+ self,
67
+ *,
68
+ text: str | None = None,
69
+ word_text: str | None = None,
70
+ categories: str | None = None,
71
+ categories_exclude: str | None = None,
72
+ near: Point | None = None,
73
+ radius: float | None = None,
74
+ intersect: Point | None = None,
75
+ contains: Point | None = None,
76
+ order: Order | None = None,
77
+ zoom: int | None = None,
78
+ limit: int | None = None,
79
+ country: str | None = None,
80
+ boost_country: str | None = None,
81
+ lang: Lang | None = None,
82
+ format: Format = "json",
83
+ ) -> GeocodeResult:
84
+ """Search objects by name, or reverse-geocode near a point. See /geocode docs."""
85
+ path, params = self._geocode_path_params(
86
+ lang=lang,
87
+ format=format,
88
+ text=text,
89
+ word_text=word_text,
90
+ categories=categories,
91
+ categories_exclude=categories_exclude,
92
+ near=near,
93
+ radius=radius,
94
+ intersect=intersect,
95
+ contains=contains,
96
+ order=order,
97
+ zoom=zoom,
98
+ limit=limit,
99
+ country=country,
100
+ boost_country=boost_country,
101
+ )
102
+ return cast(GeocodeResult, self._get(path, params))
103
+
104
+ def feature(
105
+ self,
106
+ ids: str | Iterable[str],
107
+ *,
108
+ lang: Lang | None = None,
109
+ format: Format = "json",
110
+ geometry: bool = True,
111
+ ) -> GeocodeResult:
112
+ """Fetch full geometry and attributes for up to 250 object ids."""
113
+ path, params = self._feature_path_params(
114
+ ids=ids, lang=lang, format=format, geometry=geometry
115
+ )
116
+ return cast(GeocodeResult, self._get(path, params))
117
+
118
+ def distance(
119
+ self,
120
+ origin: Point,
121
+ destination: Point,
122
+ *,
123
+ waypoints: str | Iterable[Point] | None = None,
124
+ locks: str | Iterable[Point] | None = None,
125
+ mode: Mode = "driving",
126
+ geometry: Literal["no", "path"] = "no",
127
+ accuracy: float | None = None,
128
+ ) -> DistanceResult:
129
+ """Compute a route/distance between two points."""
130
+ path, params = self._distance_path_params(
131
+ origin=origin,
132
+ destination=destination,
133
+ waypoints=waypoints,
134
+ locks=locks,
135
+ mode=mode,
136
+ geometry=geometry,
137
+ accuracy=accuracy,
138
+ )
139
+ return cast(DistanceResult, self._get(path, params))
140
+
141
+ def distancematrix(
142
+ self,
143
+ origins: str | Iterable[Point],
144
+ destinations: str | Iterable[Point],
145
+ *,
146
+ locks: str | Iterable[Point] | None = None,
147
+ mode: Mode = "driving",
148
+ ) -> DistanceMatrixResponse:
149
+ """Compute a distance matrix between up to 25x25 points."""
150
+ path, params = self._distancematrix_path_params(
151
+ origins=origins, destinations=destinations, locks=locks, mode=mode
152
+ )
153
+ return cast(DistanceMatrixResponse, self._get(path, params))
154
+
155
+ def tsp(
156
+ self,
157
+ waypoints: str | Iterable[Point],
158
+ *,
159
+ round_trip: bool = True,
160
+ locks: str | Iterable[Point] | None = None,
161
+ mode: Mode = "driving",
162
+ ) -> TspResponse:
163
+ """Solve the travelling-salesman ordering for up to 50 waypoints."""
164
+ path, params = self._tsp_path_params(
165
+ waypoints=waypoints, round_trip=round_trip, locks=locks, mode=mode
166
+ )
167
+ return cast(TspResponse, self._get(path, params))
168
+
169
+ def snaptoroad(
170
+ self,
171
+ points: str | Iterable[Point],
172
+ *,
173
+ interpolate: bool = False,
174
+ mode: SnapMode = "driving",
175
+ separate: bool = False,
176
+ ) -> SnapToRoadResult:
177
+ """Snap up to 250 GNSS points onto the road network."""
178
+ path, params = self._snaptoroad_path_params(
179
+ points=points, interpolate=interpolate, mode=mode, separate=separate
180
+ )
181
+ return cast(SnapToRoadResult, self._get(path, params))
182
+
183
+ def location(
184
+ self, start: Point, azimut: float, distance: float, *, format: Format = "json"
185
+ ) -> LocationResponse:
186
+ """Direct geodesic problem: find a point given a start, azimuth, and distance."""
187
+ path, params = self._location_path_params(
188
+ start=start, azimut=azimut, distance=distance, format=format
189
+ )
190
+ return cast(LocationResponse, self._get(path, params))
191
+
192
+ def buffer(self, near: Point, radius: float, *, format: Format = "json") -> BufferResponse:
193
+ """Build a buffer polygon (up to 10000 m) around a point/geometry/object."""
194
+ path, params = self._buffer_path_params(near=near, radius=radius, format=format)
195
+ return cast(BufferResponse, self._get(path, params))
@@ -0,0 +1,49 @@
1
+ """Exceptions raised by the Visicom API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+
8
+ class VisicomError(Exception):
9
+ """Base exception for all errors raised by this library."""
10
+
11
+
12
+ class VisicomAPIError(VisicomError):
13
+ """Raised when the Visicom API returns a non-success response."""
14
+
15
+ def __init__(
16
+ self, message: str, status_code: int, response: httpx.Response | None = None
17
+ ) -> None:
18
+ super().__init__(f"[{status_code}] {message}")
19
+ self.status_code = status_code
20
+ self.response = response
21
+
22
+
23
+ class VisicomAuthError(VisicomAPIError):
24
+ """Raised on 401/403 responses - invalid, missing, or expired API key."""
25
+
26
+
27
+ class VisicomRateLimitError(VisicomAPIError):
28
+ """Raised on 429 responses - request quota exceeded."""
29
+
30
+
31
+ def raise_for_status(response: httpx.Response) -> None:
32
+ """Translate an httpx response into a VisicomAPIError subclass if needed."""
33
+ if response.is_success:
34
+ return
35
+
36
+ message = response.text
37
+ try:
38
+ payload = response.json()
39
+ if isinstance(payload, dict):
40
+ message = str(payload.get("message") or payload.get("error") or message)
41
+ except ValueError:
42
+ pass
43
+
44
+ status = response.status_code
45
+ if status in (401, 403):
46
+ raise VisicomAuthError(message, status, response)
47
+ if status == 429:
48
+ raise VisicomRateLimitError(message, status, response)
49
+ raise VisicomAPIError(message, status, response)
visicom_api/models.py ADDED
@@ -0,0 +1,329 @@
1
+ """Typed response shapes for the Visicom Data API, derived from the official
2
+ OAS3 spec (https://api.visicom.ua/data-api/5.0/openapi.yaml).
3
+
4
+ These are TypedDicts, not runtime-validated models: they document the shape
5
+ of the parsed JSON so callers get autocomplete/mypy checking without adding a
6
+ validation dependency. Fields are `total=False` throughout because the spec's
7
+ own `required` lists are inconsistent with its `properties` lists in several
8
+ places (e.g. AdmDistrict requires `settlement_class` but never defines it).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Literal, TypedDict, Union
14
+
15
+ BBox = list[float] # [min_lng, min_lat, max_lng, max_lat]
16
+
17
+
18
+ class PointGeometry(TypedDict, total=False):
19
+ type: Literal["Point"]
20
+ coordinates: list[float] # [lng, lat]
21
+
22
+
23
+ class LineStringGeometry(TypedDict, total=False):
24
+ type: Literal["LineString"]
25
+ coordinates: list[list[float]]
26
+
27
+
28
+ class PolygonGeometry(TypedDict, total=False):
29
+ type: Literal["Polygon"]
30
+ coordinates: list[list[list[float]]]
31
+
32
+
33
+ class MultiPolygonGeometry(TypedDict, total=False):
34
+ type: Literal["MultiPolygon"]
35
+ coordinates: list[list[list[list[float]]]]
36
+
37
+
38
+ Geometry = Union[PointGeometry, LineStringGeometry, PolygonGeometry, MultiPolygonGeometry]
39
+
40
+
41
+ class NamedRef(TypedDict, total=False):
42
+ """An `{id, name}` reference to another object, e.g. an admin subdivision."""
43
+
44
+ id: str
45
+ name: str
46
+
47
+
48
+ # --- /geocode and /feature `properties`, one per `categories` value ---------
49
+
50
+
51
+ class AdmCountryProperties(TypedDict, total=False):
52
+ name: str
53
+ categories: Literal["adm_country"]
54
+ lang: str
55
+ country_code: str
56
+ admin_center: str
57
+ admin_center_id: str
58
+ admin_center_url: str
59
+ copyright: str
60
+ levels1: list[NamedRef]
61
+
62
+
63
+ class AdmDistrictProperties(TypedDict, total=False):
64
+ name: str
65
+ type: str
66
+ categories: Literal["adm_district"]
67
+ country_code: str
68
+ country: str
69
+ lang: str
70
+ level1: str
71
+ level1_id: str
72
+ level2: str
73
+ level2_id: str
74
+ level3: str
75
+ level3_id: str
76
+ settlement: str
77
+ settlement_id: str
78
+ settlement_type: str
79
+ settlement_class: str
80
+ copyright: str
81
+ settlement_url: str
82
+ level1_url: str
83
+ level2_url: str
84
+ level3_url: str
85
+
86
+
87
+ class AdmLevel1Properties(TypedDict, total=False):
88
+ name: str
89
+ categories: Literal["adm_level1"]
90
+ country_code: str
91
+ country: str
92
+ lang: str
93
+ admin_center: str
94
+ admin_center_id: str
95
+ admin_center_url: str
96
+ copyright: str
97
+ levels2: list[NamedRef]
98
+
99
+
100
+ class AdmLevel2Properties(TypedDict, total=False):
101
+ name: str
102
+ categories: Literal["adm_level2"]
103
+ country_code: str
104
+ country: str
105
+ lang: str
106
+ level1: str
107
+ level1_id: str
108
+ level1_url: str
109
+ copyright: str
110
+ levels3: list[NamedRef]
111
+
112
+
113
+ class AdmLevel3Properties(TypedDict, total=False):
114
+ name: str
115
+ categories: Literal["adm_level3"]
116
+ country_code: str
117
+ country: str
118
+ lang: str
119
+ level1: str
120
+ level1_id: str
121
+ level2: str
122
+ level2_id: str
123
+ level2_url: str
124
+ copyright: str
125
+
126
+
127
+ # `class` is a Python keyword, so this one uses the functional TypedDict form.
128
+ AdmSettlementProperties = TypedDict(
129
+ "AdmSettlementProperties",
130
+ {
131
+ "name": str,
132
+ "categories": Literal["adm_settlement"],
133
+ "country_code": str,
134
+ "country": str,
135
+ "lang": str,
136
+ "level1": str,
137
+ "level1_id": str,
138
+ "level2": str,
139
+ "level2_id": str,
140
+ "level3": str,
141
+ "level3_id": str,
142
+ "type": str,
143
+ "class": str,
144
+ "copyright": str,
145
+ },
146
+ total=False,
147
+ )
148
+
149
+
150
+ class AdrAddressProperties(TypedDict, total=False):
151
+ name: str
152
+ categories: Literal["adr_address"]
153
+ street_id: str
154
+ lang: str
155
+ street: str
156
+ street_type: str
157
+ zone: str
158
+ settlement_id: str
159
+ settlement: str
160
+ settlement_type: str
161
+ height: float
162
+ copyright: str
163
+ settlement_url: str
164
+ street_url: str
165
+ # Undocumented in the OAS3 spec, but present in live API responses.
166
+ country: str
167
+ country_code: str
168
+ postal_code: str
169
+ relevance: float
170
+
171
+
172
+ class AdrStreetProperties(TypedDict, total=False):
173
+ name: str
174
+ name_en: str
175
+ categories: Literal["adr_street"]
176
+ country_code: str
177
+ country: str
178
+ lang: str
179
+ settlement_id: str
180
+ zone: str
181
+ type: str
182
+ settlement: str
183
+ settlement_type: str
184
+ settlement_class: str
185
+ address: list[NamedRef]
186
+ copyright: str
187
+ settlement_url: str
188
+
189
+
190
+ class PoiProperties(TypedDict, total=False):
191
+ name: str
192
+ categories: str # one of many poi_* ids, see GET /categories
193
+ lang: str
194
+ address: str
195
+ address_info: str
196
+ country_code: str
197
+ creation_data: str
198
+ creator: str
199
+ description: str
200
+ email: str
201
+ phones: list[str]
202
+ site_url: str
203
+ star: float
204
+ vitrine: str
205
+ w24hours: bool
206
+ icon: int
207
+ photo1: int
208
+ photo2: int
209
+ photo3: int
210
+ copyright: str
211
+
212
+
213
+ FeatureProperties = Union[
214
+ AdmCountryProperties,
215
+ AdmDistrictProperties,
216
+ AdmLevel1Properties,
217
+ AdmLevel2Properties,
218
+ AdmLevel3Properties,
219
+ AdmSettlementProperties,
220
+ AdrAddressProperties,
221
+ AdrStreetProperties,
222
+ PoiProperties,
223
+ ]
224
+
225
+
226
+ class GeoFeature(TypedDict, total=False):
227
+ """A single object as returned by /geocode or /feature."""
228
+
229
+ type: Literal["Feature"]
230
+ id: str
231
+ bbox: BBox
232
+ geo_centroid: PointGeometry
233
+ geometry: Geometry
234
+ properties: Any # narrow with FeatureProperties / cast() based on properties["categories"]
235
+ url: str
236
+
237
+
238
+ class FeatureCollection(TypedDict, total=False):
239
+ """Wraps multiple results. The OAS3 spec names this field `feature`
240
+ (singular), but real responses use the GeoJSON-standard `features`
241
+ (verified against the live API); the spec is wrong here."""
242
+
243
+ type: Literal["FeatureCollection"]
244
+ features: list[GeoFeature]
245
+
246
+
247
+ GeocodeResult = Union[GeoFeature, FeatureCollection]
248
+
249
+
250
+ # --- /distance ---------------------------------------------------------------
251
+
252
+
253
+ class DistanceNoGeometryResponse(TypedDict, total=False):
254
+ distance: int
255
+
256
+
257
+ class _RoutePoint(TypedDict, total=False):
258
+ type: Literal["Point"]
259
+ coordinates: list[float]
260
+
261
+
262
+ class DistancePathProperties(TypedDict, total=False):
263
+ origin: _RoutePoint
264
+ destination: _RoutePoint
265
+ distance: float
266
+
267
+
268
+ class DistancePathResponse(TypedDict, total=False):
269
+ type: Literal["Feature"]
270
+ geometry: LineStringGeometry
271
+ properties: DistancePathProperties
272
+ bbox: BBox
273
+
274
+
275
+ DistanceResult = Union[DistanceNoGeometryResponse, DistancePathResponse]
276
+
277
+
278
+ # --- /distancematrix -----------------------------------------------------------
279
+
280
+
281
+ class DistanceMatrixCell(TypedDict, total=False):
282
+ distance: int
283
+ # Not in the OAS3 schema, but present in the docs' example responses.
284
+ status: str
285
+
286
+
287
+ class DistanceMatrixResponse(TypedDict, total=False):
288
+ rows: list[list[DistanceMatrixCell]]
289
+
290
+
291
+ # --- /tsp ----------------------------------------------------------------------
292
+
293
+
294
+ class TspWaypoint(TypedDict, total=False):
295
+ index: int
296
+
297
+
298
+ class TspResponse(TypedDict, total=False):
299
+ list: list[TspWaypoint]
300
+
301
+
302
+ # --- /buffer and /location -------------------------------------------------------
303
+
304
+ BufferResponse = PolygonGeometry
305
+ LocationResponse = PointGeometry
306
+
307
+
308
+ # --- /snaptoroad -----------------------------------------------------------------
309
+
310
+
311
+ class SnapPointFeature(TypedDict, total=False):
312
+ type: Literal["Feature"]
313
+ geometry: PointGeometry
314
+ bbox: BBox
315
+
316
+
317
+ class SnapLineFeature(TypedDict, total=False):
318
+ type: Literal["Feature"]
319
+ geometry: LineStringGeometry
320
+ bbox: BBox
321
+
322
+
323
+ class SnapFeatureCollection(TypedDict, total=False):
324
+ type: Literal["FeatureCollection"]
325
+ features: list[SnapPointFeature | SnapLineFeature]
326
+ bbox: BBox
327
+
328
+
329
+ SnapToRoadResult = Union[SnapPointFeature, SnapLineFeature, SnapFeatureCollection]
visicom_api/py.typed ADDED
File without changes
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: visicom-api-client
3
+ Version: 0.1.0
4
+ Summary: Unofficial sync/async Python client for the Visicom Data API
5
+ Project-URL: Homepage, https://api.visicom.ua/uk/products/data-api/data-api-references
6
+ Author: Your Name
7
+ License: MIT
8
+ Keywords: api-client,geocoding,gis,visicom
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: httpx>=0.24
16
+ Provides-Extra: dev
17
+ Requires-Dist: mypy>=1.8; extra == 'dev'
18
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
19
+ Requires-Dist: pytest>=7.0; extra == 'dev'
20
+ Requires-Dist: respx>=0.20; extra == 'dev'
21
+ Requires-Dist: ruff>=0.4; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # visicom-api-client
25
+
26
+ Неофіційний Python-клієнт для [Visicom Data API](https://api.visicom.ua/uk/products/data-api/data-api-references) (геокодування, маршрутизація, буфери, прив'язка до доріг).
27
+
28
+ ## Встановлення
29
+
30
+ ```bash
31
+ pip install -e ".[dev]"
32
+ ```
33
+
34
+ ## Використання
35
+
36
+ Синхронно:
37
+
38
+ ```python
39
+ from visicom_api import VisicomClient
40
+
41
+ with VisicomClient(api_key="YOUR_API_KEY") as client:
42
+ result = client.geocode(text="Київ, Хрещатик, 26")
43
+ print(result)
44
+
45
+ route = client.distance((30.42612, 50.45111), (30.44946, 50.45682))
46
+ print(route["distance"])
47
+ ```
48
+
49
+ Асинхронно:
50
+
51
+ ```python
52
+ import asyncio
53
+ from visicom_api import AsyncVisicomClient
54
+
55
+
56
+ async def main() -> None:
57
+ async with AsyncVisicomClient(api_key="YOUR_API_KEY") as client:
58
+ result = await client.geocode(categories="poi_restaurant", near=(30.5113, 50.4550), radius=300)
59
+ print(result)
60
+
61
+
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ Точки (`origin`, `near`, `waypoints`, ...) можна передавати як:
66
+ - ідентифікатор об'єкта: `"POIA1KIGKN"`
67
+ - координати: `(lng, lat)` кортеж
68
+ - список точок для `waypoints`/`locks`/`origins`/`destinations`/`points`: `[(lng, lat), ...]` або список id
69
+ - заздалегідь відформатований рядок (наприклад, WKT-геометрія)
70
+
71
+ ## Обробка помилок
72
+
73
+ ```python
74
+ from visicom_api import VisicomAuthError, VisicomRateLimitError, VisicomAPIError
75
+
76
+ try:
77
+ client.geocode(text="...")
78
+ except VisicomAuthError:
79
+ ... # невірний або відсутній ключ
80
+ except VisicomRateLimitError:
81
+ ... # перевищено ліміт запитів
82
+ except VisicomAPIError as e:
83
+ ... # інша помилка API, e.status_code
84
+ ```
85
+
86
+ ## Розробка
87
+
88
+ ```bash
89
+ pip install -e ".[dev]"
90
+ ruff check .
91
+ mypy src
92
+ pytest
93
+ ```
@@ -0,0 +1,11 @@
1
+ visicom_api/__init__.py,sha256=wheLVj2FJ-KV_5TqJ8fhVSePhjy_XEMrtwVyQjvbXEc,1322
2
+ visicom_api/_base.py,sha256=ctnMWf9JgT0cEl74LRefFdQSuXiDVUqZXHKQ4VYVAR0,1832
3
+ visicom_api/_endpoints.py,sha256=mM1nPFnrdnQmaEXKwd3zcIhk8S7XmXsLkE52QvMYWCY,5336
4
+ visicom_api/async_client.py,sha256=1zBaWNsCXgC1WFb9Uxht21krp6sSTWQa3lMl9TZ6oMM,6597
5
+ visicom_api/client.py,sha256=QvFFvPqi2wNvTtotFC81D-zmf5NfzYzDMjEdQ73lo_s,6401
6
+ visicom_api/exceptions.py,sha256=ds2MP3ojH6Fvsk8QTG2t7oKZm4RMrNjf-Z9ullFXlSE,1455
7
+ visicom_api/models.py,sha256=k5bsKKw6a5-DuPNlP62_pPy33QsGbVUIQLgdMQQOUwI,7789
8
+ visicom_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ visicom_api_client-0.1.0.dist-info/METADATA,sha256=eC7sf3fgCGCDtRON0v5rSa9EXAe4QIzPiogLJK_X7n0,2804
10
+ visicom_api_client-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ visicom_api_client-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any