jenkins-bot has submitted this change. ( 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1343973?usp=email )

Change subject: [FEAT] Add geographic page searches to Site
......................................................................

[FEAT] Add geographic page searches to Site

Expose GeoData's list=geosearch through Site.geosearch(), accepting
coordinates, a reference page or a bounding box. Yield search records
with coordinate metadata and distances, preserving the API's order.

Use one request because GeoSearch has no continuation, keeping the
requested limit independent of config.step. Reuse request serialization
and namespace resolution, and propagate API errors including missing
reference-page coordinates. Reject malformed result lists before
yielding records so failures cannot appear to be empty searches.

Bug: T438772
Change-Id: Ie69dfb7dde45f8e5fe74a6832a9c64f2e5f975b2
---
M pywikibot/site/_extensions.py
M tests/dry_site_tests.py
2 files changed, 237 insertions(+), 2 deletions(-)

Approvals:
  jenkins-bot: Verified
  Xqt: Looks good to me, approved




diff --git a/pywikibot/site/_extensions.py b/pywikibot/site/_extensions.py
index e681a46..e2f9dbd 100644
--- a/pywikibot/site/_extensions.py
+++ b/pywikibot/site/_extensions.py
@@ -9,7 +9,7 @@
 import re
 from collections.abc import Generator, Iterable
 from ipaddress import ip_network
-from typing import TYPE_CHECKING, Protocol
+from typing import TYPE_CHECKING, Any, Protocol

 import pywikibot
 from pywikibot.data import api
@@ -23,6 +23,7 @@
     UnexpectedAPIDataError,
 )
 from pywikibot.site._decorators import need_extension, need_right
+from pywikibot.site._namespace import NamespaceArgType
 from pywikibot.tools import merge_unique_dicts


@@ -208,6 +209,114 @@
     """APISite mixin for GeoData extension."""

     @need_extension('GeoData')
+    def geosearch(
+        self: BaseSiteProtocol,
+        *,
+        coord: tuple[float, float] | None = None,
+        page: pywikibot.page.BasePage | str | None = None,
+        bbox: tuple[float, float, float, float] | None = None,
+        radius: int | None = None,
+        namespaces: NamespaceArgType = 0,
+        total: int | None = 10,
+    ) -> Generator[dict[str, Any]]:
+        """Yield geographic search records from the GeoData extension.
+
+        Specify exactly one of *coord*, *page* or *bbox*. Results retain
+        the API's order and metadata, including ``pageid``, ``ns``,
+        ``title``, ``lat``, ``lon``, ``dist`` and ``primary``. Additional
+        coordinate properties are included where available. Distances
+        are in metres, measured from the search point or box centre.
+
+        Only primary coordinates are searched, using the site's default
+        globe (normally Earth). When searching around a page, that page
+        is excluded from the results.
+
+        GeoSearch does not support continuation. The API caps the result
+        count (normally 500, or 5000 with the ``apihighlimits`` right), so
+        this method cannot enumerate every page in a densely mapped area.
+        The radius and bounding-box size are also limited by the site.
+
+        For example, search within 5 km of a point in Lagos:
+
+        .. code-block:: python
+
+           for result in site.geosearch(coord=(6.455, 3.3841), radius=5000):
+               print(result['title'], result['dist'])
+
+        .. version-added:: 11.8
+        .. seealso:: :api:`Geosearch`
+
+        :param coord: Search centre as ``(latitude, longitude)``.
+        :param page: Title or page on this site whose primary coordinates
+            define the search centre. The page must have coordinates.
+        :param bbox: Bounding box as ``(north, west, south, east)``.
+        :param radius: Search radius in metres for *coord* or *page*.
+            If None, use the site's default (normally 500 metres).
+            Cannot be combined with *bbox*.
+        :param namespaces: Namespace identifiers or names, optionally
+            separated by ``|``. Defaults to the main namespace.
+            None or an empty iterable searches all namespaces.
+        :param total: Maximum records to yield, subject to the API limit.
+            None requests the API maximum; nonpositive values yield none.
+        :raises ValueError: Conflicting or malformed search inputs.
+        :raises RuntimeError: The reference page belongs to another site.
+        :raises UnknownExtensionError: GeoData is not installed.
+        :raises APIError: The reference page is missing or has no
+            coordinates (``no-coordinates``), or the API rejects the query.
+        :raises UnexpectedAPIDataError: The response does not contain a
+            list of search records.
+        """
+        if sum(value is not None for value in (coord, page, bbox)) != 1:
+            raise ValueError('Specify exactly one of coord, page or bbox.')
+        if bbox is not None and radius is not None:
+            raise ValueError('radius cannot be combined with bbox.')
+
+        parameters: dict[str, Any] = {
+            'action': 'query',
+            'list': 'geosearch',
+            'gslimit': 'max' if total is None else total,
+            'gsprop': ['type', 'name', 'dim', 'country', 'region', 'globe'],
+            'formatversion': 2,
+        }
+        if coord is not None:
+            if len(coord) != 2:
+                raise ValueError('coord must contain latitude and longitude.')
+            parameters['gscoord'] = coord
+        elif bbox is not None:
+            if len(bbox) != 4:
+                raise ValueError('bbox must contain north, west, south, east.')
+            parameters['gsbbox'] = bbox
+        else:
+            parameters['gspage'] = page
+
+        if radius is not None:
+            parameters['gsradius'] = radius
+        if isinstance(namespaces, str):
+            namespaces = namespaces.split('|')
+        parameters['gsnamespace'] = (
+            [ns.id for ns in self.namespaces.resolve(namespaces)]
+            if namespaces is not None else []
+        ) or '*'
+
+        if total is not None and total <= 0:
+            return
+
+        # This module has no continuation; a smaller batch loses results.
+        request = self.simple_request(**parameters)
+        data = request.submit()
+        try:
+            records = data['query']['geosearch']
+        except (KeyError, TypeError) as e:
+            raise UnexpectedAPIDataError(
+                'GeoSearch response is missing the search result list') from e
+        if not isinstance(records, list) or any(
+            not isinstance(record, dict) for record in records
+        ):
+            raise UnexpectedAPIDataError(
+                'GeoSearch response must contain a list of search records')
+        yield from records
+
+    @need_extension('GeoData')
     def loadcoordinfo(self: BaseSiteProtocol, page) -> None:
         """Load [[mw:Extension:GeoData]] info."""
         title = page.title(with_section=False)
diff --git a/tests/dry_site_tests.py b/tests/dry_site_tests.py
index aebe291..956ca41 100755
--- a/tests/dry_site_tests.py
+++ b/tests/dry_site_tests.py
@@ -12,8 +12,15 @@

 import pywikibot
 from pywikibot.comms.http import user_agent, user_agent_username
+from pywikibot.data.api import Request
+from pywikibot.exceptions import (
+    APIError,
+    UnexpectedAPIDataError,
+    UnknownExtensionError,
+)
 from pywikibot.tools import suppress_warnings
-from tests.aspects import DefaultSiteTestCase
+from tests.aspects import DefaultSiteTestCase, TestCase
+from tests.utils import DrySite


 class TestDrySite(DefaultSiteTestCase):
@@ -148,5 +155,124 @@
             res, user_agent(x, format_string='Foo ({script_comments})'))


+class TestGeoSearch(TestCase):
+
+    """Test geographic searches without relying on changing map data."""
+
+    family = 'wikipedia'
+    code = 'en'
+    dry = True
+
+    def test_geosearch(self) -> None:
+        """Preserve geographic records for each search input mode."""
+        records = [
+            {'pageid': 2, 'ns': 0, 'title': 'Nearby', 'lat': 6.46,
+             'lon': 3.38, 'dist': 500.0, 'primary': True, 'country': 'NG'},
+            {'pageid': 1, 'ns': 0, 'title': 'Further', 'lat': 6.47,
+             'lon': 3.38, 'dist': 1500.0, 'primary': True},
+        ]
+        box = (6.48, 3.36, 6.43, 3.41)
+        cases = (
+            ({'coord': (6.455, 3.3841), 'radius': 5000},
+             {'gscoord': '6.455|3.3841', 'gsradius': '5000'}),
+            ({'page': 'Lagos'}, {'gspage': 'Lagos'}),
+            ({'page': pywikibot.Page(self.site, 'Lagos#History')},
+             {'gspage': 'Lagos'}),
+            ({'bbox': box}, {'gsbbox': '6.48|3.36|6.43|3.41'}),
+        )
+        with (
+            patch.object(self.site, 'has_extension', return_value=True),
+            patch.object(Request, 'submit', autospec=True) as submit,
+            patch.object(pywikibot.config, 'step', 1),
+        ):
+            submit.return_value = {
+                'query': {'geosearch': records},
+            }
+            for inputs, parameters in cases:
+                with self.subTest(inputs=inputs):
+                    submit.reset_mock()
+                    results = list(self.site.geosearch(
+                        **inputs, namespaces='0|Talk', total=2))
+                    self.assertEqual(results, records)
+                    submit.assert_called_once()
+                    request = submit.call_args.args[0]
+                    self.assertEqual(request._encoded_items(), {
+                        'action': 'query', 'list': 'geosearch', 'gslimit': '2',
+                        'gsprop': 'type|name|dim|country|region|globe',
+                        'gsnamespace': '0|1', 'formatversion': '2',
+                        **parameters,
+                    })
+
+    def test_geosearch_empty_and_limits(self) -> None:
+        """Handle an empty response and explicit result limits."""
+        with (
+            patch.object(self.site, 'has_extension', return_value=True),
+            patch.object(Request, 'submit', autospec=True) as submit,
+        ):
+            submit.return_value = {'query': {'geosearch': []}}
+            self.assertIsEmpty(list(self.site.geosearch(
+                coord=(0, 0), namespaces=None, total=None)))
+            parameters = submit.call_args.args[0]._encoded_items()
+            self.assertEqual(parameters['gslimit'], 'max')
+            self.assertEqual(parameters['gsnamespace'], '*')
+
+            submit.reset_mock()
+            self.assertIsEmpty(list(self.site.geosearch(
+                coord=(0, 0), total=0)))
+            submit.assert_not_called()
+
+    def test_geosearch_errors(self) -> None:
+        """Reject invalid inputs and propagate unavailable search errors."""
+        with self.assertRaisesRegex(UnknownExtensionError, 'GeoData'):
+            list(self.site.geosearch(coord=(0, 0)))
+
+        cases = (
+            ({}, 'exactly one'),
+            ({'coord': (0, 0), 'page': 'Lagos'}, 'exactly one'),
+            ({'coord': (0,)}, 'latitude and longitude'),
+            ({'bbox': (0, 0)}, 'north, west, south, east'),
+            ({'bbox': (1, 0, 0, 1), 'radius': 500}, 'combined with bbox'),
+        )
+        with (
+            patch.object(self.site, 'has_extension', return_value=True),
+            patch.object(Request, 'submit', autospec=True) as submit,
+        ):
+            for inputs, message in cases:
+                with self.subTest(inputs=inputs):
+                    with self.assertRaisesRegex(ValueError, message):
+                        list(self.site.geosearch(**inputs))
+            submit.assert_not_called()
+
+            other_page = pywikibot.Page(
+                DrySite('de', 'wikipedia', None), 'Lagos')
+            submit.side_effect = Request._encoded_items
+            with self.assertRaisesRegex(RuntimeError,
+                                        'different from Request.site'):
+                list(self.site.geosearch(page=other_page))
+
+            submit.side_effect = APIError(
+                'no-coordinates', 'The reference page has no coordinates')
+            with self.assertRaisesRegex(APIError, 'no-coordinates'):
+                list(self.site.geosearch(page='Lagos'))
+
+    def test_geosearch_malformed_response(self) -> None:
+        """Reject malformed responses before yielding any records."""
+        responses = (
+            {'query': {}},
+            {'query': {'geosearch': {'unexpected': 'object'}}},
+            {'query': {'geosearch': [{'title': 'Nearby'}, 'unexpected']}},
+        )
+        with (
+            patch.object(self.site, 'has_extension', return_value=True),
+            patch.object(Request, 'submit', autospec=True) as submit,
+        ):
+            for response in responses:
+                with self.subTest(response=response):
+                    submit.return_value = response
+                    with self.assertRaisesRegex(UnexpectedAPIDataError,
+                                                'GeoSearch response'):
+                        next(self.site.geosearch(coord=(0, 0)))
+
+
 if __name__ == '__main__':
     unittest.main()

--
To view, visit 
https://gerrit.wikimedia.org/r/c/pywikibot/core/+/1343973?usp=email
To unsubscribe, or for help writing mail filters, visit 
https://gerrit.wikimedia.org/r/settings?usp=email

Gerrit-MessageType: merged
Gerrit-Project: pywikibot/core
Gerrit-Branch: master
Gerrit-Change-Id: Ie69dfb7dde45f8e5fe74a6832a9c64f2e5f975b2
Gerrit-Change-Number: 1343973
Gerrit-PatchSet: 1
Gerrit-Owner: Mahveotm <[email protected]>
Gerrit-Reviewer: Xqt <[email protected]>
Gerrit-Reviewer: jenkins-bot
_______________________________________________
Pywikibot-commits mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to