permisapi-client 0.4.0__tar.gz → 0.7.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.
@@ -63,3 +63,6 @@ landing/out/
63
63
  # Post-piratage rotation (contient les nouvelles cles API en clair)
64
64
  secrets_NEW_KEYS.json
65
65
  scrape-downloads/
66
+
67
+ # Local handoff bundle (assembled then zipped, not committed)
68
+ handoff-bundle/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: permisapi-client
3
- Version: 0.4.0
3
+ Version: 0.7.0
4
4
  Summary: Client Python officiel pour PermisAPI : permis de construire France en API REST.
5
5
  Project-URL: Homepage, https://permisapi.fr
6
6
  Project-URL: Documentation, https://permisapi.fr/docs
@@ -63,6 +63,70 @@ for p in client.permits.iter_all(dep_code="75"):
63
63
  print(p["num_pa"])
64
64
  ```
65
65
 
66
+ ## Cross-ref Pro+ (DVF, Score MDB, PLU, Géorisques)
67
+
68
+ ```python
69
+ # Top 5 transactions DVF voisines (5 ans glissants)
70
+ dvf = client.permits.dvf("PC07404021K1", limit=5)
71
+ for m in dvf["matches"]:
72
+ print(m["valeur_fonciere"], "EUR a", m["distance_m"], "m")
73
+
74
+ # Score Opportunite Marchand de Biens v0.1 (note 0-100 + tier)
75
+ score = client.permits.score_mdb("PC07404021K1")
76
+ print(f"Score: {score['score']}/100 ({score['tier']})")
77
+
78
+ # Zonage urbanisme PLU (constructible vs agricole vs naturelle)
79
+ plu = client.permits.plu("PC07404021K1")
80
+ if plu["has_plu"]:
81
+ z = plu["zonage"]
82
+ print(f"Zone {z['code']} - constructible: {z['constructible']}")
83
+
84
+ # Georisques BRGM (inondation, seisme, argile, ICPE proches)
85
+ risks = client.permits.risks("PC07404021K1")
86
+ print(f"Risk score: {risks['risk_score']}/100 ({risks['risk_tier']})")
87
+ for risk in risks["risks"]:
88
+ print(f"- {risk['label']} (PPR: {risk['has_ppr']})")
89
+
90
+ # Feedback sur le Score MDB (alimente le training V1.0 ML)
91
+ client.permits.score_feedback(
92
+ num_pa="PC07404021K1",
93
+ score_received=78,
94
+ tier_received="high",
95
+ useful=False,
96
+ accuracy="too_high",
97
+ comment="SCI familiale, pas un MDB. Le signal SCI=90 est trop generique.",
98
+ recommendation="Distinguer SCI familiale via volume de permis.",
99
+ )
100
+ ```
101
+
102
+ ## Export bulk CSV (Business+)
103
+
104
+ ```python
105
+ # Cap par defaut : 100k Business / 1M Enterprise
106
+ csv_bytes = client.permits.export(
107
+ dep_code="33",
108
+ permit_type="PC_LOGEMENT",
109
+ date_from="2024-01-01",
110
+ max_rows=5000,
111
+ )
112
+ with open("bordeaux.csv", "wb") as f:
113
+ f.write(csv_bytes)
114
+ ```
115
+
116
+ ## MCP server (Claude / ChatGPT / Cursor)
117
+
118
+ Le serveur MCP officiel (package separe `permisapi-mcp`) permet a Claude
119
+ Desktop, Cursor et Windsurf d'appeler PermisAPI en langage naturel :
120
+
121
+ ```bash
122
+ pip install permisapi-mcp
123
+ ```
124
+
125
+ Configure ta cle dans le client MCP, puis demande directement :
126
+ > *"Liste les permis de logement deposes a Bordeaux ce mois avec un score MDB > 70"*
127
+
128
+ Voir [permisapi.fr/mcp](https://permisapi.fr/mcp) pour le setup complet.
129
+
66
130
  ## Async
67
131
 
68
132
  ```python
@@ -35,6 +35,70 @@ for p in client.permits.iter_all(dep_code="75"):
35
35
  print(p["num_pa"])
36
36
  ```
37
37
 
38
+ ## Cross-ref Pro+ (DVF, Score MDB, PLU, Géorisques)
39
+
40
+ ```python
41
+ # Top 5 transactions DVF voisines (5 ans glissants)
42
+ dvf = client.permits.dvf("PC07404021K1", limit=5)
43
+ for m in dvf["matches"]:
44
+ print(m["valeur_fonciere"], "EUR a", m["distance_m"], "m")
45
+
46
+ # Score Opportunite Marchand de Biens v0.1 (note 0-100 + tier)
47
+ score = client.permits.score_mdb("PC07404021K1")
48
+ print(f"Score: {score['score']}/100 ({score['tier']})")
49
+
50
+ # Zonage urbanisme PLU (constructible vs agricole vs naturelle)
51
+ plu = client.permits.plu("PC07404021K1")
52
+ if plu["has_plu"]:
53
+ z = plu["zonage"]
54
+ print(f"Zone {z['code']} - constructible: {z['constructible']}")
55
+
56
+ # Georisques BRGM (inondation, seisme, argile, ICPE proches)
57
+ risks = client.permits.risks("PC07404021K1")
58
+ print(f"Risk score: {risks['risk_score']}/100 ({risks['risk_tier']})")
59
+ for risk in risks["risks"]:
60
+ print(f"- {risk['label']} (PPR: {risk['has_ppr']})")
61
+
62
+ # Feedback sur le Score MDB (alimente le training V1.0 ML)
63
+ client.permits.score_feedback(
64
+ num_pa="PC07404021K1",
65
+ score_received=78,
66
+ tier_received="high",
67
+ useful=False,
68
+ accuracy="too_high",
69
+ comment="SCI familiale, pas un MDB. Le signal SCI=90 est trop generique.",
70
+ recommendation="Distinguer SCI familiale via volume de permis.",
71
+ )
72
+ ```
73
+
74
+ ## Export bulk CSV (Business+)
75
+
76
+ ```python
77
+ # Cap par defaut : 100k Business / 1M Enterprise
78
+ csv_bytes = client.permits.export(
79
+ dep_code="33",
80
+ permit_type="PC_LOGEMENT",
81
+ date_from="2024-01-01",
82
+ max_rows=5000,
83
+ )
84
+ with open("bordeaux.csv", "wb") as f:
85
+ f.write(csv_bytes)
86
+ ```
87
+
88
+ ## MCP server (Claude / ChatGPT / Cursor)
89
+
90
+ Le serveur MCP officiel (package separe `permisapi-mcp`) permet a Claude
91
+ Desktop, Cursor et Windsurf d'appeler PermisAPI en langage naturel :
92
+
93
+ ```bash
94
+ pip install permisapi-mcp
95
+ ```
96
+
97
+ Configure ta cle dans le client MCP, puis demande directement :
98
+ > *"Liste les permis de logement deposes a Bordeaux ce mois avec un score MDB > 70"*
99
+
100
+ Voir [permisapi.fr/mcp](https://permisapi.fr/mcp) pour le setup complet.
101
+
38
102
  ## Async
39
103
 
40
104
  ```python
@@ -30,4 +30,4 @@ __all__ = [
30
30
  "PermisAPIValidationError",
31
31
  ]
32
32
 
33
- __version__ = "0.1.0"
33
+ __version__ = "0.7.0"
@@ -13,7 +13,7 @@ from permisapi_client.exceptions import error_from_response
13
13
 
14
14
  DEFAULT_BASE_URL = "https://api.permisapi.fr"
15
15
  DEFAULT_TIMEOUT = 15.0
16
- USER_AGENT = "permisapi-client-python/0.1.0"
16
+ USER_AGENT = "permisapi-client-python/0.7.0"
17
17
 
18
18
 
19
19
  def _raise_for_status(resp: httpx.Response) -> None:
@@ -138,12 +138,112 @@ class _PermitsResource:
138
138
  high/premium), components (7 signaux ponderes), weights,
139
139
  version, method, dvf_matches_used, density_class}.
140
140
 
141
- Algo : heuristique ponderee qui combine type permit, surface,
141
+ Algo : heuristique ponderee qui combine type permis, surface,
142
142
  DVF voisin €/m², activite dep, demandeur (SCI vs promoteur),
143
- coherence DVF/permit, densite INSEE.
143
+ coherence DVF/permis, densite INSEE.
144
144
  """
145
145
  return self._c._get(f"/v1/permits/{num_pa}/score")
146
146
 
147
+ def risks(self, num_pa: str) -> dict[str, Any]:
148
+ """GET /v1/permits/{num_pa}/risks : Georisques BRGM (commune-level).
149
+
150
+ Plans Pro et superieurs uniquement (402 si Free / Explorer). Source :
151
+ API Georisques (ministere Transition Ecologique + BRGM) live, pas de
152
+ cache V0.1, latence 200-1000ms typique.
153
+
154
+ Retourne {num_pa, comm_code, risk_score (0-100), risk_tier (low/
155
+ moderate/high/critical), risks: [{code, label, has_ppr, ppr_type}],
156
+ icpe_proches: [{nom, regime, distance_m, activite}], fetch_errors,
157
+ source, fetched_at, disclaimer}.
158
+
159
+ En cas d'erreur partielle (ex: ICPE OK mais /risques 500), retourne
160
+ ce qui marche + liste les endpoints qui ont fail dans `fetch_errors`.
161
+ """
162
+ return self._c._get(f"/v1/permits/{num_pa}/risks")
163
+
164
+ def plu(self, num_pa: str) -> dict[str, Any]:
165
+ """GET /v1/permits/{num_pa}/plu : zonage urbanisme PLU au point geocode.
166
+
167
+ Plans Pro et superieurs uniquement (402 si Free / Explorer). Source :
168
+ Geoportail de l'Urbanisme via apicarto.ign.fr (live API, latence
169
+ 200-500ms typique).
170
+
171
+ Retourne {num_pa, lat, lng, has_plu, zonage : {code (UA/UB/AU/A/N..),
172
+ libelle, type_zone, constructible (bool), constructible_reason,
173
+ plu_revision_date, plu_partition}, other_zones, source, fetched_at}.
174
+
175
+ Si la commune n'a pas digitalise son PLU sur Geoportail (~20% des
176
+ communes FR), `has_plu=False` avec `not_covered_reason` explicite.
177
+ """
178
+ return self._c._get(f"/v1/permits/{num_pa}/plu")
179
+
180
+ def full_view(self, num_pa: str) -> dict[str, Any]:
181
+ """GET /v1/permits/{num_pa}/360 : vue agregee 6-en-1 en 1 appel HTTP.
182
+
183
+ Combine en 1 reponse : detail + sirene + dvf + score + plu + risks.
184
+ Equivalent fonctionnel de 6 calls separes mais en 1 round-trip,
185
+ avec parallelisation cote serveur des sub-fetches lents (PLU
186
+ apicarto + Risks BRGM).
187
+
188
+ Cout quota : 6 unites du quota mensuel pour Pro+, 1 unite pour
189
+ Free / Explorer (qui recoivent juste `detail`, autres champs `null`
190
+ avec `plan_required_for_full="pro"`). Le header de reponse
191
+ `X-RateLimit-Cost` confirme le cout decompte.
192
+
193
+ Best-effort : si une sous-feature echoue (timeout, 5xx tiers),
194
+ son champ vaut `null` et l'erreur est listee dans `fetch_errors`.
195
+ Les autres sous-features ne sont pas affectees.
196
+
197
+ Latence typique 5-7s (dominee par PLU + Risks live, paralleles).
198
+
199
+ Returns:
200
+ dict avec cles : num_pa, detail, sirene, dvf, score, plu,
201
+ risks, fetch_errors (list), subfetches_billed (int).
202
+ Si plan Free/Explorer : `plan_required_for_full="pro"` est
203
+ present et toutes les sous-features sauf detail sont null.
204
+ """
205
+ return self._c._get(f"/v1/permits/{num_pa}/360")
206
+
207
+ def score_feedback(
208
+ self,
209
+ *,
210
+ num_pa: str,
211
+ score_received: int,
212
+ tier_received: str,
213
+ useful: bool,
214
+ accuracy: str,
215
+ comment: str,
216
+ recommendation: str | None = None,
217
+ ) -> dict[str, Any]:
218
+ """POST /v1/score/feedback : envoyer un feedback sur un Score MDB.
219
+
220
+ Plans Pro et superieurs uniquement. Le feedback est stocke en DB
221
+ et un email est envoye au founder pour suivi rapide.
222
+
223
+ Args:
224
+ num_pa: identifiant du permis concerne
225
+ score_received: score 0-100 que tu as recu via /score
226
+ tier_received: "low" | "medium" | "high" | "premium"
227
+ useful: True si le score t'a ete utile
228
+ accuracy: "correct" | "too_high" | "too_low" | "wrong_signals"
229
+ comment: ton retour libre (1-2000 chars)
230
+ recommendation: optionnel, ce qui pourrait etre ameliore (1-2000 chars)
231
+
232
+ Returns:
233
+ {id, num_pa, email_sent, message}
234
+ """
235
+ body: dict[str, Any] = {
236
+ "num_pa": num_pa,
237
+ "score_received": score_received,
238
+ "tier_received": tier_received,
239
+ "useful": useful,
240
+ "accuracy": accuracy,
241
+ "comment": comment,
242
+ }
243
+ if recommendation is not None:
244
+ body["recommendation"] = recommendation
245
+ return self._c._post("/v1/score/feedback", json=body)
246
+
147
247
  def export(
148
248
  self,
149
249
  *,
@@ -423,6 +523,50 @@ class _AsyncPermitsResource:
423
523
  """GET /v1/permits/{num_pa}/score : version async. Plans Pro+ only."""
424
524
  return await self._c._get(f"/v1/permits/{num_pa}/score")
425
525
 
526
+ async def plu(self, num_pa: str) -> dict[str, Any]:
527
+ """GET /v1/permits/{num_pa}/plu : version async. Plans Pro+ only.
528
+ Voir _PermitsResource.plu pour la doc complete."""
529
+ return await self._c._get(f"/v1/permits/{num_pa}/plu")
530
+
531
+ async def risks(self, num_pa: str) -> dict[str, Any]:
532
+ """GET /v1/permits/{num_pa}/risks : version async. Plans Pro+ only.
533
+ Voir _PermitsResource.risks pour la doc complete."""
534
+ return await self._c._get(f"/v1/permits/{num_pa}/risks")
535
+
536
+ async def full_view(self, num_pa: str) -> dict[str, Any]:
537
+ """GET /v1/permits/{num_pa}/360 : version async. Vue agregee 6-en-1.
538
+
539
+ Cout quota : 6 unites pour Pro+, 1 unite pour Free / Explorer.
540
+ Le header `X-RateLimit-Cost` confirme le cout. Voir
541
+ _PermitsResource.full_view pour la doc complete.
542
+ """
543
+ return await self._c._get(f"/v1/permits/{num_pa}/360")
544
+
545
+ async def score_feedback(
546
+ self,
547
+ *,
548
+ num_pa: str,
549
+ score_received: int,
550
+ tier_received: str,
551
+ useful: bool,
552
+ accuracy: str,
553
+ comment: str,
554
+ recommendation: str | None = None,
555
+ ) -> dict[str, Any]:
556
+ """POST /v1/score/feedback : version async. Plans Pro+ only.
557
+ Voir _PermitsResource.score_feedback pour la doc complete."""
558
+ body: dict[str, Any] = {
559
+ "num_pa": num_pa,
560
+ "score_received": score_received,
561
+ "tier_received": tier_received,
562
+ "useful": useful,
563
+ "accuracy": accuracy,
564
+ "comment": comment,
565
+ }
566
+ if recommendation is not None:
567
+ body["recommendation"] = recommendation
568
+ return await self._c._post("/v1/score/feedback", json=body)
569
+
426
570
  async def export(
427
571
  self,
428
572
  *,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "permisapi-client"
7
- version = "0.4.0"
7
+ version = "0.7.0"
8
8
  description = "Client Python officiel pour PermisAPI : permis de construire France en API REST."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -337,6 +337,56 @@ async def test_async_permits_export() -> None:
337
337
  assert b"PC-A,75" in result
338
338
 
339
339
 
340
+ @respx.mock
341
+ def test_permits_full_view_sync() -> None:
342
+ """Sync : full_view appelle GET /v1/permits/{num_pa}/360 et retourne
343
+ le payload agrege complet."""
344
+ respx.get(f"{BASE}/v1/permits/PC-360-001/360").respond(
345
+ json={
346
+ "num_pa": "PC-360-001",
347
+ "detail": {"num_pa": "PC-360-001", "dep_code": "75"},
348
+ "sirene": {"match": {"siren": "111222333"}},
349
+ "dvf": {"matches": []},
350
+ "score": {"score": 72, "tier": "high"},
351
+ "plu": {"zonage": {"code": "UA", "constructible": True}},
352
+ "risks": {"risk_tier": "low"},
353
+ "fetch_errors": [],
354
+ "subfetches_billed": 6,
355
+ }
356
+ )
357
+ with PermisAPI(api_key="pk_test_abc") as client:
358
+ view = client.permits.full_view("PC-360-001")
359
+ assert view["num_pa"] == "PC-360-001"
360
+ assert view["subfetches_billed"] == 6
361
+ assert view["score"]["tier"] == "high"
362
+ assert view["plu"]["zonage"]["constructible"] is True
363
+
364
+
365
+ @pytest.mark.asyncio
366
+ @respx.mock
367
+ async def test_async_permits_full_view() -> None:
368
+ """Async : full_view appelle GET /v1/permits/{num_pa}/360."""
369
+ respx.get(f"{BASE}/v1/permits/PC-FREE/360").respond(
370
+ json={
371
+ "num_pa": "PC-FREE",
372
+ "detail": {"num_pa": "PC-FREE"},
373
+ "sirene": None,
374
+ "dvf": None,
375
+ "score": None,
376
+ "plu": None,
377
+ "risks": None,
378
+ "fetch_errors": [],
379
+ "plan_required_for_full": "pro",
380
+ "subfetches_billed": 1,
381
+ }
382
+ )
383
+ async with AsyncPermisAPI(api_key="pk_test_abc") as client:
384
+ view = await client.permits.full_view("PC-FREE")
385
+ assert view["plan_required_for_full"] == "pro"
386
+ assert view["subfetches_billed"] == 1
387
+ assert view["sirene"] is None
388
+
389
+
340
390
  def test_custom_base_url() -> None:
341
391
  client = PermisAPI(api_key="pk_test_abc", base_url="http://localhost:8000/")
342
392
  assert client.base_url == "http://localhost:8000"