permisapi-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,33 @@
|
|
|
1
|
+
"""Client Python officiel pour PermisAPI.
|
|
2
|
+
|
|
3
|
+
Usage sync :
|
|
4
|
+
from permisapi_client import PermisAPI
|
|
5
|
+
client = PermisAPI(api_key="pk_live_...")
|
|
6
|
+
for permit in client.permits.list(dep_code="75", limit=10):
|
|
7
|
+
print(permit["num_pa"], permit["adr_localite_ter"])
|
|
8
|
+
|
|
9
|
+
Usage async :
|
|
10
|
+
from permisapi_client import AsyncPermisAPI
|
|
11
|
+
async with AsyncPermisAPI(api_key="pk_live_...") as client:
|
|
12
|
+
permits = await client.permits.near(lat=48.85, lng=2.35, radius_m=500)
|
|
13
|
+
"""
|
|
14
|
+
from permisapi_client.client import AsyncPermisAPI, PermisAPI
|
|
15
|
+
from permisapi_client.exceptions import (
|
|
16
|
+
PermisAPIError,
|
|
17
|
+
PermisAPIQuotaExceeded,
|
|
18
|
+
PermisAPIRateLimited,
|
|
19
|
+
PermisAPIUnauthorized,
|
|
20
|
+
PermisAPIValidationError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"PermisAPI",
|
|
25
|
+
"AsyncPermisAPI",
|
|
26
|
+
"PermisAPIError",
|
|
27
|
+
"PermisAPIUnauthorized",
|
|
28
|
+
"PermisAPIRateLimited",
|
|
29
|
+
"PermisAPIQuotaExceeded",
|
|
30
|
+
"PermisAPIValidationError",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""PermisAPI sync + async client.
|
|
2
|
+
|
|
3
|
+
Minimal, no ORM. Returns dict / list[dict] pour rester flexible aux
|
|
4
|
+
futurs changements de schema cote API.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Iterator, AsyncIterator
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from permisapi_client.exceptions import error_from_response
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.permisapi.fr"
|
|
15
|
+
DEFAULT_TIMEOUT = 15.0
|
|
16
|
+
USER_AGENT = "permisapi-client-python/0.1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _raise_for_status(resp: httpx.Response) -> None:
|
|
20
|
+
if 200 <= resp.status_code < 300:
|
|
21
|
+
return
|
|
22
|
+
try:
|
|
23
|
+
payload = resp.json()
|
|
24
|
+
except ValueError:
|
|
25
|
+
payload = {"message": resp.text[:200] or "Erreur inconnue"}
|
|
26
|
+
if "retry_after" not in payload and "Retry-After" in resp.headers:
|
|
27
|
+
try:
|
|
28
|
+
payload["retry_after"] = int(resp.headers["Retry-After"])
|
|
29
|
+
except ValueError:
|
|
30
|
+
pass
|
|
31
|
+
raise error_from_response(resp.status_code, payload)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_headers(api_key: str, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
35
|
+
h = {
|
|
36
|
+
"X-API-Key": api_key,
|
|
37
|
+
"Accept": "application/json",
|
|
38
|
+
"User-Agent": USER_AGENT,
|
|
39
|
+
}
|
|
40
|
+
if extra:
|
|
41
|
+
h.update(extra)
|
|
42
|
+
return h
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Sync client
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _PermitsResource:
|
|
51
|
+
def __init__(self, client: PermisAPI) -> None:
|
|
52
|
+
self._c = client
|
|
53
|
+
|
|
54
|
+
def list(self, **params: Any) -> dict[str, Any]:
|
|
55
|
+
"""GET /v1/permits avec filtres. Retourne {items, pagination}."""
|
|
56
|
+
return self._c._get("/v1/permits", params=params)
|
|
57
|
+
|
|
58
|
+
def iter_all(self, **params: Any) -> Iterator[dict[str, Any]]:
|
|
59
|
+
"""Itere sur toutes les pages (limite fixee par le plan)."""
|
|
60
|
+
page = 1
|
|
61
|
+
per_page = params.get("limit", 100)
|
|
62
|
+
while True:
|
|
63
|
+
p = {**params, "page": page, "limit": per_page}
|
|
64
|
+
data = self.list(**p)
|
|
65
|
+
items = data.get("items", [])
|
|
66
|
+
if not items:
|
|
67
|
+
return
|
|
68
|
+
yield from items
|
|
69
|
+
pages = (data.get("pagination") or {}).get("pages", 1)
|
|
70
|
+
if page >= pages:
|
|
71
|
+
return
|
|
72
|
+
page += 1
|
|
73
|
+
|
|
74
|
+
def get(self, num_pa: str) -> dict[str, Any]:
|
|
75
|
+
"""GET /v1/permits/{num_pa}."""
|
|
76
|
+
return self._c._get(f"/v1/permits/{num_pa}")
|
|
77
|
+
|
|
78
|
+
def near(
|
|
79
|
+
self, *, lat: float, lng: float, radius_m: int, **filters: Any
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""GET /v1/permits/near?lat&lng&radius_m (PostGIS)."""
|
|
82
|
+
return self._c._get(
|
|
83
|
+
"/v1/permits/near",
|
|
84
|
+
params={"lat": lat, "lng": lng, "radius_m": radius_m, **filters},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def by_cadastre(self, *, section: str, numero: str) -> dict[str, Any]:
|
|
88
|
+
return self._c._get(
|
|
89
|
+
"/v1/permits/by-cadastre",
|
|
90
|
+
params={"section": section, "numero": numero},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _StatsResource:
|
|
95
|
+
def __init__(self, client: PermisAPI) -> None:
|
|
96
|
+
self._c = client
|
|
97
|
+
|
|
98
|
+
def commune(self, code: str) -> dict[str, Any]:
|
|
99
|
+
return self._c._get(f"/v1/stats/commune/{code}")
|
|
100
|
+
|
|
101
|
+
def departement(self, code: str) -> dict[str, Any]:
|
|
102
|
+
return self._c._get(f"/v1/stats/departement/{code}")
|
|
103
|
+
|
|
104
|
+
def region(self, code: str) -> dict[str, Any]:
|
|
105
|
+
return self._c._get(f"/v1/stats/region/{code}")
|
|
106
|
+
|
|
107
|
+
def trends(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
dep_code: str | None = None,
|
|
111
|
+
comm_code: str | None = None,
|
|
112
|
+
group_by: str = "month",
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
params = {"group_by": group_by}
|
|
115
|
+
if dep_code:
|
|
116
|
+
params["dep_code"] = dep_code
|
|
117
|
+
if comm_code:
|
|
118
|
+
params["comm_code"] = comm_code
|
|
119
|
+
return self._c._get("/v1/stats/trends", params=params)
|
|
120
|
+
|
|
121
|
+
def latest_day(self) -> dict[str, Any]:
|
|
122
|
+
return self._c._get("/v1/stats/latest-day")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _AlertsResource:
|
|
126
|
+
def __init__(self, client: PermisAPI) -> None:
|
|
127
|
+
self._c = client
|
|
128
|
+
|
|
129
|
+
def list(self) -> list[dict[str, Any]]:
|
|
130
|
+
return self._c._get("/v1/alerts")
|
|
131
|
+
|
|
132
|
+
def create(
|
|
133
|
+
self,
|
|
134
|
+
*,
|
|
135
|
+
name: str,
|
|
136
|
+
center_lat: float,
|
|
137
|
+
center_lng: float,
|
|
138
|
+
radius_km: float,
|
|
139
|
+
webhook_url: str,
|
|
140
|
+
permit_types: list[str] | None = None,
|
|
141
|
+
min_superficie: int | None = None,
|
|
142
|
+
etat_pa: int | None = None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""Cree une alerte. Le `webhook_secret` renvoye n'est visible QU'ICI."""
|
|
145
|
+
body: dict[str, Any] = {
|
|
146
|
+
"name": name,
|
|
147
|
+
"center_lat": center_lat,
|
|
148
|
+
"center_lng": center_lng,
|
|
149
|
+
"radius_km": radius_km,
|
|
150
|
+
"webhook_url": webhook_url,
|
|
151
|
+
}
|
|
152
|
+
if permit_types:
|
|
153
|
+
body["permit_types"] = permit_types
|
|
154
|
+
if min_superficie is not None:
|
|
155
|
+
body["min_superficie"] = min_superficie
|
|
156
|
+
if etat_pa is not None:
|
|
157
|
+
body["etat_pa"] = etat_pa
|
|
158
|
+
return self._c._post("/v1/alerts", json=body)
|
|
159
|
+
|
|
160
|
+
def get(self, alert_id: int) -> dict[str, Any]:
|
|
161
|
+
return self._c._get(f"/v1/alerts/{alert_id}")
|
|
162
|
+
|
|
163
|
+
def update(self, alert_id: int, **patch: Any) -> dict[str, Any]:
|
|
164
|
+
return self._c._patch(f"/v1/alerts/{alert_id}", json=patch)
|
|
165
|
+
|
|
166
|
+
def delete(self, alert_id: int) -> None:
|
|
167
|
+
self._c._delete(f"/v1/alerts/{alert_id}")
|
|
168
|
+
|
|
169
|
+
def replay(self, alert_id: int) -> dict[str, Any]:
|
|
170
|
+
return self._c._post(f"/v1/alerts/{alert_id}/replay")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _MeResource:
|
|
174
|
+
def __init__(self, client: PermisAPI) -> None:
|
|
175
|
+
self._c = client
|
|
176
|
+
|
|
177
|
+
def get(self) -> dict[str, Any]:
|
|
178
|
+
return self._c._get("/v1/me")
|
|
179
|
+
|
|
180
|
+
def usage(self, days: int = 30) -> dict[str, Any]:
|
|
181
|
+
return self._c._get("/v1/me/usage", params={"days": days})
|
|
182
|
+
|
|
183
|
+
def regenerate_key(self) -> dict[str, Any]:
|
|
184
|
+
"""Rotate la cle. La nouvelle cle n'est visible QU'UNE FOIS."""
|
|
185
|
+
return self._c._post("/v1/me/regenerate-key")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class PermisAPI:
|
|
189
|
+
"""Client sync. Thread-safe pour des lectures concurrentes.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
api_key: ta cle `pk_live_...` (obtenue sur https://permisapi.fr/#pricing)
|
|
193
|
+
base_url: override si tu self-host (defaut Railway prod)
|
|
194
|
+
timeout: timeout HTTP par requete (defaut 15s)
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
api_key: str,
|
|
200
|
+
*,
|
|
201
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
202
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
203
|
+
client: httpx.Client | None = None,
|
|
204
|
+
) -> None:
|
|
205
|
+
if not api_key:
|
|
206
|
+
raise ValueError("api_key est requise (pk_live_... ou pk_test_...)")
|
|
207
|
+
self.api_key = api_key
|
|
208
|
+
self.base_url = base_url.rstrip("/")
|
|
209
|
+
self.timeout = timeout
|
|
210
|
+
self._http = client or httpx.Client(
|
|
211
|
+
base_url=self.base_url,
|
|
212
|
+
timeout=timeout,
|
|
213
|
+
headers=_build_headers(api_key),
|
|
214
|
+
)
|
|
215
|
+
self.permits = _PermitsResource(self)
|
|
216
|
+
self.stats = _StatsResource(self)
|
|
217
|
+
self.alerts = _AlertsResource(self)
|
|
218
|
+
self.me = _MeResource(self)
|
|
219
|
+
|
|
220
|
+
def close(self) -> None:
|
|
221
|
+
self._http.close()
|
|
222
|
+
|
|
223
|
+
def __enter__(self) -> PermisAPI:
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
def __exit__(self, *exc: Any) -> None:
|
|
227
|
+
self.close()
|
|
228
|
+
|
|
229
|
+
# -- internal HTTP helpers --
|
|
230
|
+
|
|
231
|
+
def _get(self, path: str, *, params: dict | None = None) -> Any:
|
|
232
|
+
resp = self._http.get(path, params=params)
|
|
233
|
+
_raise_for_status(resp)
|
|
234
|
+
return resp.json()
|
|
235
|
+
|
|
236
|
+
def _post(self, path: str, *, json: dict | None = None) -> Any:
|
|
237
|
+
resp = self._http.post(path, json=json)
|
|
238
|
+
_raise_for_status(resp)
|
|
239
|
+
if resp.status_code == 204 or not resp.content:
|
|
240
|
+
return None
|
|
241
|
+
return resp.json()
|
|
242
|
+
|
|
243
|
+
def _patch(self, path: str, *, json: dict | None = None) -> Any:
|
|
244
|
+
resp = self._http.patch(path, json=json)
|
|
245
|
+
_raise_for_status(resp)
|
|
246
|
+
return resp.json()
|
|
247
|
+
|
|
248
|
+
def _delete(self, path: str) -> None:
|
|
249
|
+
resp = self._http.delete(path)
|
|
250
|
+
_raise_for_status(resp)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
# Async client
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class _AsyncPermitsResource:
|
|
259
|
+
def __init__(self, client: AsyncPermisAPI) -> None:
|
|
260
|
+
self._c = client
|
|
261
|
+
|
|
262
|
+
async def list(self, **params: Any) -> dict[str, Any]:
|
|
263
|
+
return await self._c._get("/v1/permits", params=params)
|
|
264
|
+
|
|
265
|
+
async def iter_all(self, **params: Any) -> AsyncIterator[dict[str, Any]]:
|
|
266
|
+
page = 1
|
|
267
|
+
per_page = params.get("limit", 100)
|
|
268
|
+
while True:
|
|
269
|
+
p = {**params, "page": page, "limit": per_page}
|
|
270
|
+
data = await self.list(**p)
|
|
271
|
+
items = data.get("items", [])
|
|
272
|
+
if not items:
|
|
273
|
+
return
|
|
274
|
+
for item in items:
|
|
275
|
+
yield item
|
|
276
|
+
pages = (data.get("pagination") or {}).get("pages", 1)
|
|
277
|
+
if page >= pages:
|
|
278
|
+
return
|
|
279
|
+
page += 1
|
|
280
|
+
|
|
281
|
+
async def get(self, num_pa: str) -> dict[str, Any]:
|
|
282
|
+
return await self._c._get(f"/v1/permits/{num_pa}")
|
|
283
|
+
|
|
284
|
+
async def near(
|
|
285
|
+
self, *, lat: float, lng: float, radius_m: int, **filters: Any
|
|
286
|
+
) -> dict[str, Any]:
|
|
287
|
+
return await self._c._get(
|
|
288
|
+
"/v1/permits/near",
|
|
289
|
+
params={"lat": lat, "lng": lng, "radius_m": radius_m, **filters},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class _AsyncStatsResource:
|
|
294
|
+
def __init__(self, client: AsyncPermisAPI) -> None:
|
|
295
|
+
self._c = client
|
|
296
|
+
|
|
297
|
+
async def commune(self, code: str) -> dict[str, Any]:
|
|
298
|
+
return await self._c._get(f"/v1/stats/commune/{code}")
|
|
299
|
+
|
|
300
|
+
async def departement(self, code: str) -> dict[str, Any]:
|
|
301
|
+
return await self._c._get(f"/v1/stats/departement/{code}")
|
|
302
|
+
|
|
303
|
+
async def region(self, code: str) -> dict[str, Any]:
|
|
304
|
+
return await self._c._get(f"/v1/stats/region/{code}")
|
|
305
|
+
|
|
306
|
+
async def trends(self, **params: Any) -> dict[str, Any]:
|
|
307
|
+
return await self._c._get("/v1/stats/trends", params=params)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class _AsyncAlertsResource:
|
|
311
|
+
def __init__(self, client: AsyncPermisAPI) -> None:
|
|
312
|
+
self._c = client
|
|
313
|
+
|
|
314
|
+
async def list(self) -> list[dict[str, Any]]:
|
|
315
|
+
return await self._c._get("/v1/alerts")
|
|
316
|
+
|
|
317
|
+
async def create(self, **body: Any) -> dict[str, Any]:
|
|
318
|
+
return await self._c._post("/v1/alerts", json=body)
|
|
319
|
+
|
|
320
|
+
async def delete(self, alert_id: int) -> None:
|
|
321
|
+
await self._c._delete(f"/v1/alerts/{alert_id}")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class _AsyncMeResource:
|
|
325
|
+
def __init__(self, client: AsyncPermisAPI) -> None:
|
|
326
|
+
self._c = client
|
|
327
|
+
|
|
328
|
+
async def get(self) -> dict[str, Any]:
|
|
329
|
+
return await self._c._get("/v1/me")
|
|
330
|
+
|
|
331
|
+
async def usage(self, days: int = 30) -> dict[str, Any]:
|
|
332
|
+
return await self._c._get("/v1/me/usage", params={"days": days})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class AsyncPermisAPI:
|
|
336
|
+
"""Version async : `async with` et `await`."""
|
|
337
|
+
|
|
338
|
+
def __init__(
|
|
339
|
+
self,
|
|
340
|
+
api_key: str,
|
|
341
|
+
*,
|
|
342
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
343
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
344
|
+
client: httpx.AsyncClient | None = None,
|
|
345
|
+
) -> None:
|
|
346
|
+
if not api_key:
|
|
347
|
+
raise ValueError("api_key est requise (pk_live_... ou pk_test_...)")
|
|
348
|
+
self.api_key = api_key
|
|
349
|
+
self.base_url = base_url.rstrip("/")
|
|
350
|
+
self.timeout = timeout
|
|
351
|
+
self._http = client or httpx.AsyncClient(
|
|
352
|
+
base_url=self.base_url,
|
|
353
|
+
timeout=timeout,
|
|
354
|
+
headers=_build_headers(api_key),
|
|
355
|
+
)
|
|
356
|
+
self.permits = _AsyncPermitsResource(self)
|
|
357
|
+
self.stats = _AsyncStatsResource(self)
|
|
358
|
+
self.alerts = _AsyncAlertsResource(self)
|
|
359
|
+
self.me = _AsyncMeResource(self)
|
|
360
|
+
|
|
361
|
+
async def close(self) -> None:
|
|
362
|
+
await self._http.aclose()
|
|
363
|
+
|
|
364
|
+
async def __aenter__(self) -> AsyncPermisAPI:
|
|
365
|
+
return self
|
|
366
|
+
|
|
367
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
368
|
+
await self.close()
|
|
369
|
+
|
|
370
|
+
async def _get(self, path: str, *, params: dict | None = None) -> Any:
|
|
371
|
+
resp = await self._http.get(path, params=params)
|
|
372
|
+
_raise_for_status(resp)
|
|
373
|
+
return resp.json()
|
|
374
|
+
|
|
375
|
+
async def _post(self, path: str, *, json: dict | None = None) -> Any:
|
|
376
|
+
resp = await self._http.post(path, json=json)
|
|
377
|
+
_raise_for_status(resp)
|
|
378
|
+
if resp.status_code == 204 or not resp.content:
|
|
379
|
+
return None
|
|
380
|
+
return resp.json()
|
|
381
|
+
|
|
382
|
+
async def _delete(self, path: str) -> None:
|
|
383
|
+
resp = await self._http.delete(path)
|
|
384
|
+
_raise_for_status(resp)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Exceptions hierarchy mirror des codes d'erreur de l'API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PermisAPIError(Exception):
|
|
8
|
+
"""Base : toute erreur renvoyee par l'API."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
message: str,
|
|
13
|
+
*,
|
|
14
|
+
status_code: int | None = None,
|
|
15
|
+
error_code: str | None = None,
|
|
16
|
+
detail: Any = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status_code = status_code
|
|
20
|
+
self.error_code = error_code
|
|
21
|
+
self.detail = detail
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PermisAPIUnauthorized(PermisAPIError):
|
|
25
|
+
"""401 : cle manquante ou invalide."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PermisAPIForbidden(PermisAPIError):
|
|
29
|
+
"""403 : compte desactive."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PermisAPIRateLimited(PermisAPIError):
|
|
33
|
+
"""429 : quota minute atteint (retry apres X secondes)."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
retry_after: int | None = None,
|
|
40
|
+
status_code: int | None = None,
|
|
41
|
+
error_code: str | None = None,
|
|
42
|
+
detail: Any = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(
|
|
45
|
+
message,
|
|
46
|
+
status_code=status_code,
|
|
47
|
+
error_code=error_code,
|
|
48
|
+
detail=detail,
|
|
49
|
+
)
|
|
50
|
+
self.retry_after = retry_after
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PermisAPIQuotaExceeded(PermisAPIRateLimited):
|
|
54
|
+
"""Quota mensuel depasse : il faut upgrade ou attendre le prochain mois."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PermisAPIValidationError(PermisAPIError):
|
|
58
|
+
"""400 : un parametre n'est pas au bon format."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PermisAPINotFound(PermisAPIError):
|
|
62
|
+
"""404 : permit ou ressource inexistante."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def error_from_response(status_code: int, payload: dict[str, Any]) -> PermisAPIError:
|
|
66
|
+
"""Construit la bonne sous-classe selon le status + payload de l'API."""
|
|
67
|
+
error_code = payload.get("error")
|
|
68
|
+
message = payload.get("message") or payload.get("detail") or "Erreur API"
|
|
69
|
+
detail = payload.get("detail")
|
|
70
|
+
|
|
71
|
+
if status_code == 401:
|
|
72
|
+
return PermisAPIUnauthorized(
|
|
73
|
+
message, status_code=status_code, error_code=error_code, detail=detail
|
|
74
|
+
)
|
|
75
|
+
if status_code == 403:
|
|
76
|
+
return PermisAPIForbidden(
|
|
77
|
+
message, status_code=status_code, error_code=error_code, detail=detail
|
|
78
|
+
)
|
|
79
|
+
if status_code == 404:
|
|
80
|
+
return PermisAPINotFound(
|
|
81
|
+
message, status_code=status_code, error_code=error_code, detail=detail
|
|
82
|
+
)
|
|
83
|
+
if status_code == 429:
|
|
84
|
+
retry_after = payload.get("retry_after")
|
|
85
|
+
if error_code == "quota_exceeded":
|
|
86
|
+
return PermisAPIQuotaExceeded(
|
|
87
|
+
message,
|
|
88
|
+
retry_after=retry_after,
|
|
89
|
+
status_code=status_code,
|
|
90
|
+
error_code=error_code,
|
|
91
|
+
detail=detail,
|
|
92
|
+
)
|
|
93
|
+
return PermisAPIRateLimited(
|
|
94
|
+
message,
|
|
95
|
+
retry_after=retry_after,
|
|
96
|
+
status_code=status_code,
|
|
97
|
+
error_code=error_code,
|
|
98
|
+
detail=detail,
|
|
99
|
+
)
|
|
100
|
+
if status_code == 400:
|
|
101
|
+
return PermisAPIValidationError(
|
|
102
|
+
message, status_code=status_code, error_code=error_code, detail=detail
|
|
103
|
+
)
|
|
104
|
+
return PermisAPIError(
|
|
105
|
+
message, status_code=status_code, error_code=error_code, detail=detail
|
|
106
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: permisapi-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client Python officiel pour PermisAPI : permis de construire France en API REST.
|
|
5
|
+
Project-URL: Homepage, https://permisapi.fr
|
|
6
|
+
Project-URL: Documentation, https://permisapi.fr/docs
|
|
7
|
+
Project-URL: Changelog, https://permisapi.fr/changelog
|
|
8
|
+
Author-email: Evan Caroux <evan@permisapi.fr>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: api,construire,france,geocoding,permis,proptech,real-estate,sitadel
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Natural Language :: French
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Requires-Dist: httpx>=0.24.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
26
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# permisapi-client
|
|
30
|
+
|
|
31
|
+
Client Python officiel pour [PermisAPI](https://permisapi.fr) : accede aux 311k+ permis de construire France via une API REST moderne.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install permisapi-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from permisapi_client import PermisAPI
|
|
43
|
+
|
|
44
|
+
client = PermisAPI(api_key="pk_live_...") # obtenue sur https://permisapi.fr/#pricing
|
|
45
|
+
|
|
46
|
+
# Liste
|
|
47
|
+
data = client.permits.list(dep_code="75", limit=10)
|
|
48
|
+
for permit in data["items"]:
|
|
49
|
+
print(permit["num_pa"], permit["adr_localite_ter"])
|
|
50
|
+
|
|
51
|
+
# Recherche geographique (PostGIS)
|
|
52
|
+
nearby = client.permits.near(lat=48.85, lng=2.35, radius_m=500)
|
|
53
|
+
|
|
54
|
+
# Detail
|
|
55
|
+
permit = client.permits.get("PC 075 115 24 B0001")
|
|
56
|
+
|
|
57
|
+
# Stats par commune
|
|
58
|
+
stats = client.stats.commune("75115")
|
|
59
|
+
print(stats["total"], stats["granted"])
|
|
60
|
+
|
|
61
|
+
# Itere sur toutes les pages
|
|
62
|
+
for p in client.permits.iter_all(dep_code="75"):
|
|
63
|
+
print(p["num_pa"])
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Async
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import asyncio
|
|
70
|
+
from permisapi_client import AsyncPermisAPI
|
|
71
|
+
|
|
72
|
+
async def main():
|
|
73
|
+
async with AsyncPermisAPI(api_key="pk_live_...") as client:
|
|
74
|
+
data = await client.permits.list(dep_code="75")
|
|
75
|
+
async for p in client.permits.iter_all(dep_code="75"):
|
|
76
|
+
print(p["num_pa"])
|
|
77
|
+
|
|
78
|
+
asyncio.run(main())
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Alertes webhook
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
alert = client.alerts.create(
|
|
85
|
+
name="Paris 18e renovations",
|
|
86
|
+
center_lat=48.88,
|
|
87
|
+
center_lng=2.35,
|
|
88
|
+
radius_km=3,
|
|
89
|
+
permit_types=["PC_LOGEMENT"],
|
|
90
|
+
webhook_url="https://tonapp.fr/permisapi-hook",
|
|
91
|
+
)
|
|
92
|
+
# Le webhook_secret n'est renvoye QU'ICI. Stocke-le pour verifier les HMAC.
|
|
93
|
+
print(alert["webhook_secret"])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Gestion des erreurs
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from permisapi_client import (
|
|
100
|
+
PermisAPI,
|
|
101
|
+
PermisAPIRateLimited,
|
|
102
|
+
PermisAPIQuotaExceeded,
|
|
103
|
+
PermisAPIUnauthorized,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
client.permits.list()
|
|
108
|
+
except PermisAPIUnauthorized:
|
|
109
|
+
print("Cle API invalide")
|
|
110
|
+
except PermisAPIQuotaExceeded as e:
|
|
111
|
+
print(f"Quota mensuel atteint, retry dans {e.retry_after}s")
|
|
112
|
+
except PermisAPIRateLimited as e:
|
|
113
|
+
print(f"Burst limit, retry dans {e.retry_after}s")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Licence
|
|
117
|
+
|
|
118
|
+
MIT. Source : SDES, base Sitadel, data.gouv.fr (Licence Ouverte Etalab).
|
|
119
|
+
|
|
120
|
+
## Liens
|
|
121
|
+
|
|
122
|
+
- Documentation : https://permisapi.fr/docs
|
|
123
|
+
- Reference API complete : https://api.permisapi.fr/docs
|
|
124
|
+
- Changelog : https://permisapi.fr/changelog
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
permisapi_client/__init__.py,sha256=9pbg6c9oAVlE9foYvhjil_vt5379nASvUdb58fLVkvg,967
|
|
2
|
+
permisapi_client/client.py,sha256=oK09zY_pFdWbRheIFPhtF0pLuaCwjVWj2UpvTmgNA7U,12632
|
|
3
|
+
permisapi_client/exceptions.py,sha256=5nqRA28mwoB3gDFoWyDuaxS8R4fXCdI9j90F_QNN84s,3284
|
|
4
|
+
permisapi_client-0.1.0.dist-info/METADATA,sha256=eJ80i8aKQUnFe6ngDDzLtgUpulPhW_oI4t5QG5IwHe0,3457
|
|
5
|
+
permisapi_client-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
permisapi_client-0.1.0.dist-info/RECORD,,
|