permisapi-client 0.8.1__tar.gz → 0.9.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: permisapi-client
3
- Version: 0.8.1
3
+ Version: 0.9.1
4
4
  Summary: Client Python officiel pour PermisAPI : permis de construire de France en API REST.
5
5
  Project-URL: Homepage, https://permisapi.fr
6
6
  Project-URL: Documentation, https://permisapi.fr/docs
@@ -28,7 +28,7 @@ Description-Content-Type: text/markdown
28
28
 
29
29
  # permisapi-client
30
30
 
31
- Client Python officiel de [PermisAPI](https://permisapi.fr) : 1,3 million de permis de
31
+ Client Python officiel de [PermisAPI](https://permisapi.fr) : plus de 3 millions de permis de
32
32
  construire en France (base Sitadel, permis autorisés depuis 2013, 101 départements) dans
33
33
  une API REST.
34
34
 
@@ -57,6 +57,13 @@ nearby = client.permits.near(lat=48.8599, lng=2.3423, radius_m=300)
57
57
  # Détail d'un permis
58
58
  permit = client.permits.get("07510125V0025")
59
59
 
60
+ # Un même numéro peut porter plusieurs autorisations (un PC et un PD, par
61
+ # exemple) : la fiche liste les autres types dans autres_types, et type=
62
+ # en désigne une. permit["identifiant"] (type et numéro accolés) est la
63
+ # forme à conserver ; elle est acceptée partout à la place du numéro.
64
+ if permit.get("autres_types"):
65
+ autre = client.permits.get(permit["num_pa"], type=permit["autres_types"][0])
66
+
60
67
  # Statistiques d'une commune (Paris entier est rangé sous le code 75056 dans Sitadel)
61
68
  stats = client.stats.commune("75056")
62
69
  print(stats["total_permits"], stats["granted"], stats["trend_yoy_pct"])
@@ -68,16 +75,17 @@ for p in client.permits.iter_all(dep_code="75", permit_type="PC_LOGEMENT"):
68
75
 
69
76
  Coût : une recherche (`list`, `iter_all`, `near`, `search`) décompte 1 requête par permis
70
77
  renvoyé. `iter_all` sur un département entier peut donc épuiser le quota mensuel de la
71
- formule Gratuit (500 requêtes) ou Explorer (5 000).
78
+ formule Gratuit (500 requêtes), Découverte (2 000) ou Explorateur (5 000).
72
79
 
73
- Formules : **Gratuit** (1 département, 6 mois d'historique), **Explorer** (5 départements,
74
- 6 mois), **Régional** (5 départements, historique complet et données avancées), **Pro**
75
- (les mêmes données avancées sur la France entière), **Business** et **Enterprise**. Les
80
+ Formules : **Gratuit** (1 département, les nouveaux permis des 2 dernières publications
81
+ Sitadel), **Découverte** (1 département, 6 mois), **Explorateur** (5 départements, 12 mois),
82
+ **Régional** (5 départements, historique complet et données avancées), **Pro** (les mêmes
83
+ données avancées sur la France entière), **Business** et **Entreprise**. Les
76
84
  exemples de cette page portent sur Paris (75).
77
85
 
78
- ## Données avancées (formules Régional, Pro, Business et Enterprise)
86
+ ## Données avancées (formules Régional, Pro, Business et Entreprise)
79
87
 
80
- Les formules Gratuit et Explorer reçoivent une erreur 402 sur ces routes.
88
+ Les formules Gratuit, Découverte et Explorateur reçoivent une erreur 402 sur ces routes.
81
89
 
82
90
  ```python
83
91
  num_pa = "07510125V0025"
@@ -91,8 +99,8 @@ print("Prix médian au m² :", dvf["dvf_median_price_per_m2"])
91
99
  # Score d'opportunité marchand de biens v0.3 (note 0-100 et palier)
92
100
  score = client.permits.score_mdb(num_pa)
93
101
  print(f"Score : {score['score']}/100 ({score['tier']})")
94
- # Score déjà pré-calculé : components vaut None ; le détail des 11 signaux
95
- # est donné par la route /v1/permits/{num_pa}/score/explain.
102
+ # score["components"] : note 0-100 de chacun des 11 signaux ; une explication
103
+ # en français de chaque signal : route /v1/permits/{num_pa}/score/explain.
96
104
 
97
105
  # Zonage PLU (constructible, à urbaniser, agricole, naturel)
98
106
  plu = client.permits.plu(num_pa)
@@ -102,9 +110,9 @@ if plu["has_plu"]:
102
110
 
103
111
  # Risques Géorisques (inondation, séisme, sites Seveso proches...)
104
112
  risks = client.permits.risks(num_pa)
105
- print(f"Risque : {risks['risk_score']}/100 ({risks['risk_tier']})")
106
- for risk in risks["risks"]:
107
- print(f"- {risk['label']} (PPR : {risk['has_ppr']})")
113
+ print(risks["risk_summary"]) # niveau, et ce qu'il mesure
114
+ for raison in risks["risk_reasons"]: # d'où viennent les points
115
+ print("-", raison)
108
116
 
109
117
  # Votre avis sur un score
110
118
  client.permits.score_feedback(
@@ -117,10 +125,10 @@ client.permits.score_feedback(
117
125
  )
118
126
  ```
119
127
 
120
- ## Export CSV (Business et Enterprise)
128
+ ## Export CSV (Business et Entreprise)
121
129
 
122
130
  ```python
123
- # Plafond : 100 000 lignes en Business, 1 000 000 en Enterprise
131
+ # Plafond : 100 000 lignes en Business, 1 000 000 en Entreprise
124
132
  csv_bytes = client.permits.export(
125
133
  dep_code="75",
126
134
  permit_type="PC_LOGEMENT",
@@ -133,7 +141,7 @@ with open("paris.csv", "wb") as f:
133
141
 
134
142
  ## Serveur MCP (Claude, ChatGPT, Cursor)
135
143
 
136
- Le serveur MCP officiel (paquet séparé `permisapi-mcp`) permet à Claude, ChatGPT, Claude
144
+ Le connecteur PermisAPI (paquet séparé `permisapi-mcp`) permet à Claude, ChatGPT, Claude
137
145
  Desktop, Cursor et Windsurf d'interroger PermisAPI en langage naturel :
138
146
 
139
147
  ```bash
@@ -196,6 +204,12 @@ except PermisAPIRateLimited as e:
196
204
  print(f"Trop de requêtes par minute, nouvel essai possible dans {e.retry_after} s")
197
205
  ```
198
206
 
207
+ ## Journal des versions
208
+
209
+ - **0.9.1** (13/11/2026, filtres de zonage) : la documentation de `permits.list` dit la portée des filtres `plu_zone_type` et `plu_constructible` (le zonage est calculé pour les permis géolocalisés autorisés depuis 48 mois ; les plus anciens sont ajoutés progressivement ; un permis sans zonage enregistré est exclu). Aucun changement de comportement.
210
+ - **0.9.0** (12/11/2026, bascule de la clé type et numéro, C24) : argument facultatif `type` (`PC`, `DP`, `PA` ou `PD`) sur les méthodes qui prennent un `num_pa` ; `identifiant` (`PC0330632600176`) accepté partout à la place du numéro ; l'export CSV gagne `type_autorisation` et `identifiant` en fin de ligne. Version mineure : ajouts seulement, aucun appel existant ne change.
211
+ - Versions précédentes : historique du dépôt (`git log -- sdk-python/`).
212
+
199
213
  ## Licence
200
214
 
201
215
  MIT. Source des permis : SDES, base Sitadel, data.gouv.fr (Licence Ouverte Etalab).
@@ -1,6 +1,6 @@
1
1
  # permisapi-client
2
2
 
3
- Client Python officiel de [PermisAPI](https://permisapi.fr) : 1,3 million de permis de
3
+ Client Python officiel de [PermisAPI](https://permisapi.fr) : plus de 3 millions de permis de
4
4
  construire en France (base Sitadel, permis autorisés depuis 2013, 101 départements) dans
5
5
  une API REST.
6
6
 
@@ -29,6 +29,13 @@ nearby = client.permits.near(lat=48.8599, lng=2.3423, radius_m=300)
29
29
  # Détail d'un permis
30
30
  permit = client.permits.get("07510125V0025")
31
31
 
32
+ # Un même numéro peut porter plusieurs autorisations (un PC et un PD, par
33
+ # exemple) : la fiche liste les autres types dans autres_types, et type=
34
+ # en désigne une. permit["identifiant"] (type et numéro accolés) est la
35
+ # forme à conserver ; elle est acceptée partout à la place du numéro.
36
+ if permit.get("autres_types"):
37
+ autre = client.permits.get(permit["num_pa"], type=permit["autres_types"][0])
38
+
32
39
  # Statistiques d'une commune (Paris entier est rangé sous le code 75056 dans Sitadel)
33
40
  stats = client.stats.commune("75056")
34
41
  print(stats["total_permits"], stats["granted"], stats["trend_yoy_pct"])
@@ -40,16 +47,17 @@ for p in client.permits.iter_all(dep_code="75", permit_type="PC_LOGEMENT"):
40
47
 
41
48
  Coût : une recherche (`list`, `iter_all`, `near`, `search`) décompte 1 requête par permis
42
49
  renvoyé. `iter_all` sur un département entier peut donc épuiser le quota mensuel de la
43
- formule Gratuit (500 requêtes) ou Explorer (5 000).
50
+ formule Gratuit (500 requêtes), Découverte (2 000) ou Explorateur (5 000).
44
51
 
45
- Formules : **Gratuit** (1 département, 6 mois d'historique), **Explorer** (5 départements,
46
- 6 mois), **Régional** (5 départements, historique complet et données avancées), **Pro**
47
- (les mêmes données avancées sur la France entière), **Business** et **Enterprise**. Les
52
+ Formules : **Gratuit** (1 département, les nouveaux permis des 2 dernières publications
53
+ Sitadel), **Découverte** (1 département, 6 mois), **Explorateur** (5 départements, 12 mois),
54
+ **Régional** (5 départements, historique complet et données avancées), **Pro** (les mêmes
55
+ données avancées sur la France entière), **Business** et **Entreprise**. Les
48
56
  exemples de cette page portent sur Paris (75).
49
57
 
50
- ## Données avancées (formules Régional, Pro, Business et Enterprise)
58
+ ## Données avancées (formules Régional, Pro, Business et Entreprise)
51
59
 
52
- Les formules Gratuit et Explorer reçoivent une erreur 402 sur ces routes.
60
+ Les formules Gratuit, Découverte et Explorateur reçoivent une erreur 402 sur ces routes.
53
61
 
54
62
  ```python
55
63
  num_pa = "07510125V0025"
@@ -63,8 +71,8 @@ print("Prix médian au m² :", dvf["dvf_median_price_per_m2"])
63
71
  # Score d'opportunité marchand de biens v0.3 (note 0-100 et palier)
64
72
  score = client.permits.score_mdb(num_pa)
65
73
  print(f"Score : {score['score']}/100 ({score['tier']})")
66
- # Score déjà pré-calculé : components vaut None ; le détail des 11 signaux
67
- # est donné par la route /v1/permits/{num_pa}/score/explain.
74
+ # score["components"] : note 0-100 de chacun des 11 signaux ; une explication
75
+ # en français de chaque signal : route /v1/permits/{num_pa}/score/explain.
68
76
 
69
77
  # Zonage PLU (constructible, à urbaniser, agricole, naturel)
70
78
  plu = client.permits.plu(num_pa)
@@ -74,9 +82,9 @@ if plu["has_plu"]:
74
82
 
75
83
  # Risques Géorisques (inondation, séisme, sites Seveso proches...)
76
84
  risks = client.permits.risks(num_pa)
77
- print(f"Risque : {risks['risk_score']}/100 ({risks['risk_tier']})")
78
- for risk in risks["risks"]:
79
- print(f"- {risk['label']} (PPR : {risk['has_ppr']})")
85
+ print(risks["risk_summary"]) # niveau, et ce qu'il mesure
86
+ for raison in risks["risk_reasons"]: # d'où viennent les points
87
+ print("-", raison)
80
88
 
81
89
  # Votre avis sur un score
82
90
  client.permits.score_feedback(
@@ -89,10 +97,10 @@ client.permits.score_feedback(
89
97
  )
90
98
  ```
91
99
 
92
- ## Export CSV (Business et Enterprise)
100
+ ## Export CSV (Business et Entreprise)
93
101
 
94
102
  ```python
95
- # Plafond : 100 000 lignes en Business, 1 000 000 en Enterprise
103
+ # Plafond : 100 000 lignes en Business, 1 000 000 en Entreprise
96
104
  csv_bytes = client.permits.export(
97
105
  dep_code="75",
98
106
  permit_type="PC_LOGEMENT",
@@ -105,7 +113,7 @@ with open("paris.csv", "wb") as f:
105
113
 
106
114
  ## Serveur MCP (Claude, ChatGPT, Cursor)
107
115
 
108
- Le serveur MCP officiel (paquet séparé `permisapi-mcp`) permet à Claude, ChatGPT, Claude
116
+ Le connecteur PermisAPI (paquet séparé `permisapi-mcp`) permet à Claude, ChatGPT, Claude
109
117
  Desktop, Cursor et Windsurf d'interroger PermisAPI en langage naturel :
110
118
 
111
119
  ```bash
@@ -168,6 +176,12 @@ except PermisAPIRateLimited as e:
168
176
  print(f"Trop de requêtes par minute, nouvel essai possible dans {e.retry_after} s")
169
177
  ```
170
178
 
179
+ ## Journal des versions
180
+
181
+ - **0.9.1** (13/11/2026, filtres de zonage) : la documentation de `permits.list` dit la portée des filtres `plu_zone_type` et `plu_constructible` (le zonage est calculé pour les permis géolocalisés autorisés depuis 48 mois ; les plus anciens sont ajoutés progressivement ; un permis sans zonage enregistré est exclu). Aucun changement de comportement.
182
+ - **0.9.0** (12/11/2026, bascule de la clé type et numéro, C24) : argument facultatif `type` (`PC`, `DP`, `PA` ou `PD`) sur les méthodes qui prennent un `num_pa` ; `identifiant` (`PC0330632600176`) accepté partout à la place du numéro ; l'export CSV gagne `type_autorisation` et `identifiant` en fin de ligne. Version mineure : ajouts seulement, aucun appel existant ne change.
183
+ - Versions précédentes : historique du dépôt (`git log -- sdk-python/`).
184
+
171
185
  ## Licence
172
186
 
173
187
  MIT. Source des permis : SDES, base Sitadel, data.gouv.fr (Licence Ouverte Etalab).
@@ -31,4 +31,4 @@ __all__ = [
31
31
  "PermisAPIValidationError",
32
32
  ]
33
33
 
34
- __version__ = "0.8.1"
34
+ __version__ = "0.9.1"
@@ -13,7 +13,25 @@ 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.8.1"
16
+ USER_AGENT = "permisapi-client-python/0.9.0"
17
+
18
+
19
+ TYPES_AUTORISATION = ("PC", "DP", "PA", "PD")
20
+
21
+
22
+ def _params_type(type_: str | None) -> dict[str, str] | None:
23
+ """Paramètre facultatif `type` des méthodes à `num_pa` (API, 05/11/2026) :
24
+ un même numéro peut porter plusieurs autorisations (un PC et un PD, par
25
+ exemple) ; `type="PD"` désigne laquelle. Sans lui, l'autorisation servie
26
+ par défaut (toujours la même pour un numéro), comme avant. Un type que ce
27
+ numéro ne porte pas donne une erreur 404. Même effet : passer
28
+ l'`identifiant` (`PD0330632600176`) à la place du numéro."""
29
+ if type_ is None or type_ == "":
30
+ return None
31
+ t = str(type_).strip().upper()
32
+ if t not in TYPES_AUTORISATION:
33
+ raise ValueError("type vaut PC, DP, PA ou PD")
34
+ return {"type": t}
17
35
 
18
36
 
19
37
  def _raise_for_status(resp: httpx.Response) -> None:
@@ -117,7 +135,7 @@ class _PermitsResource:
117
135
  6 travaux terminés (1 et 3 n'existent pas dans Sitadel).
118
136
 
119
137
  Filtre `min_score` (formules Régional, Pro, Business et
120
- Enterprise ; 402 pour Gratuit et Explorer) : retourne uniquement
138
+ Entreprise ; 402 pour Gratuit, Découverte et Explorateur) : retourne uniquement
121
139
  les permis dont le Score Opportunité MDB est >= N (0-100). Cas
122
140
  d'usage marchand de biens : présélectionner les meilleures
123
141
  opportunités en un appel au lieu de filtrer côté client.
@@ -133,7 +151,18 @@ class _PermitsResource:
133
151
 
134
152
  Filtre `plu_constructible` (Régional et plus, 402 sinon) :
135
153
  booléen. `plu_constructible=True` ne retourne que les permis dont
136
- le verdict heuristique est constructible (zone U ou AU).
154
+ le verdict heuristique est constructible (zone U ou AU),
155
+ `plu_constructible=False` les permis en zone A ou N.
156
+
157
+ Portée des deux filtres de zonage (texte recopié de
158
+ src/services/portee_zonage.py du dépôt de l'API).
159
+ Le zonage est calculé pour les permis géolocalisés autorisés depuis
160
+ 48 mois ; les plus anciens sont ajoutés progressivement. Un permis
161
+ sans zonage enregistré (non géolocalisé, plus ancien et pas encore
162
+ calculé, ou situé là où aucun plan d'urbanisme n'est publié sur le
163
+ Géoportail de l'Urbanisme) est exclu, quelle que soit la valeur
164
+ demandée. Le zonage de n'importe quel permis géolocalisé reste
165
+ disponible par `permits.plu(num_pa)`.
137
166
  """
138
167
  return self._c._get("/v1/permits", params=params)
139
168
 
@@ -146,13 +175,13 @@ class _PermitsResource:
146
175
 
147
176
  Attention au quota : chaque permis retourné coûte 1 unité.
148
177
  Itérer sur un département entier peut consommer tout le quota
149
- mensuel d'une formule Gratuit (500) ou Explorer (5 000).
178
+ mensuel d'une formule Gratuit (500), Découverte (2 000) ou Explorateur (5 000).
150
179
  """
151
180
  yield from _iter_pages(self.list, params)
152
181
 
153
- def get(self, num_pa: str) -> dict[str, Any]:
182
+ def get(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
154
183
  """GET /v1/permits/{num_pa}."""
155
- return self._c._get(f"/v1/permits/{num_pa}")
184
+ return self._c._get(f"/v1/permits/{num_pa}", params=_params_type(type))
156
185
 
157
186
  def near(
158
187
  self, *, lat: float, lng: float, radius_m: int, **filters: Any
@@ -171,17 +200,17 @@ class _PermitsResource:
171
200
  def by_cadastre(self, *, section: str, numero: str) -> list[dict[str, Any]]:
172
201
  """GET /v1/permits/by-cadastre : liste nue des permis qui citent
173
202
  cette parcelle (section + numéro). Formules Régional et plus
174
- (403 pour Gratuit et Explorer)."""
203
+ (403 pour Gratuit, Découverte et Explorateur)."""
175
204
  return self._c._get(
176
205
  "/v1/permits/by-cadastre",
177
206
  params={"section": section, "numero": numero},
178
207
  )
179
208
 
180
- def parcelle(self, num_pa: str) -> dict[str, Any]:
209
+ def parcelle(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
181
210
  """GET /v1/permits/{num_pa}/parcelle : geometrie cadastre DGFiP.
182
211
 
183
- Formules Régional, Pro, Business et Enterprise (402 pour Gratuit
184
- et Explorer).
212
+ Formules Régional, Pro, Business et Entreprise (402 pour Gratuit,
213
+ Découverte et Explorateur).
185
214
  Retourne {num_pa, comm_code, section, numero, cadastre_id,
186
215
  surface_m2 (contenance officielle DGFiP), geometry (GeoJSON
187
216
  Polygon WGS84), cached_at, source}.
@@ -194,13 +223,13 @@ class _PermitsResource:
194
223
  un point lat/lng), comparer surface DGFiP vs Sitadel, cross-ref
195
224
  avec DVF en parcelle precise.
196
225
  """
197
- return self._c._get(f"/v1/permits/{num_pa}/parcelle")
226
+ return self._c._get(f"/v1/permits/{num_pa}/parcelle", params=_params_type(type))
198
227
 
199
- def batiments_existants(self, num_pa: str) -> dict[str, Any]:
228
+ def batiments_existants(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
200
229
  """GET /v1/permits/{num_pa}/batiments-existants : batiments
201
230
  cadastraux deja construits sur la parcelle du permit.
202
231
 
203
- Formules Régional et plus (402 pour Gratuit et Explorer).
232
+ Formules Régional et plus (402 pour Gratuit, Découverte et Explorateur).
204
233
  Retourne :
205
234
  - num_pa, parcelle_cadastre_id, parcelle_surface_m2
206
235
  - nb_batiments (total), nb_bati_dur (type 01), nb_bati_leger
@@ -224,7 +253,7 @@ class _PermitsResource:
224
253
  (matching spatial impossible). Source : cadastre.data.gouv.fr
225
254
  (DGFiP via Etalab).
226
255
  """
227
- return self._c._get(f"/v1/permits/{num_pa}/batiments-existants")
256
+ return self._c._get(f"/v1/permits/{num_pa}/batiments-existants", params=_params_type(type))
228
257
 
229
258
  def bulk_enrich(
230
259
  self,
@@ -247,8 +276,8 @@ class _PermitsResource:
247
276
 
248
277
  `ref` est echo dans la response pour mapper sur votre data.
249
278
 
250
- Formules Business et Enterprise uniquement (402 pour Gratuit,
251
- Explorer, Régional et Pro).
279
+ Formules Business et Entreprise uniquement (402 pour Gratuit,
280
+ Découverte, Explorateur, Régional et Pro).
252
281
 
253
282
  Args:
254
283
  rows: liste max 1000 lignes
@@ -272,6 +301,7 @@ class _PermitsResource:
272
301
  self,
273
302
  num_pa: str,
274
303
  *,
304
+ type: str | None = None,
275
305
  limit: int | None = None,
276
306
  type_local: str | None = None,
277
307
  include_terrains: bool | None = None,
@@ -281,10 +311,15 @@ class _PermitsResource:
281
311
  ) -> dict[str, Any]:
282
312
  """GET /v1/permits/{num_pa}/dvf : top-K transactions DVF voisines.
283
313
 
284
- Formules Régional et plus (402 pour Gratuit et Explorer).
314
+ Formules Régional et plus (402 pour Gratuit, Découverte et Explorateur).
285
315
  Retourne {num_pa, permit_id, matches: [...],
286
- dvf_median_price_per_m2, dvf_median_sample_size, filters_applied,
287
- source, coverage}. Ventes DVF de 2021 à 2025.
316
+ dvf_median_price_per_m2, dvf_median_sample_size, dvf_median_basis,
317
+ filters_applied, source, coverage}. Ventes DVF de 2021 à 2025.
318
+
319
+ Le prix médian au m² est le même que dans `/360` et `score_mdb()` :
320
+ il porte sur les 5 ventes voisines retenues par le score (au moins
321
+ 1 000 €, surface bâtie de 10 à 1 000 m²), quels que soient `limit`
322
+ et les filtres, qui ne changent que `matches`.
288
323
 
289
324
  Filtres optionnels :
290
325
  - limit : 1-5, default 3
@@ -307,45 +342,55 @@ class _PermitsResource:
307
342
  params["min_year"] = min_year
308
343
  if max_distance is not None:
309
344
  params["max_distance"] = max_distance
310
- return self._c._get(f"/v1/permits/{num_pa}/dvf", params=params or None)
345
+ return self._c._get(
346
+ f"/v1/permits/{num_pa}/dvf",
347
+ params={**params, **(_params_type(type) or {})} or None,
348
+ )
311
349
 
312
- def score_mdb(self, num_pa: str) -> dict[str, Any]:
350
+ def score_mdb(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
313
351
  """GET /v1/permits/{num_pa}/score : Score Opportunité MDB v0.3.
314
352
 
315
- Formules Régional et plus (402 pour Gratuit et Explorer).
353
+ Formules Régional et plus (402 pour Gratuit, Découverte et Explorateur).
316
354
  Retourne {num_pa, permit_id, score (0-100), tier (low / medium /
317
355
  high / premium), components, weights, version, method}.
318
356
 
319
- Quand le score est déjà pré-calculé (cas le plus fréquent,
320
- `method="materialise (precomputed)"`), `components` et `weights`
321
- valent `None` : le détail des 11 signaux (type, surface,
322
- dvf_value, dep_activity, demandeur, dvf_coherence, density, plu,
323
- risk, sirene, building_density) est alors donné par
324
- `GET /v1/permits/{num_pa}/score/explain`. En calcul à la demande,
325
- la réponse ajoute `dvf_matches_used` et `density_class`.
357
+ `components` donne la note 0-100 de chacun des 11 signaux (type,
358
+ surface, dvf_value, dep_activity, demandeur, dvf_coherence,
359
+ density, plu, risk, sirene, building_density) et `weights` leur
360
+ poids ; la réponse ajoute `dvf_matches_used`,
361
+ `dvf_median_price_per_m2`, `dvf_median_sample_size` et
362
+ `density_class`. Une explication en français de chaque signal est
363
+ donnée par `GET /v1/permits/{num_pa}/score/explain`.
326
364
  """
327
- return self._c._get(f"/v1/permits/{num_pa}/score")
365
+ return self._c._get(f"/v1/permits/{num_pa}/score", params=_params_type(type))
328
366
 
329
- def risks(self, num_pa: str) -> dict[str, Any]:
367
+ def risks(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
330
368
  """GET /v1/permits/{num_pa}/risks : Georisques BRGM (commune-level).
331
369
 
332
- Formules Régional et plus (402 pour Gratuit et Explorer). Source :
370
+ Formules Régional et plus (402 pour Gratuit, Découverte et Explorateur). Source :
333
371
  API Géorisques (ministère de la Transition écologique et BRGM).
334
372
 
335
373
  Retourne {num_pa, comm_code, risk_score (0-100), risk_tier (low/
336
- moderate/high/critical), risks: [{code, label, has_ppr, ppr_type}],
337
- icpe_proches: [{nom, regime, distance_m, activite}], fetch_errors,
338
- source, fetched_at, disclaimer}.
374
+ moderate/high/critical), risk_tier_label (faible, modéré, élevé,
375
+ critique), risk_summary, risk_reasons, risks: [{code, label,
376
+ has_ppr, ppr_type}], icpe_proches: [{nom, regime, distance_m,
377
+ activite}], fetch_errors, source, fetched_at, disclaimer}.
378
+
379
+ `risk_reasons` dit d'où viennent les points (un risque recensé pour
380
+ la commune, les sites Seveso à moins de 2 km) ; `has_ppr` : True si
381
+ la commune a un plan de prévention opposable sur ce risque (archive
382
+ GASPAR, `ppr_lu_le`, `ppr_type`, `ppr_nom` ; points x1,5), False
383
+ sinon, None si non vérifié.
339
384
 
340
385
  En cas d'erreur partielle (ex: ICPE OK mais /risques 500), retourne
341
386
  ce qui marche + liste les endpoints qui ont fail dans `fetch_errors`.
342
387
  """
343
- return self._c._get(f"/v1/permits/{num_pa}/risks")
388
+ return self._c._get(f"/v1/permits/{num_pa}/risks", params=_params_type(type))
344
389
 
345
- def plu(self, num_pa: str) -> dict[str, Any]:
390
+ def plu(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
346
391
  """GET /v1/permits/{num_pa}/plu : zonage urbanisme PLU au point geocode.
347
392
 
348
- Formules Régional et plus (402 pour Gratuit et Explorer). Source :
393
+ Formules Régional et plus (402 pour Gratuit, Découverte et Explorateur). Source :
349
394
  Géoportail de l'Urbanisme via apicarto.ign.fr.
350
395
 
351
396
  Retourne {num_pa, lat, lng, has_plu, zonage : {code (UA/UB/AU/A/N..),
@@ -355,9 +400,9 @@ class _PermitsResource:
355
400
  Si la commune n'a pas de PLU numérisé sur le Géoportail,
356
401
  `has_plu=False` avec `not_covered_reason` explicite.
357
402
  """
358
- return self._c._get(f"/v1/permits/{num_pa}/plu")
403
+ return self._c._get(f"/v1/permits/{num_pa}/plu", params=_params_type(type))
359
404
 
360
- def full_view(self, num_pa: str) -> dict[str, Any]:
405
+ def full_view(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
361
406
  """GET /v1/permits/{num_pa}/360 : vue agregee 6-en-1 en 1 appel HTTP.
362
407
 
363
408
  Combine en 1 reponse : detail + sirene + dvf + score + plu + risks.
@@ -366,7 +411,7 @@ class _PermitsResource:
366
411
  apicarto + Risks BRGM).
367
412
 
368
413
  Coût quota : 6 unités pour les formules Régional, Pro, Business
369
- et Enterprise ; 1 unité pour Gratuit et Explorer, qui reçoivent
414
+ et Entreprise ; 1 unité pour Gratuit, Découverte et Explorateur, qui reçoivent
370
415
  seulement `detail` (autres champs `None`, et
371
416
  `plan_required_for_full="pro"` alors que Régional suffit).
372
417
 
@@ -379,10 +424,10 @@ class _PermitsResource:
379
424
  Returns:
380
425
  dict avec cles : num_pa, detail, sirene, dvf, score, plu,
381
426
  risks, fetch_errors (list), subfetches_billed (int).
382
- Pour Gratuit et Explorer : `plan_required_for_full` est
427
+ Pour Gratuit, Découverte et Explorateur : `plan_required_for_full` est
383
428
  présent et toutes les sous-parties sauf `detail` sont None.
384
429
  """
385
- return self._c._get(f"/v1/permits/{num_pa}/360")
430
+ return self._c._get(f"/v1/permits/{num_pa}/360", params=_params_type(type))
386
431
 
387
432
  def score_feedback(
388
433
  self,
@@ -397,7 +442,7 @@ class _PermitsResource:
397
442
  ) -> dict[str, Any]:
398
443
  """POST /v1/score/feedback : envoyer un feedback sur un Score MDB.
399
444
 
400
- Formules Régional et plus (402 pour Gratuit et Explorer). Le
445
+ Formules Régional et plus (402 pour Gratuit, Découverte et Explorateur). Le
401
446
  retour est enregistré et transmis par e-mail à l'équipe.
402
447
 
403
448
  Args:
@@ -435,10 +480,10 @@ class _PermitsResource:
435
480
  etat_pa: int | None = None,
436
481
  max_rows: int | None = None,
437
482
  ) -> bytes:
438
- """GET /v1/permits/export : export CSV en masse (Business et Enterprise).
483
+ """GET /v1/permits/export : export CSV en masse (Business et Entreprise).
439
484
 
440
- Formules Business et Enterprise uniquement (403 sinon).
441
- Cap par defaut : 100k lignes Business, 1M Enterprise. Le user
485
+ Formules Business et Entreprise uniquement (403 sinon).
486
+ Cap par defaut : 100k lignes Business, 1M Entreprise. Le user
442
487
  peut reduire via `max_rows` mais pas augmenter.
443
488
 
444
489
  Retourne le contenu CSV brut en bytes UTF-8 (header + rows). Pour
@@ -611,8 +656,8 @@ class PermisAPI:
611
656
  Utilise pg_trgm cote DB. Insensible aux accents et a la casse,
612
657
  tolerant aux typos courants. Latence ~50-200ms.
613
658
 
614
- Toutes formules (Gratuit, Explorer, Régional, Pro, Business,
615
- Enterprise), dans le périmètre géographique de la formule. Coût
659
+ Toutes formules (Gratuit, Découverte, Explorateur, Régional, Pro,
660
+ Business, Entreprise), dans le périmètre géographique de la formule. Coût
616
661
  quota : 1 unité par résultat retourné (minimum 1).
617
662
 
618
663
  Args:
@@ -694,8 +739,8 @@ class _AsyncPermitsResource:
694
739
  return
695
740
  page += 1
696
741
 
697
- async def get(self, num_pa: str) -> dict[str, Any]:
698
- return await self._c._get(f"/v1/permits/{num_pa}")
742
+ async def get(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
743
+ return await self._c._get(f"/v1/permits/{num_pa}", params=_params_type(type))
699
744
 
700
745
  async def near(
701
746
  self, *, lat: float, lng: float, radius_m: int, **filters: Any
@@ -710,6 +755,7 @@ class _AsyncPermitsResource:
710
755
  self,
711
756
  num_pa: str,
712
757
  *,
758
+ type: str | None = None,
713
759
  limit: int | None = None,
714
760
  type_local: str | None = None,
715
761
  include_terrains: bool | None = None,
@@ -732,33 +778,36 @@ class _AsyncPermitsResource:
732
778
  params["min_year"] = min_year
733
779
  if max_distance is not None:
734
780
  params["max_distance"] = max_distance
735
- return await self._c._get(f"/v1/permits/{num_pa}/dvf", params=params or None)
781
+ return await self._c._get(
782
+ f"/v1/permits/{num_pa}/dvf",
783
+ params={**params, **(_params_type(type) or {})} or None,
784
+ )
736
785
 
737
- async def score_mdb(self, num_pa: str) -> dict[str, Any]:
786
+ async def score_mdb(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
738
787
  """GET /v1/permits/{num_pa}/score : version async. Régional et plus."""
739
- return await self._c._get(f"/v1/permits/{num_pa}/score")
788
+ return await self._c._get(f"/v1/permits/{num_pa}/score", params=_params_type(type))
740
789
 
741
- async def plu(self, num_pa: str) -> dict[str, Any]:
790
+ async def plu(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
742
791
  """GET /v1/permits/{num_pa}/plu : version async. Régional et plus.
743
792
  Voir _PermitsResource.plu pour la doc complete."""
744
- return await self._c._get(f"/v1/permits/{num_pa}/plu")
793
+ return await self._c._get(f"/v1/permits/{num_pa}/plu", params=_params_type(type))
745
794
 
746
- async def risks(self, num_pa: str) -> dict[str, Any]:
795
+ async def risks(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
747
796
  """GET /v1/permits/{num_pa}/risks : version async. Régional et plus.
748
797
  Voir _PermitsResource.risks pour la doc complete."""
749
- return await self._c._get(f"/v1/permits/{num_pa}/risks")
798
+ return await self._c._get(f"/v1/permits/{num_pa}/risks", params=_params_type(type))
750
799
 
751
- async def parcelle(self, num_pa: str) -> dict[str, Any]:
800
+ async def parcelle(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
752
801
  """GET /v1/permits/{num_pa}/parcelle : version async. Régional et plus.
753
802
  Voir _PermitsResource.parcelle pour la doc complete."""
754
- return await self._c._get(f"/v1/permits/{num_pa}/parcelle")
803
+ return await self._c._get(f"/v1/permits/{num_pa}/parcelle", params=_params_type(type))
755
804
 
756
- async def batiments_existants(self, num_pa: str) -> dict[str, Any]:
805
+ async def batiments_existants(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
757
806
  """GET /v1/permits/{num_pa}/batiments-existants : version async.
758
807
  Régional et plus. Voir _PermitsResource.batiments_existants pour la
759
808
  doc complete. Use case marchand de biens : terrain nu vs deja bati."""
760
809
  return await self._c._get(
761
- f"/v1/permits/{num_pa}/batiments-existants"
810
+ f"/v1/permits/{num_pa}/batiments-existants", params=_params_type(type)
762
811
  )
763
812
 
764
813
  async def bulk_enrich(
@@ -768,7 +817,7 @@ class _AsyncPermitsResource:
768
817
  radius_m: int = 500,
769
818
  max_matches: int = 3,
770
819
  ) -> dict[str, Any]:
771
- """POST /v1/permits/bulk-enrich : version async. Business et Enterprise.
820
+ """POST /v1/permits/bulk-enrich : version async. Business et Entreprise.
772
821
  Voir _PermitsResource.bulk_enrich pour la doc complete."""
773
822
  return await self._c._post(
774
823
  "/v1/permits/bulk-enrich",
@@ -779,14 +828,14 @@ class _AsyncPermitsResource:
779
828
  },
780
829
  )
781
830
 
782
- async def full_view(self, num_pa: str) -> dict[str, Any]:
831
+ async def full_view(self, num_pa: str, type: str | None = None) -> dict[str, Any]:
783
832
  """GET /v1/permits/{num_pa}/360 : version async. Vue agregee 6-en-1.
784
833
 
785
- Coût quota : 6 unités de Régional à Enterprise, 1 unité pour Gratuit et Explorer.
834
+ Coût quota : 6 unités de Régional à Entreprise, 1 unité pour Gratuit, Découverte et Explorateur.
786
835
  Le header `X-RateLimit-Cost` confirme le cout. Voir
787
836
  _PermitsResource.full_view pour la doc complete.
788
837
  """
789
- return await self._c._get(f"/v1/permits/{num_pa}/360")
838
+ return await self._c._get(f"/v1/permits/{num_pa}/360", params=_params_type(type))
790
839
 
791
840
  async def score_feedback(
792
841
  self,
@@ -824,7 +873,7 @@ class _AsyncPermitsResource:
824
873
  etat_pa: int | None = None,
825
874
  max_rows: int | None = None,
826
875
  ) -> bytes:
827
- """GET /v1/permits/export : version async. Business et Enterprise.
876
+ """GET /v1/permits/export : version async. Business et Entreprise.
828
877
  Voir _PermitsResource.export pour la doc complete des params."""
829
878
  params: dict[str, Any] = {}
830
879
  if dep_code is not None:
@@ -27,7 +27,7 @@ class PermisAPIUnauthorized(PermisAPIError):
27
27
 
28
28
  class PermisAPIForbidden(PermisAPIError):
29
29
  """403 : accès refusé (compte désactivé, département ou historique
30
- hors de votre formule, export réservé à Business et Enterprise)."""
30
+ hors de votre formule, export réservé à Business et Entreprise)."""
31
31
 
32
32
 
33
33
  class PermisAPIRateLimited(PermisAPIError):
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "permisapi-client"
7
- version = "0.8.1"
7
+ version = "0.9.1"
8
8
  description = "Client Python officiel pour PermisAPI : permis de construire de France en API REST."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -520,3 +520,34 @@ def test_custom_http_client() -> None:
520
520
  client = PermisAPI(api_key="pk_test_xyz", client=h)
521
521
  assert client._http is h
522
522
  client.close()
523
+
524
+
525
+ # Argument facultatif `type` (05/11/2026) : un même numéro peut porter
526
+ # plusieurs autorisations (un PC et un PD, par exemple).
527
+ @respx.mock
528
+ def test_type_passe_en_parametre() -> None:
529
+ route = respx.get(f"{BASE}/v1/permits/0330632600176").respond(json={"num_pa": "0330632600176"})
530
+ dvf = respx.get(f"{BASE}/v1/permits/0330632600176/dvf").respond(json={"matches": []})
531
+ with PermisAPI(api_key="pk_test_abc") as client:
532
+ client.permits.get("0330632600176", type="pd")
533
+ client.permits.dvf("0330632600176", type="PD", limit=2)
534
+ client.permits.get("0330632600176")
535
+ assert route.calls[0].request.url.params["type"] == "PD"
536
+ assert "type" not in route.calls[1].request.url.params
537
+ assert dvf.calls.last.request.url.params["type"] == "PD"
538
+ assert dvf.calls.last.request.url.params["limit"] == "2"
539
+
540
+
541
+ def test_type_inconnu_refuse() -> None:
542
+ with PermisAPI(api_key="pk_test_abc") as client:
543
+ with pytest.raises(ValueError):
544
+ client.permits.get("0330632600176", type="CU")
545
+
546
+
547
+ @respx.mock
548
+ @pytest.mark.asyncio
549
+ async def test_type_async() -> None:
550
+ route = respx.get(f"{BASE}/v1/permits/0330632600176/360").respond(json={"num_pa": "0330632600176"})
551
+ async with AsyncPermisAPI(api_key="pk_test_abc") as client:
552
+ await client.permits.full_view("0330632600176", type="PA")
553
+ assert route.calls.last.request.url.params["type"] == "PA"