permisapi-client 0.1.0__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.
@@ -0,0 +1,65 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .venv/
11
+ venv/
12
+ env/
13
+
14
+ # IDE
15
+ .vscode/
16
+ .idea/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Environment
21
+ .env
22
+ .env.local
23
+ .env.*.backup
24
+ .env.backup-*
25
+ .env.*.local
26
+
27
+ # Secrets inventory (file listant les valeurs sensibles a copier dans
28
+ # 1Password/Bitwarden avant de changer de machine). Ne doit JAMAIS
29
+ # remonter sur git.
30
+ SECRETS_INVENTORY.md
31
+
32
+ # Claude Code workspace (launch.json personnel)
33
+ .claude/
34
+
35
+ # Data (too big, ignored)
36
+ data/raw/*.csv
37
+ data/raw/*.zip
38
+ data/processed/*.parquet
39
+
40
+ # Database
41
+ *.sqlite
42
+ *.db
43
+
44
+ # Logs
45
+ *.log
46
+ logs/
47
+
48
+ # Tests
49
+ .pytest_cache/
50
+ .coverage
51
+ htmlcov/
52
+
53
+ # OS
54
+ .DS_Store
55
+ Thumbs.db
56
+ desktop.ini
57
+
58
+ # Node (landing page)
59
+ landing/node_modules/
60
+ landing/.next/
61
+ landing/out/
62
+
63
+ # Post-piratage rotation (contient les nouvelles cles API en clair)
64
+ secrets_NEW_KEYS.json
65
+ scrape-downloads/
@@ -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,96 @@
1
+ # permisapi-client
2
+
3
+ Client Python officiel pour [PermisAPI](https://permisapi.fr) : accede aux 311k+ permis de construire France via une API REST moderne.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install permisapi-client
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from permisapi_client import PermisAPI
15
+
16
+ client = PermisAPI(api_key="pk_live_...") # obtenue sur https://permisapi.fr/#pricing
17
+
18
+ # Liste
19
+ data = client.permits.list(dep_code="75", limit=10)
20
+ for permit in data["items"]:
21
+ print(permit["num_pa"], permit["adr_localite_ter"])
22
+
23
+ # Recherche geographique (PostGIS)
24
+ nearby = client.permits.near(lat=48.85, lng=2.35, radius_m=500)
25
+
26
+ # Detail
27
+ permit = client.permits.get("PC 075 115 24 B0001")
28
+
29
+ # Stats par commune
30
+ stats = client.stats.commune("75115")
31
+ print(stats["total"], stats["granted"])
32
+
33
+ # Itere sur toutes les pages
34
+ for p in client.permits.iter_all(dep_code="75"):
35
+ print(p["num_pa"])
36
+ ```
37
+
38
+ ## Async
39
+
40
+ ```python
41
+ import asyncio
42
+ from permisapi_client import AsyncPermisAPI
43
+
44
+ async def main():
45
+ async with AsyncPermisAPI(api_key="pk_live_...") as client:
46
+ data = await client.permits.list(dep_code="75")
47
+ async for p in client.permits.iter_all(dep_code="75"):
48
+ print(p["num_pa"])
49
+
50
+ asyncio.run(main())
51
+ ```
52
+
53
+ ## Alertes webhook
54
+
55
+ ```python
56
+ alert = client.alerts.create(
57
+ name="Paris 18e renovations",
58
+ center_lat=48.88,
59
+ center_lng=2.35,
60
+ radius_km=3,
61
+ permit_types=["PC_LOGEMENT"],
62
+ webhook_url="https://tonapp.fr/permisapi-hook",
63
+ )
64
+ # Le webhook_secret n'est renvoye QU'ICI. Stocke-le pour verifier les HMAC.
65
+ print(alert["webhook_secret"])
66
+ ```
67
+
68
+ ## Gestion des erreurs
69
+
70
+ ```python
71
+ from permisapi_client import (
72
+ PermisAPI,
73
+ PermisAPIRateLimited,
74
+ PermisAPIQuotaExceeded,
75
+ PermisAPIUnauthorized,
76
+ )
77
+
78
+ try:
79
+ client.permits.list()
80
+ except PermisAPIUnauthorized:
81
+ print("Cle API invalide")
82
+ except PermisAPIQuotaExceeded as e:
83
+ print(f"Quota mensuel atteint, retry dans {e.retry_after}s")
84
+ except PermisAPIRateLimited as e:
85
+ print(f"Burst limit, retry dans {e.retry_after}s")
86
+ ```
87
+
88
+ ## Licence
89
+
90
+ MIT. Source : SDES, base Sitadel, data.gouv.fr (Licence Ouverte Etalab).
91
+
92
+ ## Liens
93
+
94
+ - Documentation : https://permisapi.fr/docs
95
+ - Reference API complete : https://api.permisapi.fr/docs
96
+ - Changelog : https://permisapi.fr/changelog
@@ -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,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "permisapi-client"
7
+ version = "0.1.0"
8
+ description = "Client Python officiel pour PermisAPI : permis de construire France en API REST."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Evan Caroux", email = "evan@permisapi.fr" },
14
+ ]
15
+ keywords = ["permis", "construire", "france", "api", "real-estate", "sitadel", "geocoding", "proptech"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Natural Language :: French",
27
+ ]
28
+ dependencies = [
29
+ "httpx>=0.24.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.4",
35
+ "pytest-asyncio>=0.23",
36
+ "respx>=0.20",
37
+ ]
38
+
39
+ [project.urls]
40
+ Homepage = "https://permisapi.fr"
41
+ Documentation = "https://permisapi.fr/docs"
42
+ Changelog = "https://permisapi.fr/changelog"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["permisapi_client"]
@@ -0,0 +1,195 @@
1
+ """Tests du SDK via respx (mock httpx transport)."""
2
+ from __future__ import annotations
3
+
4
+ import httpx
5
+ import pytest
6
+ import respx
7
+
8
+ from permisapi_client import (
9
+ AsyncPermisAPI,
10
+ PermisAPI,
11
+ PermisAPIQuotaExceeded,
12
+ PermisAPIRateLimited,
13
+ PermisAPIUnauthorized,
14
+ PermisAPIValidationError,
15
+ )
16
+ from permisapi_client.exceptions import PermisAPINotFound
17
+
18
+
19
+ BASE = "https://api.permisapi.fr"
20
+
21
+
22
+ def test_requires_api_key() -> None:
23
+ with pytest.raises(ValueError, match="api_key"):
24
+ PermisAPI(api_key="")
25
+
26
+
27
+ @respx.mock
28
+ def test_permits_list() -> None:
29
+ route = respx.get(f"{BASE}/v1/permits").respond(
30
+ json={
31
+ "items": [{"num_pa": "PC 075 115 24 B0001", "dep_code": "75"}],
32
+ "pagination": {"page": 1, "total": 1, "pages": 1},
33
+ }
34
+ )
35
+ with PermisAPI(api_key="pk_test_abc") as client:
36
+ data = client.permits.list(dep_code="75", limit=5)
37
+ assert route.called
38
+ assert data["items"][0]["num_pa"] == "PC 075 115 24 B0001"
39
+ # Header propage
40
+ assert route.calls.last.request.headers["x-api-key"] == "pk_test_abc"
41
+
42
+
43
+ @respx.mock
44
+ def test_permits_get() -> None:
45
+ respx.get(f"{BASE}/v1/permits/PC-123").respond(
46
+ json={"num_pa": "PC-123", "dep_code": "75"}
47
+ )
48
+ with PermisAPI(api_key="pk_test_abc") as client:
49
+ permit = client.permits.get("PC-123")
50
+ assert permit["num_pa"] == "PC-123"
51
+
52
+
53
+ @respx.mock
54
+ def test_unauthorized_raises() -> None:
55
+ respx.get(f"{BASE}/v1/me").respond(
56
+ status_code=401,
57
+ json={"error": "unauthorized", "message": "API key manquante"},
58
+ )
59
+ with PermisAPI(api_key="pk_test_abc") as client:
60
+ with pytest.raises(PermisAPIUnauthorized):
61
+ client.me.get()
62
+
63
+
64
+ @respx.mock
65
+ def test_rate_limit_raises_with_retry_after() -> None:
66
+ respx.get(f"{BASE}/v1/permits").respond(
67
+ status_code=429,
68
+ json={"error": "rate_limit_exceeded", "message": "burst"},
69
+ headers={"Retry-After": "3"},
70
+ )
71
+ with PermisAPI(api_key="pk_test_abc") as client:
72
+ with pytest.raises(PermisAPIRateLimited) as exc:
73
+ client.permits.list()
74
+ assert exc.value.retry_after == 3
75
+
76
+
77
+ @respx.mock
78
+ def test_quota_exceeded_subclass() -> None:
79
+ respx.get(f"{BASE}/v1/permits").respond(
80
+ status_code=429,
81
+ json={"error": "quota_exceeded", "message": "mensuel"},
82
+ )
83
+ with PermisAPI(api_key="pk_test_abc") as client:
84
+ with pytest.raises(PermisAPIQuotaExceeded):
85
+ client.permits.list()
86
+
87
+
88
+ @respx.mock
89
+ def test_not_found() -> None:
90
+ respx.get(f"{BASE}/v1/permits/UNKNOWN").respond(
91
+ status_code=404,
92
+ json={"error": "not_found", "message": "permit introuvable"},
93
+ )
94
+ with PermisAPI(api_key="pk_test_abc") as client:
95
+ with pytest.raises(PermisAPINotFound):
96
+ client.permits.get("UNKNOWN")
97
+
98
+
99
+ @respx.mock
100
+ def test_validation_error() -> None:
101
+ respx.get(f"{BASE}/v1/permits").respond(
102
+ status_code=400,
103
+ json={"error": "validation_error", "message": "dep_code invalide"},
104
+ )
105
+ with PermisAPI(api_key="pk_test_abc") as client:
106
+ with pytest.raises(PermisAPIValidationError):
107
+ client.permits.list(dep_code="999999")
108
+
109
+
110
+ @respx.mock
111
+ def test_iter_all_paginates() -> None:
112
+ respx.get(f"{BASE}/v1/permits", params={"page": "1", "limit": "2"}).respond(
113
+ json={
114
+ "items": [{"num_pa": "a"}, {"num_pa": "b"}],
115
+ "pagination": {"page": 1, "total": 3, "pages": 2},
116
+ }
117
+ )
118
+ respx.get(f"{BASE}/v1/permits", params={"page": "2", "limit": "2"}).respond(
119
+ json={
120
+ "items": [{"num_pa": "c"}],
121
+ "pagination": {"page": 2, "total": 3, "pages": 2},
122
+ }
123
+ )
124
+ with PermisAPI(api_key="pk_test_abc") as client:
125
+ results = list(client.permits.iter_all(limit=2))
126
+ assert [p["num_pa"] for p in results] == ["a", "b", "c"]
127
+
128
+
129
+ @respx.mock
130
+ def test_alerts_create() -> None:
131
+ respx.post(f"{BASE}/v1/alerts").respond(
132
+ json={
133
+ "id": 42,
134
+ "name": "Paris 18",
135
+ "webhook_secret": "super-secret-abc",
136
+ }
137
+ )
138
+ with PermisAPI(api_key="pk_test_abc") as client:
139
+ alert = client.alerts.create(
140
+ name="Paris 18",
141
+ center_lat=48.88,
142
+ center_lng=2.35,
143
+ radius_km=3,
144
+ webhook_url="https://x/hook",
145
+ )
146
+ assert alert["webhook_secret"] == "super-secret-abc"
147
+
148
+
149
+ @respx.mock
150
+ def test_alerts_delete_204() -> None:
151
+ respx.delete(f"{BASE}/v1/alerts/42").respond(status_code=204)
152
+ with PermisAPI(api_key="pk_test_abc") as client:
153
+ client.alerts.delete(42)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Async
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ @pytest.mark.asyncio
162
+ @respx.mock
163
+ async def test_async_permits_list() -> None:
164
+ respx.get(f"{BASE}/v1/permits").respond(
165
+ json={"items": [{"num_pa": "x"}], "pagination": {"pages": 1}}
166
+ )
167
+ async with AsyncPermisAPI(api_key="pk_test_abc") as client:
168
+ data = await client.permits.list()
169
+ assert data["items"][0]["num_pa"] == "x"
170
+
171
+
172
+ @pytest.mark.asyncio
173
+ @respx.mock
174
+ async def test_async_rate_limit() -> None:
175
+ respx.get(f"{BASE}/v1/me").respond(
176
+ status_code=429,
177
+ json={"error": "rate_limit_exceeded", "message": "burst"},
178
+ )
179
+ async with AsyncPermisAPI(api_key="pk_test_abc") as client:
180
+ with pytest.raises(PermisAPIRateLimited):
181
+ await client.me.get()
182
+
183
+
184
+ def test_custom_base_url() -> None:
185
+ client = PermisAPI(api_key="pk_test_abc", base_url="http://localhost:8000/")
186
+ assert client.base_url == "http://localhost:8000"
187
+ client.close()
188
+
189
+
190
+ def test_custom_http_client() -> None:
191
+ """On peut injecter un httpx.Client pre-configure (proxy, retry, etc)."""
192
+ h = httpx.Client(base_url=BASE, headers={"X-API-Key": "pk_test_xyz"})
193
+ client = PermisAPI(api_key="pk_test_xyz", client=h)
194
+ assert client._http is h
195
+ client.close()