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