Skip to content

Commit eb59627

Browse files
authored
fix(search): keep a limit the caller wrote into the ask query
Merge pull request #161 from OpenSemanticLab/fix/semantic-search-respect-query-limit
2 parents a51dc84 + 6e94187 commit eb59627

2 files changed

Lines changed: 182 additions & 4 deletions

File tree

‎src/osw/wiki_tools.py‎

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import getpass
2+
import re
23
import warnings
34
from typing import Dict, List, Optional, Tuple, Union
45

@@ -137,6 +138,9 @@ class SearchParam(OswBaseModel):
137138
parallel: Optional[bool] = None # is set to true if query is a list longer than 5
138139
debug: Optional[bool] = False
139140
limit: Optional[int] = 1000
141+
"""the result limit to apply, None to apply none and leave it to the wiki.
142+
Ignored by semantic_search for a query that sets 'limit=' itself, since SMW
143+
honours the last limit in the query string"""
140144
return_json: Optional[bool] = False
141145

142146
def __init__(self, **data):
@@ -244,6 +248,36 @@ def _ask_results_as_dict(results: Union[dict, list]) -> dict:
244248
}
245249

246250

251+
CONDITION_PATTERN = re.compile(r"\[\[.*?\]\]", re.DOTALL)
252+
"""matches an SMW query condition, which may contain '|' as the disjunction
253+
operator and must therefore be removed before the parameters are split"""
254+
LIMIT_PARAM_PATTERN = re.compile(r"^limit\s*=\s*(\d+)$", re.IGNORECASE)
255+
"""matches a limit parameter of an SMW query, e.g. 'limit=100'"""
256+
257+
258+
def get_query_limit(query: str) -> Optional[int]:
259+
"""Returns the limit an SMW ask query sets itself, None if it sets none
260+
261+
Parameters
262+
----------
263+
query :
264+
an SMW ask query string, e.g. '[[Category:Item]]|?Name|limit=2'
265+
266+
Returns
267+
-------
268+
result:
269+
the limit the query asks for, or None if the query does not set one or
270+
sets one that is not a number
271+
"""
272+
limit = None
273+
for parameter in CONDITION_PATTERN.sub("", query).split("|"):
274+
match = LIMIT_PARAM_PATTERN.match(parameter.strip())
275+
if match:
276+
# SMW honours the last limit in the query string
277+
limit = int(match.group(1))
278+
return limit
279+
280+
247281
def semantic_search(
248282
site: mwclient.client.Site, query: Union[str, List[str], SearchParam]
249283
) -> Union[List[str], List[dict]]:
@@ -254,7 +288,9 @@ def semantic_search(
254288
site :
255289
Site object from mwclient lib
256290
query :
257-
(List of) query text(s) or instance of SearchParam
291+
(List of) query text(s) or instance of SearchParam. A query that sets
292+
``limit=`` itself keeps that limit; ``SearchParam.limit`` is only
293+
appended to a query that does not, and only when it is not None.
258294
259295
Returns
260296
-------
@@ -268,7 +304,14 @@ def semantic_search(
268304

269305
def semantic_search_(single_query):
270306
page_list = list()
271-
single_query += f"|limit={query.limit}"
307+
# SMW honours the last limit in the query string, so appending the
308+
# default would silently override a limit the caller wrote themselves.
309+
# A SearchParam limit of None asks for no limit at all, leaving the
310+
# wiki to apply its own default
311+
limit = get_query_limit(single_query)
312+
if limit is None and query.limit is not None:
313+
limit = query.limit
314+
single_query += f"|limit={limit}"
272315
result = site.api("ask", query=single_query, format="json")
273316
results = _ask_results_as_dict(result["query"]["results"])
274317
n = len(results)
@@ -277,10 +320,13 @@ def semantic_search_(single_query):
277320
print(f"Query '{single_query}' returned no results")
278321
else:
279322
print(f"Query '{single_query}' returned {n} results")
280-
if n >= query.limit:
323+
# No limit in force, or 'limit=0' asking for no results at all as a
324+
# count format does, means the result count says nothing about
325+
# truncation
326+
if limit and n >= limit:
281327
warnings.warn(
282328
f"Query '{single_query}' returned {n} results, which meets the "
283-
f"requested limit of {query.limit}. Results are truncated - raise "
329+
f"requested limit of {limit}. Results are truncated - raise "
284330
f"the limit or page through with '|offset=' to retrieve the "
285331
f"remainder."
286332
)

‎tests/test_wiki_tools.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,138 @@ def test_semantic_search_exists_drop_warning():
207207
assert out == ["Item:OSW1"]
208208

209209

210+
@pytest.mark.parametrize(
211+
"query, expected",
212+
[
213+
("[[HasType::Category:Item]]", None),
214+
("[[HasType::Category:Item]]|limit=2", 2),
215+
("[[HasType::Category:Item]]|?Name|limit=2|offset=5", 2),
216+
("[[HasType::Category:Item]]| limit = 2 ", 2),
217+
("[[HasType::Category:Item]]|Limit=2", 2),
218+
# SMW honours the last one, so that is the effective limit
219+
("[[HasType::Category:Item]]|limit=2|limit=7", 7),
220+
# a limit inside a condition is a value, not a parameter
221+
("[[HasText::limit=2]]", None),
222+
("[[HasText::a||limit=2]]", None),
223+
# a printout named 'limit', not the parameter
224+
("[[HasType::Category:Item]]|?limit", None),
225+
# a printout parameter, which limits that printout and not the query
226+
("[[HasType::Category:Item]]|?Has subobject|+limit=3", None),
227+
# not a number, so there is no limit to honour
228+
("[[HasType::Category:Item]]|limit=all", None),
229+
],
230+
)
231+
def test_get_query_limit(query, expected):
232+
assert wt.get_query_limit(query) == expected
233+
234+
235+
def test_semantic_search_appends_the_default_limit():
236+
site = MagicMock()
237+
site.api.return_value = _ask_result("Item:OSW1")
238+
239+
wt.semantic_search(site, "[[HasType::Category:Item]]")
240+
241+
assert site.api.call_args.kwargs["query"] == (
242+
"[[HasType::Category:Item]]|limit=1000"
243+
)
244+
245+
246+
def test_semantic_search_keeps_a_limit_the_caller_wrote():
247+
"""Appending the default would override it, since SMW honours the last one."""
248+
site = MagicMock()
249+
site.api.return_value = _ask_result("Item:OSW1")
250+
251+
wt.semantic_search(site, "[[HasType::Category:Item]]|limit=2")
252+
253+
assert site.api.call_args.kwargs["query"] == "[[HasType::Category:Item]]|limit=2"
254+
255+
256+
def test_semantic_search_query_limit_beats_the_search_param_limit():
257+
site = MagicMock()
258+
site.api.return_value = _ask_result("Item:OSW1")
259+
260+
wt.semantic_search(
261+
site, wt.SearchParam(query="[[HasType::Category:Item]]|limit=2", limit=500)
262+
)
263+
264+
assert site.api.call_args.kwargs["query"] == "[[HasType::Category:Item]]|limit=2"
265+
266+
267+
def test_semantic_search_truncation_warning_uses_the_query_limit():
268+
"""The caller's limit is the one the results were truncated at."""
269+
titles = [f"Item:OSW{i}" for i in range(2)]
270+
site = MagicMock()
271+
site.api.return_value = _ask_result(*titles)
272+
273+
with pytest.warns(UserWarning, match="requested limit of 2"):
274+
out = wt.semantic_search(site, "[[HasType::Category:Item]]|limit=2")
275+
276+
assert sorted(out) == sorted(titles)
277+
278+
279+
def test_semantic_search_limit_none_sends_no_limit():
280+
"""None asks for no limit at all, leaving the wiki to apply its own."""
281+
site = MagicMock()
282+
site.api.return_value = _ask_result("Item:OSW1")
283+
284+
wt.semantic_search(
285+
site, wt.SearchParam(query="[[HasType::Category:Item]]", limit=None)
286+
)
287+
288+
assert site.api.call_args.kwargs["query"] == "[[HasType::Category:Item]]"
289+
290+
291+
def test_semantic_search_limit_none_keeps_a_limit_the_caller_wrote():
292+
site = MagicMock()
293+
site.api.return_value = _ask_result("Item:OSW1")
294+
295+
wt.semantic_search(
296+
site, wt.SearchParam(query="[[HasType::Category:Item]]|limit=2", limit=None)
297+
)
298+
299+
assert site.api.call_args.kwargs["query"] == "[[HasType::Category:Item]]|limit=2"
300+
301+
302+
def test_semantic_search_limit_none_does_not_warn_about_truncation():
303+
"""With no limit in force the result count says nothing about truncation."""
304+
titles = [f"Item:OSW{i}" for i in range(5)]
305+
site = MagicMock()
306+
site.api.return_value = _ask_result(*titles)
307+
308+
with warnings.catch_warnings(record=True) as caught:
309+
warnings.simplefilter("always")
310+
out = wt.semantic_search(
311+
site, wt.SearchParam(query="[[HasType::Category:Item]]", limit=None)
312+
)
313+
314+
assert not any("truncated" in str(w.message) for w in caught)
315+
assert sorted(out) == sorted(titles)
316+
317+
318+
def test_semantic_search_no_truncation_warning_for_a_zero_limit():
319+
"""'limit=0' asks for no results, so meeting it is not truncation."""
320+
site = MagicMock()
321+
site.api.return_value = _ask_result_empty()
322+
323+
with warnings.catch_warnings(record=True) as caught:
324+
warnings.simplefilter("always")
325+
wt.semantic_search(site, "[[HasType::Category:Item]]|limit=0")
326+
327+
assert not any("truncated" in str(w.message) for w in caught)
328+
329+
330+
def test_semantic_search_no_truncation_warning_below_the_query_limit():
331+
titles = [f"Item:OSW{i}" for i in range(2)]
332+
site = MagicMock()
333+
site.api.return_value = _ask_result(*titles)
334+
335+
with warnings.catch_warnings(record=True) as caught:
336+
warnings.simplefilter("always")
337+
wt.semantic_search(site, "[[HasType::Category:Item]]|limit=50")
338+
339+
assert not any("truncated" in str(w.message) for w in caught)
340+
341+
210342
def _prefixsearch_result(*titles):
211343
"""Build a minimal MediaWiki ``prefixsearch`` API result dict."""
212344
return {

0 commit comments

Comments
 (0)