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.
- {permisapi_client-0.8.1 → permisapi_client-0.9.1}/PKG-INFO +30 -16
- {permisapi_client-0.8.1 → permisapi_client-0.9.1}/README.md +29 -15
- {permisapi_client-0.8.1 → permisapi_client-0.9.1}/permisapi_client/__init__.py +1 -1
- {permisapi_client-0.8.1 → permisapi_client-0.9.1}/permisapi_client/client.py +116 -67
- {permisapi_client-0.8.1 → permisapi_client-0.9.1}/permisapi_client/exceptions.py +1 -1
- {permisapi_client-0.8.1 → permisapi_client-0.9.1}/pyproject.toml +1 -1
- {permisapi_client-0.8.1 → permisapi_client-0.9.1}/tests/test_client.py +31 -0
- {permisapi_client-0.8.1 → permisapi_client-0.9.1}/.gitignore +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: permisapi-client
|
|
3
|
-
Version: 0.
|
|
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) :
|
|
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
|
|
78
|
+
formule Gratuit (500 requêtes), Découverte (2 000) ou Explorateur (5 000).
|
|
72
79
|
|
|
73
|
-
Formules : **Gratuit** (1 département,
|
|
74
|
-
6 mois), **
|
|
75
|
-
(
|
|
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
|
|
86
|
+
## Données avancées (formules Régional, Pro, Business et Entreprise)
|
|
79
87
|
|
|
80
|
-
Les formules Gratuit et
|
|
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
|
-
#
|
|
95
|
-
#
|
|
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(
|
|
106
|
-
for
|
|
107
|
-
print(
|
|
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
|
|
128
|
+
## Export CSV (Business et Entreprise)
|
|
121
129
|
|
|
122
130
|
```python
|
|
123
|
-
# Plafond : 100 000 lignes en Business, 1 000 000 en
|
|
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
|
|
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) :
|
|
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
|
|
50
|
+
formule Gratuit (500 requêtes), Découverte (2 000) ou Explorateur (5 000).
|
|
44
51
|
|
|
45
|
-
Formules : **Gratuit** (1 département,
|
|
46
|
-
6 mois), **
|
|
47
|
-
(
|
|
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
|
|
58
|
+
## Données avancées (formules Régional, Pro, Business et Entreprise)
|
|
51
59
|
|
|
52
|
-
Les formules Gratuit et
|
|
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
|
-
#
|
|
67
|
-
#
|
|
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(
|
|
78
|
-
for
|
|
79
|
-
print(
|
|
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
|
|
100
|
+
## Export CSV (Business et Entreprise)
|
|
93
101
|
|
|
94
102
|
```python
|
|
95
|
-
# Plafond : 100 000 lignes en Business, 1 000 000 en
|
|
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
|
|
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).
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
184
|
-
et
|
|
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
|
|
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
|
|
251
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
`
|
|
325
|
-
|
|
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
|
|
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),
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
483
|
+
"""GET /v1/permits/export : export CSV en masse (Business et Entreprise).
|
|
439
484
|
|
|
440
|
-
Formules Business et
|
|
441
|
-
Cap par defaut : 100k lignes Business, 1M
|
|
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,
|
|
615
|
-
|
|
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(
|
|
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
|
|
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 à
|
|
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
|
|
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
|
|
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.
|
|
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"
|
|
File without changes
|