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)
class ChangesetMixin:
 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.

@contextmanager
def changeset( self: osmapi.OsmApi.OsmApi, changeset_tags: Optional[dict[str, str]] = None) -> Generator[int, NoneType, NoneType]:
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.

def changeset_get( self: osmapi.OsmApi.OsmApi, changeset_id: int, include_discussion: bool = False) -> dict[str, typing.Any]:
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.

def changeset_update( self: osmapi.OsmApi.OsmApi, changeset_tags: Optional[dict[str, str]] = None) -> int:
 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.

def changeset_create( self: osmapi.OsmApi.OsmApi, changeset_tags: Optional[dict[str, str]] = None) -> int:
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.

def changeset_close(self: osmapi.OsmApi.OsmApi) -> int:
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.

def changeset_upload( self: osmapi.OsmApi.OsmApi, changes_data: list[dict[str, typing.Any]]) -> list[dict[str, typing.Any]]:
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.

def changeset_download( self: osmapi.OsmApi.OsmApi, changeset_id: int) -> list[dict[str, typing.Any]]:
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.

def changesets_get( self: osmapi.OsmApi.OsmApi, min_lon: Optional[float] = None, min_lat: Optional[float] = None, max_lon: Optional[float] = None, max_lat: Optional[float] = None, userid: Optional[int] = None, username: Optional[str] = None, closed_after: Optional[str] = None, created_before: Optional[str] = None, only_open: bool = False, only_closed: bool = False) -> dict[int, dict[str, typing.Any]]:
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.

def changeset_comment( self: osmapi.OsmApi.OsmApi, changeset_id: int, comment: str) -> dict[str, typing.Any]:
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.

def changeset_subscribe(self: osmapi.OsmApi.OsmApi, changeset_id: int) -> dict[str, typing.Any]:
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.

def changeset_unsubscribe(self: osmapi.OsmApi.OsmApi, changeset_id: int) -> dict[str, typing.Any]:
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.