osmapi.changeset
Changeset operations for the OpenStreetMap API.
1""" 2Changeset operations for the OpenStreetMap API. 3""" 4 5import re 6import urllib.parse 7import xml.dom.minidom 8import xml.parsers.expat 9from contextlib import contextmanager 10from collections.abc import Generator 11from typing import Any, TYPE_CHECKING, cast 12from xml.dom.minidom import Element 13 14from . import dom, errors, xmlbuilder, parser 15 16if TYPE_CHECKING: 17 from .OsmApi import OsmApi 18 19 20class ChangesetMixin: 21 """Mixin providing changeset-related operations with pythonic method names.""" 22 23 @contextmanager 24 def changeset( 25 self: "OsmApi", changeset_tags: dict[str, str] | None = None 26 ) -> Generator[int, None, None]: 27 """ 28 Context manager for a Changeset. 29 30 It opens a Changeset, uploads the changes and closes the changeset 31 when used with the `with` statement: 32 33 #!python 34 import osmapi 35 36 with api.changeset({"comment": "Import script XYZ"}) as changeset_id: 37 print(f"Part of changeset {changeset_id}") 38 api.node_create({"lon":1, "lat":1, "tag": {}}) 39 40 If `changeset_tags` are given, this tags are applied (key/value). 41 42 Returns `changeset_id` 43 44 The changeset is closed on the way out even if the body raises, so 45 that an error does not leave a changeset dangling and block the next 46 one with an `OsmApi.ChangesetAlreadyOpenError`. 47 48 If no session is provided to authenticate the request, 49 `OsmApi.AuthenticationMissingError` is raised. 50 51 If there is already an open changeset, 52 `OsmApi.ChangesetAlreadyOpenError` is raised. 53 """ 54 if changeset_tags is None: 55 changeset_tags = {} 56 # Create a new changeset 57 changeset_id = self.changeset_create(changeset_tags) 58 try: 59 yield changeset_id 60 finally: 61 self.changeset_close() 62 63 def changeset_get( 64 self: "OsmApi", changeset_id: int, include_discussion: bool = False 65 ) -> dict[str, Any]: 66 """ 67 Returns changeset with `changeset_id` as a dict. 68 69 `changeset_id` is the unique identifier of a changeset. 70 71 If `include_discussion` is set to `True` the changeset discussion 72 will be available in the result. 73 """ 74 path = f"/api/0.6/changeset/{changeset_id}" 75 if include_discussion: 76 path = f"{path}?include_discussion=true" 77 data = self._session._get(path) 78 changeset = cast( 79 Element, dom.OsmResponseToDom(data, tag="changeset", single=True) 80 ) 81 return dom.dom_parse_changeset(changeset, include_discussion=include_discussion) 82 83 def changeset_update( 84 self: "OsmApi", changeset_tags: dict[str, str] | None = None 85 ) -> int: 86 """ 87 Updates current changeset with `changeset_tags`. 88 89 If no session is provided to authenticate the request, 90 `OsmApi.AuthenticationMissingError` is raised. 91 92 If there is no open changeset, 93 `OsmApi.NoChangesetOpenError` is raised. 94 95 If the changeset is already closed, 96 `OsmApi.ChangesetClosedApiError` is raised. 97 """ 98 if changeset_tags is None: 99 changeset_tags = {} 100 if not self._current_changeset_id: 101 raise errors.NoChangesetOpenError("No changeset currently opened") 102 if "created_by" not in changeset_tags: 103 changeset_tags["created_by"] = self._created_by 104 try: 105 self._session._put( 106 f"/api/0.6/changeset/{self._current_changeset_id}", 107 xmlbuilder._xml_build("changeset", {"tag": changeset_tags}, data=self), 108 return_value=False, 109 ) 110 except errors.ApiError as e: 111 if e.status == 409: 112 raise errors.ChangesetClosedApiError( 113 e.status, e.reason, e.payload 114 ) from e 115 else: 116 raise 117 return self._current_changeset_id 118 119 def changeset_create( 120 self: "OsmApi", changeset_tags: dict[str, str] | None = None 121 ) -> int: 122 """ 123 Opens a changeset. 124 125 If `changeset_tags` are given, this tags are applied (key/value). 126 127 Returns `changeset_id` 128 129 If no session is provided to authenticate the request, 130 `OsmApi.AuthenticationMissingError` is raised. 131 132 If there is already an open changeset, 133 `OsmApi.ChangesetAlreadyOpenError` is raised. 134 """ 135 if changeset_tags is None: 136 changeset_tags = {} 137 if self._current_changeset_id: 138 raise errors.ChangesetAlreadyOpenError("Changeset already opened") 139 if "created_by" not in changeset_tags: 140 changeset_tags["created_by"] = self._created_by 141 142 # check if someone tries to create a test changeset to PROD 143 if ( 144 self._api == "https://www.openstreetmap.org" 145 and changeset_tags.get("comment") == "My first test" 146 ): 147 raise errors.OsmApiError( 148 "DO NOT CREATE test changesets on the production server" 149 ) 150 151 result = self._session._put( 152 "/api/0.6/changeset/create", 153 xmlbuilder._xml_build("changeset", {"tag": changeset_tags}, data=self), 154 ) 155 self._current_changeset_id = int(result) 156 return self._current_changeset_id 157 158 def changeset_close(self: "OsmApi") -> int: 159 """ 160 Closes current changeset. 161 162 Returns `changeset_id`. 163 164 If no session is provided to authenticate the request, 165 `OsmApi.AuthenticationMissingError` is raised. 166 167 If there is no open changeset, 168 `OsmApi.NoChangesetOpenError` is raised. 169 170 If the changeset is already closed, 171 `OsmApi.ChangesetClosedApiError` is raised. 172 """ 173 if not self._current_changeset_id: 174 raise errors.NoChangesetOpenError("No changeset currently opened") 175 try: 176 self._session._put( 177 f"/api/0.6/changeset/{self._current_changeset_id}/close", 178 None, 179 return_value=False, 180 ) 181 current_changeset_id = self._current_changeset_id 182 self._current_changeset_id = 0 183 except errors.ApiError as e: 184 if e.status == 409: 185 raise errors.ChangesetClosedApiError( 186 e.status, e.reason, e.payload 187 ) from e 188 else: 189 raise 190 return current_changeset_id 191 192 def changeset_upload( 193 self: "OsmApi", changes_data: list[dict[str, Any]] 194 ) -> list[dict[str, Any]]: 195 """ 196 Upload data with the `changes_data` list of dicts. 197 198 Returns list with updated ids. 199 200 If no session is provided to authenticate the request, 201 `OsmApi.AuthenticationMissingError` is raised. 202 203 If the changeset is already closed, 204 `OsmApi.ChangesetClosedApiError` is raised. 205 """ 206 data = "" 207 data += '<?xml version="1.0" encoding="UTF-8"?>\n' 208 data += '<osmChange version="0.6" generator="' 209 data += self._created_by + '">\n' 210 for change in changes_data: 211 data += "<" + change["action"] + ">\n" 212 change_data = change["data"] 213 data += self._add_changeset_data(change_data, change["type"]) 214 data += "</" + change["action"] + ">\n" 215 data += "</osmChange>" 216 try: 217 response_data = self._session._post( 218 f"/api/0.6/changeset/{self._current_changeset_id}/upload", 219 data.encode("utf-8"), 220 forceAuth=True, 221 ) 222 except errors.ApiError as e: 223 if e.status == 409 and re.search( 224 r"The changeset .* was closed at .*", e.payload_str 225 ): 226 raise errors.ChangesetClosedApiError( 227 e.status, e.reason, e.payload 228 ) from e 229 else: 230 raise 231 try: 232 result_dom = xml.dom.minidom.parseString(response_data) 233 diff_result = result_dom.getElementsByTagName("diffResult")[0] 234 result_elements = [ 235 x for x in diff_result.childNodes if x.nodeType == x.ELEMENT_NODE 236 ] 237 except (xml.parsers.expat.ExpatError, IndexError) as e: 238 raise errors.XmlResponseInvalidError( 239 f"The XML response from the OSM API is invalid: {e!r}" 240 ) from e 241 242 for change in changes_data: 243 if change["action"] == "delete": 244 for change_element in change["data"]: 245 change_element.pop("version") 246 else: 247 self._assign_id_and_version(result_elements, change["data"]) 248 249 return changes_data 250 251 def changeset_download(self: "OsmApi", changeset_id: int) -> list[dict[str, Any]]: 252 """ 253 Download data from changeset `changeset_id`. 254 255 Returns list of dict with type, action, and data. 256 """ 257 uri = f"/api/0.6/changeset/{changeset_id}/download" 258 data = self._session._get(uri) 259 return parser.parse_osc(data) 260 261 def changesets_get( # noqa: C901 262 self: "OsmApi", 263 min_lon: float | None = None, 264 min_lat: float | None = None, 265 max_lon: float | None = None, 266 max_lat: float | None = None, 267 userid: int | None = None, 268 username: str | None = None, 269 closed_after: str | None = None, 270 created_before: str | None = None, 271 only_open: bool = False, 272 only_closed: bool = False, 273 ) -> dict[int, dict[str, Any]]: 274 """ 275 Returns a dict with the id of the changeset as key matching all criteria. 276 277 All parameters are optional. The bounding box is only applied if all 278 four of `min_lon`, `min_lat`, `max_lon` and `max_lat` are given. 279 280 If only some of the bounding box values are given, 281 `ValueError` is raised. 282 """ 283 uri = "/api/0.6/changesets" 284 params: dict[str, Any] = {} 285 bbox = (min_lon, min_lat, max_lon, max_lat) 286 if any(coord is not None for coord in bbox): 287 if any(coord is None for coord in bbox): 288 raise ValueError( 289 "A bounding box needs all of min_lon, min_lat, max_lon " 290 "and max_lat, got " 291 f"min_lon={min_lon}, min_lat={min_lat}, " 292 f"max_lon={max_lon}, max_lat={max_lat}" 293 ) 294 params["bbox"] = ",".join(str(coord) for coord in bbox) 295 if userid: 296 params["user"] = userid 297 if username: 298 params["display_name"] = username 299 if closed_after and not created_before: 300 params["time"] = closed_after 301 if created_before: 302 if not closed_after: 303 closed_after = "1970-01-01T00:00:00Z" 304 params["time"] = f"{closed_after},{created_before}" 305 if only_open: 306 params["open"] = 1 307 if only_closed: 308 params["closed"] = 1 309 310 if params: 311 uri += "?" + urllib.parse.urlencode(params) 312 313 data = self._session._get(uri) 314 changesets = cast(list[Element], dom.OsmResponseToDom(data, tag="changeset")) 315 result: dict[int, dict[str, Any]] = {} 316 for cur_changeset in changesets: 317 tmp_cs = dom.dom_parse_changeset(cur_changeset) 318 result[tmp_cs["id"]] = tmp_cs 319 return result 320 321 def changeset_comment( 322 self: "OsmApi", changeset_id: int, comment: str 323 ) -> dict[str, Any]: 324 """ 325 Adds a comment to the changeset `changeset_id`. 326 327 `comment` should be a string. 328 329 Returns the updated changeset data dict. 330 331 If no session is provided to authenticate the request, 332 `OsmApi.AuthenticationMissingError` is raised. 333 334 If the changeset is already closed, 335 `OsmApi.ChangesetClosedApiError` is raised. 336 """ 337 params = urllib.parse.urlencode({"text": comment}) 338 try: 339 data = self._session._post( 340 f"/api/0.6/changeset/{changeset_id}/comment", 341 params, 342 forceAuth=True, 343 ) 344 except errors.ApiError as e: 345 if e.status == 409: 346 raise errors.ChangesetClosedApiError( 347 e.status, e.reason, e.payload 348 ) from e 349 else: 350 raise 351 changeset = cast( 352 Element, 353 dom.OsmResponseToDom(data, tag="changeset", single=True), 354 ) 355 return dom.dom_parse_changeset(changeset, include_discussion=False) 356 357 def changeset_subscribe(self: "OsmApi", changeset_id: int) -> dict[str, Any]: 358 """ 359 Subscribe to the changeset `changeset_id`. 360 361 Returns the updated changeset data dict. 362 363 If no session is provided to authenticate the request, 364 `OsmApi.AuthenticationMissingError` is raised. 365 366 If already subscribed to this changeset, 367 `OsmApi.AlreadySubscribedApiError` is raised. 368 """ 369 try: 370 data = self._session._post( 371 f"/api/0.6/changeset/{changeset_id}/subscribe", 372 None, 373 forceAuth=True, 374 ) 375 except errors.ApiError as e: 376 if e.status == 409: 377 raise errors.AlreadySubscribedApiError( 378 e.status, e.reason, e.payload 379 ) from e 380 else: 381 raise 382 changeset = cast( 383 Element, 384 dom.OsmResponseToDom(data, tag="changeset", single=True), 385 ) 386 return dom.dom_parse_changeset(changeset, include_discussion=False) 387 388 def changeset_unsubscribe(self: "OsmApi", changeset_id: int) -> dict[str, Any]: 389 """ 390 Unsubscribe from the changeset `changeset_id`. 391 392 Returns the updated changeset data dict. 393 394 If no session is provided to authenticate the request, 395 `OsmApi.AuthenticationMissingError` is raised. 396 397 If not subscribed to this changeset, 398 `OsmApi.NotSubscribedApiError` is raised. 399 """ 400 try: 401 data = self._session._post( 402 f"/api/0.6/changeset/{changeset_id}/unsubscribe", 403 None, 404 forceAuth=True, 405 ) 406 except errors.ApiError as e: 407 if e.status == 404: 408 raise errors.NotSubscribedApiError(e.status, e.reason, e.payload) from e 409 else: 410 raise 411 changeset = cast( 412 Element, 413 dom.OsmResponseToDom(data, tag="changeset", single=True), 414 ) 415 return dom.dom_parse_changeset(changeset, include_discussion=False)
21class ChangesetMixin: 22 """Mixin providing changeset-related operations with pythonic method names.""" 23 24 @contextmanager 25 def changeset( 26 self: "OsmApi", changeset_tags: dict[str, str] | None = None 27 ) -> Generator[int, None, None]: 28 """ 29 Context manager for a Changeset. 30 31 It opens a Changeset, uploads the changes and closes the changeset 32 when used with the `with` statement: 33 34 #!python 35 import osmapi 36 37 with api.changeset({"comment": "Import script XYZ"}) as changeset_id: 38 print(f"Part of changeset {changeset_id}") 39 api.node_create({"lon":1, "lat":1, "tag": {}}) 40 41 If `changeset_tags` are given, this tags are applied (key/value). 42 43 Returns `changeset_id` 44 45 The changeset is closed on the way out even if the body raises, so 46 that an error does not leave a changeset dangling and block the next 47 one with an `OsmApi.ChangesetAlreadyOpenError`. 48 49 If no session is provided to authenticate the request, 50 `OsmApi.AuthenticationMissingError` is raised. 51 52 If there is already an open changeset, 53 `OsmApi.ChangesetAlreadyOpenError` is raised. 54 """ 55 if changeset_tags is None: 56 changeset_tags = {} 57 # Create a new changeset 58 changeset_id = self.changeset_create(changeset_tags) 59 try: 60 yield changeset_id 61 finally: 62 self.changeset_close() 63 64 def changeset_get( 65 self: "OsmApi", changeset_id: int, include_discussion: bool = False 66 ) -> dict[str, Any]: 67 """ 68 Returns changeset with `changeset_id` as a dict. 69 70 `changeset_id` is the unique identifier of a changeset. 71 72 If `include_discussion` is set to `True` the changeset discussion 73 will be available in the result. 74 """ 75 path = f"/api/0.6/changeset/{changeset_id}" 76 if include_discussion: 77 path = f"{path}?include_discussion=true" 78 data = self._session._get(path) 79 changeset = cast( 80 Element, dom.OsmResponseToDom(data, tag="changeset", single=True) 81 ) 82 return dom.dom_parse_changeset(changeset, include_discussion=include_discussion) 83 84 def changeset_update( 85 self: "OsmApi", changeset_tags: dict[str, str] | None = None 86 ) -> int: 87 """ 88 Updates current changeset with `changeset_tags`. 89 90 If no session is provided to authenticate the request, 91 `OsmApi.AuthenticationMissingError` is raised. 92 93 If there is no open changeset, 94 `OsmApi.NoChangesetOpenError` is raised. 95 96 If the changeset is already closed, 97 `OsmApi.ChangesetClosedApiError` is raised. 98 """ 99 if changeset_tags is None: 100 changeset_tags = {} 101 if not self._current_changeset_id: 102 raise errors.NoChangesetOpenError("No changeset currently opened") 103 if "created_by" not in changeset_tags: 104 changeset_tags["created_by"] = self._created_by 105 try: 106 self._session._put( 107 f"/api/0.6/changeset/{self._current_changeset_id}", 108 xmlbuilder._xml_build("changeset", {"tag": changeset_tags}, data=self), 109 return_value=False, 110 ) 111 except errors.ApiError as e: 112 if e.status == 409: 113 raise errors.ChangesetClosedApiError( 114 e.status, e.reason, e.payload 115 ) from e 116 else: 117 raise 118 return self._current_changeset_id 119 120 def changeset_create( 121 self: "OsmApi", changeset_tags: dict[str, str] | None = None 122 ) -> int: 123 """ 124 Opens a changeset. 125 126 If `changeset_tags` are given, this tags are applied (key/value). 127 128 Returns `changeset_id` 129 130 If no session is provided to authenticate the request, 131 `OsmApi.AuthenticationMissingError` is raised. 132 133 If there is already an open changeset, 134 `OsmApi.ChangesetAlreadyOpenError` is raised. 135 """ 136 if changeset_tags is None: 137 changeset_tags = {} 138 if self._current_changeset_id: 139 raise errors.ChangesetAlreadyOpenError("Changeset already opened") 140 if "created_by" not in changeset_tags: 141 changeset_tags["created_by"] = self._created_by 142 143 # check if someone tries to create a test changeset to PROD 144 if ( 145 self._api == "https://www.openstreetmap.org" 146 and changeset_tags.get("comment") == "My first test" 147 ): 148 raise errors.OsmApiError( 149 "DO NOT CREATE test changesets on the production server" 150 ) 151 152 result = self._session._put( 153 "/api/0.6/changeset/create", 154 xmlbuilder._xml_build("changeset", {"tag": changeset_tags}, data=self), 155 ) 156 self._current_changeset_id = int(result) 157 return self._current_changeset_id 158 159 def changeset_close(self: "OsmApi") -> int: 160 """ 161 Closes current changeset. 162 163 Returns `changeset_id`. 164 165 If no session is provided to authenticate the request, 166 `OsmApi.AuthenticationMissingError` is raised. 167 168 If there is no open changeset, 169 `OsmApi.NoChangesetOpenError` is raised. 170 171 If the changeset is already closed, 172 `OsmApi.ChangesetClosedApiError` is raised. 173 """ 174 if not self._current_changeset_id: 175 raise errors.NoChangesetOpenError("No changeset currently opened") 176 try: 177 self._session._put( 178 f"/api/0.6/changeset/{self._current_changeset_id}/close", 179 None, 180 return_value=False, 181 ) 182 current_changeset_id = self._current_changeset_id 183 self._current_changeset_id = 0 184 except errors.ApiError as e: 185 if e.status == 409: 186 raise errors.ChangesetClosedApiError( 187 e.status, e.reason, e.payload 188 ) from e 189 else: 190 raise 191 return current_changeset_id 192 193 def changeset_upload( 194 self: "OsmApi", changes_data: list[dict[str, Any]] 195 ) -> list[dict[str, Any]]: 196 """ 197 Upload data with the `changes_data` list of dicts. 198 199 Returns list with updated ids. 200 201 If no session is provided to authenticate the request, 202 `OsmApi.AuthenticationMissingError` is raised. 203 204 If the changeset is already closed, 205 `OsmApi.ChangesetClosedApiError` is raised. 206 """ 207 data = "" 208 data += '<?xml version="1.0" encoding="UTF-8"?>\n' 209 data += '<osmChange version="0.6" generator="' 210 data += self._created_by + '">\n' 211 for change in changes_data: 212 data += "<" + change["action"] + ">\n" 213 change_data = change["data"] 214 data += self._add_changeset_data(change_data, change["type"]) 215 data += "</" + change["action"] + ">\n" 216 data += "</osmChange>" 217 try: 218 response_data = self._session._post( 219 f"/api/0.6/changeset/{self._current_changeset_id}/upload", 220 data.encode("utf-8"), 221 forceAuth=True, 222 ) 223 except errors.ApiError as e: 224 if e.status == 409 and re.search( 225 r"The changeset .* was closed at .*", e.payload_str 226 ): 227 raise errors.ChangesetClosedApiError( 228 e.status, e.reason, e.payload 229 ) from e 230 else: 231 raise 232 try: 233 result_dom = xml.dom.minidom.parseString(response_data) 234 diff_result = result_dom.getElementsByTagName("diffResult")[0] 235 result_elements = [ 236 x for x in diff_result.childNodes if x.nodeType == x.ELEMENT_NODE 237 ] 238 except (xml.parsers.expat.ExpatError, IndexError) as e: 239 raise errors.XmlResponseInvalidError( 240 f"The XML response from the OSM API is invalid: {e!r}" 241 ) from e 242 243 for change in changes_data: 244 if change["action"] == "delete": 245 for change_element in change["data"]: 246 change_element.pop("version") 247 else: 248 self._assign_id_and_version(result_elements, change["data"]) 249 250 return changes_data 251 252 def changeset_download(self: "OsmApi", changeset_id: int) -> list[dict[str, Any]]: 253 """ 254 Download data from changeset `changeset_id`. 255 256 Returns list of dict with type, action, and data. 257 """ 258 uri = f"/api/0.6/changeset/{changeset_id}/download" 259 data = self._session._get(uri) 260 return parser.parse_osc(data) 261 262 def changesets_get( # noqa: C901 263 self: "OsmApi", 264 min_lon: float | None = None, 265 min_lat: float | None = None, 266 max_lon: float | None = None, 267 max_lat: float | None = None, 268 userid: int | None = None, 269 username: str | None = None, 270 closed_after: str | None = None, 271 created_before: str | None = None, 272 only_open: bool = False, 273 only_closed: bool = False, 274 ) -> dict[int, dict[str, Any]]: 275 """ 276 Returns a dict with the id of the changeset as key matching all criteria. 277 278 All parameters are optional. The bounding box is only applied if all 279 four of `min_lon`, `min_lat`, `max_lon` and `max_lat` are given. 280 281 If only some of the bounding box values are given, 282 `ValueError` is raised. 283 """ 284 uri = "/api/0.6/changesets" 285 params: dict[str, Any] = {} 286 bbox = (min_lon, min_lat, max_lon, max_lat) 287 if any(coord is not None for coord in bbox): 288 if any(coord is None for coord in bbox): 289 raise ValueError( 290 "A bounding box needs all of min_lon, min_lat, max_lon " 291 "and max_lat, got " 292 f"min_lon={min_lon}, min_lat={min_lat}, " 293 f"max_lon={max_lon}, max_lat={max_lat}" 294 ) 295 params["bbox"] = ",".join(str(coord) for coord in bbox) 296 if userid: 297 params["user"] = userid 298 if username: 299 params["display_name"] = username 300 if closed_after and not created_before: 301 params["time"] = closed_after 302 if created_before: 303 if not closed_after: 304 closed_after = "1970-01-01T00:00:00Z" 305 params["time"] = f"{closed_after},{created_before}" 306 if only_open: 307 params["open"] = 1 308 if only_closed: 309 params["closed"] = 1 310 311 if params: 312 uri += "?" + urllib.parse.urlencode(params) 313 314 data = self._session._get(uri) 315 changesets = cast(list[Element], dom.OsmResponseToDom(data, tag="changeset")) 316 result: dict[int, dict[str, Any]] = {} 317 for cur_changeset in changesets: 318 tmp_cs = dom.dom_parse_changeset(cur_changeset) 319 result[tmp_cs["id"]] = tmp_cs 320 return result 321 322 def changeset_comment( 323 self: "OsmApi", changeset_id: int, comment: str 324 ) -> dict[str, Any]: 325 """ 326 Adds a comment to the changeset `changeset_id`. 327 328 `comment` should be a string. 329 330 Returns the updated changeset data dict. 331 332 If no session is provided to authenticate the request, 333 `OsmApi.AuthenticationMissingError` is raised. 334 335 If the changeset is already closed, 336 `OsmApi.ChangesetClosedApiError` is raised. 337 """ 338 params = urllib.parse.urlencode({"text": comment}) 339 try: 340 data = self._session._post( 341 f"/api/0.6/changeset/{changeset_id}/comment", 342 params, 343 forceAuth=True, 344 ) 345 except errors.ApiError as e: 346 if e.status == 409: 347 raise errors.ChangesetClosedApiError( 348 e.status, e.reason, e.payload 349 ) from e 350 else: 351 raise 352 changeset = cast( 353 Element, 354 dom.OsmResponseToDom(data, tag="changeset", single=True), 355 ) 356 return dom.dom_parse_changeset(changeset, include_discussion=False) 357 358 def changeset_subscribe(self: "OsmApi", changeset_id: int) -> dict[str, Any]: 359 """ 360 Subscribe to the changeset `changeset_id`. 361 362 Returns the updated changeset data dict. 363 364 If no session is provided to authenticate the request, 365 `OsmApi.AuthenticationMissingError` is raised. 366 367 If already subscribed to this changeset, 368 `OsmApi.AlreadySubscribedApiError` is raised. 369 """ 370 try: 371 data = self._session._post( 372 f"/api/0.6/changeset/{changeset_id}/subscribe", 373 None, 374 forceAuth=True, 375 ) 376 except errors.ApiError as e: 377 if e.status == 409: 378 raise errors.AlreadySubscribedApiError( 379 e.status, e.reason, e.payload 380 ) from e 381 else: 382 raise 383 changeset = cast( 384 Element, 385 dom.OsmResponseToDom(data, tag="changeset", single=True), 386 ) 387 return dom.dom_parse_changeset(changeset, include_discussion=False) 388 389 def changeset_unsubscribe(self: "OsmApi", changeset_id: int) -> dict[str, Any]: 390 """ 391 Unsubscribe from the changeset `changeset_id`. 392 393 Returns the updated changeset data dict. 394 395 If no session is provided to authenticate the request, 396 `OsmApi.AuthenticationMissingError` is raised. 397 398 If not subscribed to this changeset, 399 `OsmApi.NotSubscribedApiError` is raised. 400 """ 401 try: 402 data = self._session._post( 403 f"/api/0.6/changeset/{changeset_id}/unsubscribe", 404 None, 405 forceAuth=True, 406 ) 407 except errors.ApiError as e: 408 if e.status == 404: 409 raise errors.NotSubscribedApiError(e.status, e.reason, e.payload) from e 410 else: 411 raise 412 changeset = cast( 413 Element, 414 dom.OsmResponseToDom(data, tag="changeset", single=True), 415 ) 416 return dom.dom_parse_changeset(changeset, include_discussion=False)
Mixin providing changeset-related operations with pythonic method names.
24 @contextmanager 25 def changeset( 26 self: "OsmApi", changeset_tags: dict[str, str] | None = None 27 ) -> Generator[int, None, None]: 28 """ 29 Context manager for a Changeset. 30 31 It opens a Changeset, uploads the changes and closes the changeset 32 when used with the `with` statement: 33 34 #!python 35 import osmapi 36 37 with api.changeset({"comment": "Import script XYZ"}) as changeset_id: 38 print(f"Part of changeset {changeset_id}") 39 api.node_create({"lon":1, "lat":1, "tag": {}}) 40 41 If `changeset_tags` are given, this tags are applied (key/value). 42 43 Returns `changeset_id` 44 45 The changeset is closed on the way out even if the body raises, so 46 that an error does not leave a changeset dangling and block the next 47 one with an `OsmApi.ChangesetAlreadyOpenError`. 48 49 If no session is provided to authenticate the request, 50 `OsmApi.AuthenticationMissingError` is raised. 51 52 If there is already an open changeset, 53 `OsmApi.ChangesetAlreadyOpenError` is raised. 54 """ 55 if changeset_tags is None: 56 changeset_tags = {} 57 # Create a new changeset 58 changeset_id = self.changeset_create(changeset_tags) 59 try: 60 yield changeset_id 61 finally: 62 self.changeset_close()
Context manager for a Changeset.
It opens a Changeset, uploads the changes and closes the changeset
when used with the with statement:
#!python
import osmapi
with api.changeset({"comment": "Import script XYZ"}) as changeset_id:
print(f"Part of changeset {changeset_id}")
api.node_create({"lon":1, "lat":1, "tag": {}})
If changeset_tags are given, this tags are applied (key/value).
Returns changeset_id
The changeset is closed on the way out even if the body raises, so
that an error does not leave a changeset dangling and block the next
one with an OsmApi.ChangesetAlreadyOpenError.
If no session is provided to authenticate the request,
OsmApi.AuthenticationMissingError is raised.
If there is already an open changeset,
OsmApi.ChangesetAlreadyOpenError is raised.
64 def changeset_get( 65 self: "OsmApi", changeset_id: int, include_discussion: bool = False 66 ) -> dict[str, Any]: 67 """ 68 Returns changeset with `changeset_id` as a dict. 69 70 `changeset_id` is the unique identifier of a changeset. 71 72 If `include_discussion` is set to `True` the changeset discussion 73 will be available in the result. 74 """ 75 path = f"/api/0.6/changeset/{changeset_id}" 76 if include_discussion: 77 path = f"{path}?include_discussion=true" 78 data = self._session._get(path) 79 changeset = cast( 80 Element, dom.OsmResponseToDom(data, tag="changeset", single=True) 81 ) 82 return dom.dom_parse_changeset(changeset, include_discussion=include_discussion)
Returns changeset with changeset_id as a dict.
changeset_id is the unique identifier of a changeset.
If include_discussion is set to True the changeset discussion
will be available in the result.
84 def changeset_update( 85 self: "OsmApi", changeset_tags: dict[str, str] | None = None 86 ) -> int: 87 """ 88 Updates current changeset with `changeset_tags`. 89 90 If no session is provided to authenticate the request, 91 `OsmApi.AuthenticationMissingError` is raised. 92 93 If there is no open changeset, 94 `OsmApi.NoChangesetOpenError` is raised. 95 96 If the changeset is already closed, 97 `OsmApi.ChangesetClosedApiError` is raised. 98 """ 99 if changeset_tags is None: 100 changeset_tags = {} 101 if not self._current_changeset_id: 102 raise errors.NoChangesetOpenError("No changeset currently opened") 103 if "created_by" not in changeset_tags: 104 changeset_tags["created_by"] = self._created_by 105 try: 106 self._session._put( 107 f"/api/0.6/changeset/{self._current_changeset_id}", 108 xmlbuilder._xml_build("changeset", {"tag": changeset_tags}, data=self), 109 return_value=False, 110 ) 111 except errors.ApiError as e: 112 if e.status == 409: 113 raise errors.ChangesetClosedApiError( 114 e.status, e.reason, e.payload 115 ) from e 116 else: 117 raise 118 return self._current_changeset_id
Updates current changeset with changeset_tags.
If no session is provided to authenticate the request,
OsmApi.AuthenticationMissingError is raised.
If there is no open changeset,
OsmApi.NoChangesetOpenError is raised.
If the changeset is already closed,
OsmApi.ChangesetClosedApiError is raised.
120 def changeset_create( 121 self: "OsmApi", changeset_tags: dict[str, str] | None = None 122 ) -> int: 123 """ 124 Opens a changeset. 125 126 If `changeset_tags` are given, this tags are applied (key/value). 127 128 Returns `changeset_id` 129 130 If no session is provided to authenticate the request, 131 `OsmApi.AuthenticationMissingError` is raised. 132 133 If there is already an open changeset, 134 `OsmApi.ChangesetAlreadyOpenError` is raised. 135 """ 136 if changeset_tags is None: 137 changeset_tags = {} 138 if self._current_changeset_id: 139 raise errors.ChangesetAlreadyOpenError("Changeset already opened") 140 if "created_by" not in changeset_tags: 141 changeset_tags["created_by"] = self._created_by 142 143 # check if someone tries to create a test changeset to PROD 144 if ( 145 self._api == "https://www.openstreetmap.org" 146 and changeset_tags.get("comment") == "My first test" 147 ): 148 raise errors.OsmApiError( 149 "DO NOT CREATE test changesets on the production server" 150 ) 151 152 result = self._session._put( 153 "/api/0.6/changeset/create", 154 xmlbuilder._xml_build("changeset", {"tag": changeset_tags}, data=self), 155 ) 156 self._current_changeset_id = int(result) 157 return self._current_changeset_id
Opens a changeset.
If changeset_tags are given, this tags are applied (key/value).
Returns changeset_id
If no session is provided to authenticate the request,
OsmApi.AuthenticationMissingError is raised.
If there is already an open changeset,
OsmApi.ChangesetAlreadyOpenError is raised.
159 def changeset_close(self: "OsmApi") -> int: 160 """ 161 Closes current changeset. 162 163 Returns `changeset_id`. 164 165 If no session is provided to authenticate the request, 166 `OsmApi.AuthenticationMissingError` is raised. 167 168 If there is no open changeset, 169 `OsmApi.NoChangesetOpenError` is raised. 170 171 If the changeset is already closed, 172 `OsmApi.ChangesetClosedApiError` is raised. 173 """ 174 if not self._current_changeset_id: 175 raise errors.NoChangesetOpenError("No changeset currently opened") 176 try: 177 self._session._put( 178 f"/api/0.6/changeset/{self._current_changeset_id}/close", 179 None, 180 return_value=False, 181 ) 182 current_changeset_id = self._current_changeset_id 183 self._current_changeset_id = 0 184 except errors.ApiError as e: 185 if e.status == 409: 186 raise errors.ChangesetClosedApiError( 187 e.status, e.reason, e.payload 188 ) from e 189 else: 190 raise 191 return current_changeset_id
Closes current changeset.
Returns changeset_id.
If no session is provided to authenticate the request,
OsmApi.AuthenticationMissingError is raised.
If there is no open changeset,
OsmApi.NoChangesetOpenError is raised.
If the changeset is already closed,
OsmApi.ChangesetClosedApiError is raised.
193 def changeset_upload( 194 self: "OsmApi", changes_data: list[dict[str, Any]] 195 ) -> list[dict[str, Any]]: 196 """ 197 Upload data with the `changes_data` list of dicts. 198 199 Returns list with updated ids. 200 201 If no session is provided to authenticate the request, 202 `OsmApi.AuthenticationMissingError` is raised. 203 204 If the changeset is already closed, 205 `OsmApi.ChangesetClosedApiError` is raised. 206 """ 207 data = "" 208 data += '<?xml version="1.0" encoding="UTF-8"?>\n' 209 data += '<osmChange version="0.6" generator="' 210 data += self._created_by + '">\n' 211 for change in changes_data: 212 data += "<" + change["action"] + ">\n" 213 change_data = change["data"] 214 data += self._add_changeset_data(change_data, change["type"]) 215 data += "</" + change["action"] + ">\n" 216 data += "</osmChange>" 217 try: 218 response_data = self._session._post( 219 f"/api/0.6/changeset/{self._current_changeset_id}/upload", 220 data.encode("utf-8"), 221 forceAuth=True, 222 ) 223 except errors.ApiError as e: 224 if e.status == 409 and re.search( 225 r"The changeset .* was closed at .*", e.payload_str 226 ): 227 raise errors.ChangesetClosedApiError( 228 e.status, e.reason, e.payload 229 ) from e 230 else: 231 raise 232 try: 233 result_dom = xml.dom.minidom.parseString(response_data) 234 diff_result = result_dom.getElementsByTagName("diffResult")[0] 235 result_elements = [ 236 x for x in diff_result.childNodes if x.nodeType == x.ELEMENT_NODE 237 ] 238 except (xml.parsers.expat.ExpatError, IndexError) as e: 239 raise errors.XmlResponseInvalidError( 240 f"The XML response from the OSM API is invalid: {e!r}" 241 ) from e 242 243 for change in changes_data: 244 if change["action"] == "delete": 245 for change_element in change["data"]: 246 change_element.pop("version") 247 else: 248 self._assign_id_and_version(result_elements, change["data"]) 249 250 return changes_data
Upload data with the changes_data list of dicts.
Returns list with updated ids.
If no session is provided to authenticate the request,
OsmApi.AuthenticationMissingError is raised.
If the changeset is already closed,
OsmApi.ChangesetClosedApiError is raised.
252 def changeset_download(self: "OsmApi", changeset_id: int) -> list[dict[str, Any]]: 253 """ 254 Download data from changeset `changeset_id`. 255 256 Returns list of dict with type, action, and data. 257 """ 258 uri = f"/api/0.6/changeset/{changeset_id}/download" 259 data = self._session._get(uri) 260 return parser.parse_osc(data)
Download data from changeset changeset_id.
Returns list of dict with type, action, and data.
262 def changesets_get( # noqa: C901 263 self: "OsmApi", 264 min_lon: float | None = None, 265 min_lat: float | None = None, 266 max_lon: float | None = None, 267 max_lat: float | None = None, 268 userid: int | None = None, 269 username: str | None = None, 270 closed_after: str | None = None, 271 created_before: str | None = None, 272 only_open: bool = False, 273 only_closed: bool = False, 274 ) -> dict[int, dict[str, Any]]: 275 """ 276 Returns a dict with the id of the changeset as key matching all criteria. 277 278 All parameters are optional. The bounding box is only applied if all 279 four of `min_lon`, `min_lat`, `max_lon` and `max_lat` are given. 280 281 If only some of the bounding box values are given, 282 `ValueError` is raised. 283 """ 284 uri = "/api/0.6/changesets" 285 params: dict[str, Any] = {} 286 bbox = (min_lon, min_lat, max_lon, max_lat) 287 if any(coord is not None for coord in bbox): 288 if any(coord is None for coord in bbox): 289 raise ValueError( 290 "A bounding box needs all of min_lon, min_lat, max_lon " 291 "and max_lat, got " 292 f"min_lon={min_lon}, min_lat={min_lat}, " 293 f"max_lon={max_lon}, max_lat={max_lat}" 294 ) 295 params["bbox"] = ",".join(str(coord) for coord in bbox) 296 if userid: 297 params["user"] = userid 298 if username: 299 params["display_name"] = username 300 if closed_after and not created_before: 301 params["time"] = closed_after 302 if created_before: 303 if not closed_after: 304 closed_after = "1970-01-01T00:00:00Z" 305 params["time"] = f"{closed_after},{created_before}" 306 if only_open: 307 params["open"] = 1 308 if only_closed: 309 params["closed"] = 1 310 311 if params: 312 uri += "?" + urllib.parse.urlencode(params) 313 314 data = self._session._get(uri) 315 changesets = cast(list[Element], dom.OsmResponseToDom(data, tag="changeset")) 316 result: dict[int, dict[str, Any]] = {} 317 for cur_changeset in changesets: 318 tmp_cs = dom.dom_parse_changeset(cur_changeset) 319 result[tmp_cs["id"]] = tmp_cs 320 return result
Returns a dict with the id of the changeset as key matching all criteria.
All parameters are optional. The bounding box is only applied if all
four of min_lon, min_lat, max_lon and max_lat are given.
If only some of the bounding box values are given,
ValueError is raised.
322 def changeset_comment( 323 self: "OsmApi", changeset_id: int, comment: str 324 ) -> dict[str, Any]: 325 """ 326 Adds a comment to the changeset `changeset_id`. 327 328 `comment` should be a string. 329 330 Returns the updated changeset data dict. 331 332 If no session is provided to authenticate the request, 333 `OsmApi.AuthenticationMissingError` is raised. 334 335 If the changeset is already closed, 336 `OsmApi.ChangesetClosedApiError` is raised. 337 """ 338 params = urllib.parse.urlencode({"text": comment}) 339 try: 340 data = self._session._post( 341 f"/api/0.6/changeset/{changeset_id}/comment", 342 params, 343 forceAuth=True, 344 ) 345 except errors.ApiError as e: 346 if e.status == 409: 347 raise errors.ChangesetClosedApiError( 348 e.status, e.reason, e.payload 349 ) from e 350 else: 351 raise 352 changeset = cast( 353 Element, 354 dom.OsmResponseToDom(data, tag="changeset", single=True), 355 ) 356 return dom.dom_parse_changeset(changeset, include_discussion=False)
Adds a comment to the changeset changeset_id.
comment should be a string.
Returns the updated changeset data dict.
If no session is provided to authenticate the request,
OsmApi.AuthenticationMissingError is raised.
If the changeset is already closed,
OsmApi.ChangesetClosedApiError is raised.
358 def changeset_subscribe(self: "OsmApi", changeset_id: int) -> dict[str, Any]: 359 """ 360 Subscribe to the changeset `changeset_id`. 361 362 Returns the updated changeset data dict. 363 364 If no session is provided to authenticate the request, 365 `OsmApi.AuthenticationMissingError` is raised. 366 367 If already subscribed to this changeset, 368 `OsmApi.AlreadySubscribedApiError` is raised. 369 """ 370 try: 371 data = self._session._post( 372 f"/api/0.6/changeset/{changeset_id}/subscribe", 373 None, 374 forceAuth=True, 375 ) 376 except errors.ApiError as e: 377 if e.status == 409: 378 raise errors.AlreadySubscribedApiError( 379 e.status, e.reason, e.payload 380 ) from e 381 else: 382 raise 383 changeset = cast( 384 Element, 385 dom.OsmResponseToDom(data, tag="changeset", single=True), 386 ) 387 return dom.dom_parse_changeset(changeset, include_discussion=False)
Subscribe to the changeset changeset_id.
Returns the updated changeset data dict.
If no session is provided to authenticate the request,
OsmApi.AuthenticationMissingError is raised.
If already subscribed to this changeset,
OsmApi.AlreadySubscribedApiError is raised.
389 def changeset_unsubscribe(self: "OsmApi", changeset_id: int) -> dict[str, Any]: 390 """ 391 Unsubscribe from the changeset `changeset_id`. 392 393 Returns the updated changeset data dict. 394 395 If no session is provided to authenticate the request, 396 `OsmApi.AuthenticationMissingError` is raised. 397 398 If not subscribed to this changeset, 399 `OsmApi.NotSubscribedApiError` is raised. 400 """ 401 try: 402 data = self._session._post( 403 f"/api/0.6/changeset/{changeset_id}/unsubscribe", 404 None, 405 forceAuth=True, 406 ) 407 except errors.ApiError as e: 408 if e.status == 404: 409 raise errors.NotSubscribedApiError(e.status, e.reason, e.payload) from e 410 else: 411 raise 412 changeset = cast( 413 Element, 414 dom.OsmResponseToDom(data, tag="changeset", single=True), 415 ) 416 return dom.dom_parse_changeset(changeset, include_discussion=False)
Unsubscribe from the changeset changeset_id.
Returns the updated changeset data dict.
If no session is provided to authenticate the request,
OsmApi.AuthenticationMissingError is raised.
If not subscribed to this changeset,
OsmApi.NotSubscribedApiError is raised.