Repository navigation
Expand file tree
/
Copy pathPyOsm.py
More file actions
451 lines (331 loc) · 18.2 KB
/
Copy pathPyOsm.py
File metadata and controls
451 lines (331 loc) · 18.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
# SPDX-License-Identifier: MIT
# Copyright (C) 2024 picasso2005 <clementduran0@gmail.com> - All Rights Reserved
import json
import sys
from typing import Tuple, Iterable, Optional, Literal
from urllib.parse import quote
import aiohttp
from .Capabilities import OSMCapabilities
from .Enums import OSMOrder
from .Enums import OSMSort
from .Enums import OSMStatus
from .Objects import OSMBoundingBox
from .Objects import OSMChangeset
from .Objects import OSMNote
from .Objects import OSMTimeDelta
from .Objects import OSMUser
"""
HOW TO FIND YOUR USER ID
Log in into your OSM account and you will be able to get it on https://api.openstreetmap.org/api/0.6/user/details.json
It should be under {'user': {'id': YOUR_USER_ID}}
"""
class PyOSM:
def __init__(self):
self.capabilities: OSMCapabilities = OSMCapabilities()
async def update_capabilities(self) -> bool:
"""
Updates api rates dictionary
:return: True if it managed to update api rates
"""
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.openstreetmap.org/api/0.6/capabilities.json") as resp:
if resp.status == 200:
data = json.loads(await resp.text())
self.capabilities.update_from_api(data)
else:
sys.stderr.write(f"WARNING: Couldn't fetch OSM API rates: {resp.status} {await resp.text()}\n")
return False
@staticmethod
async def get_uid_with_changeset(display_name: str) -> int:
"""
Following method is a hack used to get a user UID from one of their changeset
This doesn't work if someone never made any changeset
:param display_name: The display name of the user you want to fetch
:return: If UID is found, return its value; else it will return -1
"""
async with aiohttp.ClientSession() as session:
async with session.get(
f"https://api.openstreetmap.org/api/0.6/changesets.json?limit=1&display_name={quote(display_name)}"
) as resp:
if resp.status == 200:
data = json.loads(await resp.text())
if len(data['changesets']) == 0:
return -1
return data['changesets'][0]["uid"]
else:
sys.stderr.write(f"WARNING: Couldn't fetch OSM UID from display_name: {resp.status} "
f"{await resp.text()}\n")
return -1
@staticmethod
async def fetch_user_info(uid: int) -> OSMUser | None:
"""
Fetch a user using its UID
:param uid: The user's UID
:return: Inititalized OSMUser if successfull, else None
"""
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.openstreetmap.org/api/0.6/user/{uid}.json") as resp:
if resp.status == 200:
data = json.loads(await resp.text())
return OSMUser(data["user"])
else:
sys.stderr.write(f"WARNING: Couldn't fetch user informations: {resp.status} {await resp.text()}\n")
return None
@staticmethod
async def fetch_users_info(uid: Iterable[int]) -> Tuple[OSMUser, ...] | None:
"""
Fetch multiple user using their UID
:param uid: Every UID we want to get information on
:return: Inititalized OSMUsers in a tuple if successfull, else None
"""
uid_str = ','.join([str(i) for i in uid])
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.openstreetmap.org/api/0.6/users.json?users={uid_str}") as resp:
if resp.status == 200:
data = json.loads(await resp.text())
return tuple([OSMUser(i["user"]) for i in data["users"]])
else:
sys.stderr.write(f"WARNING: Couldn't fetch user informations: {resp.status} {await resp.text()}\n")
return None
async def fetch_notes_by_bbox(self, bbox: OSMBoundingBox, limit: int = 100, closed: int = 7) -> Tuple[OSMNote, ...]:
"""
Fetch notes located in a defined bounding box
:param bbox: Coordinates for the area to retrieve the notes from. Must not be overlaping the date line
:param limit: Number of entries returned at max
:param closed: Number of days a note needs to be closed to be excluded (0 means only open notes are returned, negative means all notes)
:return: A tuple of OSM Notes
:except ValueError: Raises this exception if the parameters are invalid (e.g. bounding box crosses date line, is too big or if limit is too high)
"""
# ===== Parameters checks ===== #
if not bbox.check_data():
raise ValueError("Bounding box invalid: for more information, check Documentation/OSMBoundingBox.md")
if bbox.cross_date_line():
raise ValueError("The bounding box crosses date line")
if bbox.get_area() > self.capabilities.notes.area:
raise ValueError(f"Bounding box is too big: must be under {self.capabilities.notes.area} square degrees")
if limit > self.capabilities.notes.maximum_query_limit:
raise ValueError(f"Limit is too big: must be under {self.capabilities.notes.maximum_query_limit}")
# ========== #
async with aiohttp.ClientSession() as session:
async with session.get(
f"https://api.openstreetmap.org/api/0.6/notes.json?bbox={bbox}&limit={limit}&closed={closed}"
) as resp:
if resp.status == 200:
data = json.loads(await resp.text())
if len(data['features']) == 0:
return ()
return tuple([OSMNote(i) for i in data['features']])
else:
sys.stderr.write(f"WARNING: Couldn't fetch OSM notes: {resp.status} {await resp.text()}\n")
return ()
@staticmethod
async def fetch_note_by_id(note_id: int) -> OSMNote | None:
"""
Fetch a note by its internal id
:param note_id: The note we want to fetch
:return: The note object or None if something went wrong
"""
async with aiohttp.ClientSession() as session:
async with session.get(f"https://api.openstreetmap.org/api/0.6/notes/{note_id}.json") as resp:
if resp.status == 200:
data = json.loads(await resp.text())
return OSMNote(data)
else:
sys.stderr.write(f"WARNING: Couldn't fetch OSM note: {resp.status} {await resp.text()}\n")
return None
async def fetch_notes_by_search(
self,
limit: int = 100,
closed: int = 7,
query: Optional[str] = None,
user_name: Optional[str] = None,
user_id: Optional[int] = None,
bbox: Optional[OSMBoundingBox] = None,
during: Optional[OSMTimeDelta] = None,
sort: Optional[Literal[OSMSort.CREATED_AT, OSMSort.UPDATED_AT]] = None,
order: Optional[Literal[OSMOrder.NEWEST, OSMOrder.OLDEST]] = None
) -> Tuple[OSMNote, ...]:
"""
Fetch all notes matching to defined criteria.
For more information: https://wiki.openstreetmap.org/wiki/API_v0.6#Search_for_notes:_GET_/api/0.6/notes/search
:param limit: Maximum number of results. Must be under self.capabilities.notes.maximum_query_limit
:param closed: Maximum number of days a note has been closed for. If 0, return only open notes; if negative, return all notes
:param query: Text search query, matching either note text or comments
:param user_name: Search for notes which the given user interacted with
:param user_id: Same than user_name but with user ID. If both option provided, user_name takes priority
:param bbox: Coordinates for the area to retrieve the notes from. Must be under self._capability['note_area'] degrees
:param during: Keep only notes which were created at or updated at (defined in sort) in this timedelta. Before value is optional
:param sort: Define what type of values we use for during. Valid values are defined in OSMSort
:param order: Used to sort by newest or oldest. Valid values are defined in OSMOrder
:return: A tuple containing all notes matching search criterias
:except ValueError: If any parameters is invalid.
"""
# ===== Parameters check ===== #
if not 0 <= limit <= self.capabilities.notes.maximum_query_limit:
raise ValueError(f"Invalid limit: must be a positive below {self.capabilities.notes.maximum_query_limit}")
if bbox is not None:
if not bbox.check_data():
raise ValueError("Bounding box invalid: for more information, check Documentation/OSMBoundingBox.md")
if bbox.get_area() > self.capabilities.notes.area:
raise ValueError(f"Bounding box must be under {self.capabilities.notes.area} square degrees")
if during is not None:
if not during.check_data_validity(optional_after=False):
raise ValueError("After value is newer than before value or you forgot to set before value")
if isinstance(sort, str):
sort = OSMSort(sort)
if sort is not None and sort.value not in OSMSort:
raise ValueError(f"{sort} is an invalid value for sort parameter. Valid values are defined in OSMSort")
if isinstance(order, str):
order = OSMOrder(order)
if order is not None and order.value not in OSMOrder:
raise ValueError(f"{order} is an invalid value for order parameter. Valid values are defined in OSMOrder")
if during is not None and sort is None:
ValueError("You must specify sort if during is defined")
# ===== Build URL ===== #
url = f"https://api.openstreetmap.org/api/0.6/notes/search.json?limit={limit}&closed={quote(str(closed))}"
if query:
url += f"&q={quote(query)}"
if user_name:
url += f"&display_name={quote(user_name)}"
if user_id:
url += f"&user={user_id}"
if bbox:
url += f"&bbox={bbox}"
if during is not None:
url += f"&from={quote(during.after.isoformat())}"
if during.before is not None:
url += f"&to={quote(during.before.isoformat())}"
if sort:
url += f"&sort={sort.value if isinstance(sort, OSMSort) else sort}"
if order:
url += f"&order={order.value if isinstance(order, OSMOrder) else order}"
# ========== #
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
data = json.loads(await resp.text())
if len(data['features']) == 0:
return ()
return tuple([OSMNote(i) for i in data['features']])
else:
sys.stderr.write(f"WARNING: Couldn't fetch OSM notes: {resp.status} {await resp.text()}\n")
return ()
async def fetch_changesets_by_search(
self,
limit: int = 100,
user_name: Optional[str] = None,
user_id: Optional[int] = None,
bbox: Optional[OSMBoundingBox] = None,
created_timedelta: Optional[OSMTimeDelta] = None,
closed_timedelta: Optional[OSMTimeDelta] = None,
by_ids: Optional[Iterable[int]] = None,
status: Literal[OSMStatus.OPEN, OSMStatus.CLOSED, OSMStatus.OPEN_AND_CLOSED] = OSMStatus.OPEN_AND_CLOSED,
order: Optional[Literal[OSMOrder.NEWEST, OSMOrder.OLDEST]] = None
) -> Tuple[OSMChangeset, ...]:
"""
Fetch all changesets matching to defined criteria.
For more information: https://wiki.openstreetmap.org/wiki/API_v0.6#Query:_GET_/api/0.6/changesets
Note: this method doesn't return comments, please use self.fetch_changesets_by_id for this purpose
:param limit: Maximum number of results. Must be under self.capabilities.changesets.maximum_query_limit
:param user_name: Search for notes which the given user interacted with
:param user_id: Same than user_name but with user ID. If both option provided, user_name takes priority
:param bbox: Changesets must be within this bounding box to be returned
:param created_timedelta: Return changesets created during this timedelta. Note: You can set only before or before AND after but can't just set after
:param closed_timedelta: Return changesets created during this timedelta. Note: You can set only before or before AND after but can't just set after
:param by_ids: Return only changesets with those ID
:param status: Decides if you want to get only open or closed changesets. Default value is both of them
:param order: Define in which order to return changesets
:return: Tuple containing all changesets validating those conditions
:except ValueError: If any parameter have invalid values. Please refer to the attached message
"""
# ===== Parameters check =====
if not 0 <= limit <= self.capabilities.changesets.maximum_query_limit:
raise ValueError(
f"Invalid limit: must be a positive below {self.capabilities.changesets.maximum_query_limit}"
)
if user_name is not None and user_id is not None:
raise ValueError("You can only specify a user name or a user ID, but not both")
if bbox is not None:
if not bbox.check_data():
raise ValueError("Bounding box invalid: for more information, check Documentation/OSMBoundingBox.md")
if created_timedelta is not None:
if created_timedelta.after is None:
raise ValueError("You never defined a after datetime for created_timedelta")
if not created_timedelta.check_data_validity(optional_after=False):
raise ValueError(f"Created_timedelta is invalid: before datetime is older than after datetime")
if closed_timedelta is not None:
if closed_timedelta.after is None:
raise ValueError("You never defined a after datetime for closed_timedelta")
if not closed_timedelta.check_data_validity(optional_after=False):
raise ValueError(f"Closed_timedelta is invalid: before datetime is older than after datetime")
if isinstance(status, str):
status = OSMStatus(status)
if status.value not in OSMStatus:
raise ValueError(f"{status} is an invalid value for sort parameter. Valid values are defined in OSMStatus")
if isinstance(order, str):
order = OSMOrder(order)
if order is not None and order.value not in OSMOrder:
raise ValueError(f"{order} is an invalid value for sort parameter. Valid values are defined in OSMOrder")
# ===== Build URL =====
url = f"https://api.openstreetmap.org/api/0.6/changesets.json?limit={limit}"
if user_name:
url += f"&display_name={quote(user_name)}"
if user_id:
url += f"&user={user_id}"
if bbox is not None:
url += f"&bbox={bbox}"
if created_timedelta is not None:
url += f"&from={created_timedelta.after.isoformat()}"
if created_timedelta.before is not None:
url += f"&to={created_timedelta.before.isoformat()}"
if closed_timedelta is not None:
url += f"&time={closed_timedelta.after.isoformat()}"
if closed_timedelta.before is not None:
url += f",{closed_timedelta.before.isoformat()}"
if by_ids is not None:
url += f"&changesets={','.join([str(i) for i in by_ids])}"
match status:
case OSMStatus.OPEN | "o" | "open":
url += "&open=true"
case OSMStatus.CLOSED | "c" | "closed":
url += "&closed=true"
# Do nothing if status is open and closed
if order is not None:
url += f"&order={order.value if isinstance(order, OSMOrder) else order}"
# ========== #
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
data = json.loads(await resp.text())
if len(data['changesets']) == 0:
return ()
return tuple([OSMChangeset(i) for i in data['changesets']])
else:
sys.stderr.write(f"WARNING: Couldn't fetch OSM changesets: {resp.status} {await resp.text()}\n")
return ()
@staticmethod
async def fetch_changeset_by_id(changeset_id: int, include_discussion: bool = False) -> OSMChangeset | None:
"""
Fetch a changeset by its ID
:param changeset_id: The changeset ID to fetch from
:param include_discussion: If set to True, will also fetch this changeset comments
:return: The fetched changeset, or None if any issue happened
"""
url = f"https://api.openstreetmap.org/api/0.6/changeset/{changeset_id}.json"
if include_discussion:
url += "?include_discussion=true"
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
data = json.loads(await resp.text())
if len(data['changeset']) == 0:
sys.stderr.write(f"WARNING: Couldn't fetch OSM changesets: data invalid\n")
return None
return OSMChangeset(data['changeset'])
else:
sys.stderr.write(f"WARNING: Couldn't fetch OSM changesets: {resp.status} {await resp.text()}\n")
return None
async def py_osm_builder() -> PyOSM:
pyosm = PyOSM()
await pyosm.update_capabilities()
return pyosm