Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,15 @@ See the `API reference
<https://dateparser.readthedocs.io/en/latest/dateparser.html#dateparser.search.search_dates>`__
for other parameters, such as ``strategy``.

``search_first_date`` stops at the first date, which is faster when you only
need that one:

.. code:: python

>>> from dateparser.search import search_first_date
>>> search_first_date('Launched on 4 October 1957, it fell on 4 January 1958.')
('on 4 October 1957', datetime.datetime(1957, 10, 4, 0, 0))

Time Span Detection
-------------------

Expand Down
46 changes: 46 additions & 0 deletions dateparser/search/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,49 @@ def search_dates(
if add_detected_language:
return [(*date[:2], language) for date in dates]
return [date[:2] for date in dates]


def search_first_date(
text: str,
languages: Iterable[str] | None = None,
settings: Settings | dict[str, Any] | None = None,
add_detected_language: bool = False,
detect_languages_function: Callable[..., list[str]] | None = None,
strategy: str = "split",
date_formats: Iterable[str] | None = None,
add_period: bool = False,
) -> (
tuple[str, datetime]
| tuple[str, datetime, str | None]
| tuple[str, datetime, str | None, str | None]
| None
):
"""Return the first item that :func:`search_dates` would return for the
same arguments, or ``None`` if it would return ``None``.

.. versionadded:: VERSION

The search stops at the first date found, so it is faster than
:func:`search_dates` on text with more dates after the first one.

>>> from dateparser.search import search_first_date
>>> search_first_date('Launched on 4 October 1957, it fell on 4 January 1958.')
('on 4 October 1957', datetime.datetime(1957, 10, 4, 0, 0))
"""
text = _search_with_detection.preprocess_text(text, languages)

language, dates = _search_with_detection._search_dates(
text=text,
languages=languages,
settings=settings,
detect_languages_function=detect_languages_function,
strategy=strategy,
date_formats=date_formats,
limit=1,
)
if not dates:
return None
date = dates[0] if add_period else dates[0][:2]
if add_detected_language:
return (*date, language)
return date
5 changes: 4 additions & 1 deletion dateparser/search/ngram_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ def search_parse(
text: str,
settings: Settings,
date_formats: Sequence[str] | None = None,
limit: int | None = None,
) -> list[_DateResult]:
"""Find all dates in ``text`` and return ``(substring, date, period)``
tuples.

``languages`` are tried in the given order for every candidate
n-gram.
n-gram. The scan stops once ``limit`` dates are found.

Text that matches ``date_formats`` is found first, in a separate scan.
A longer n-gram may still contain such a match, e.g. to add a time to
Expand Down Expand Up @@ -161,6 +162,8 @@ def parse_words(words: list[str]) -> DateData | None:
date_data.period,
)
)
if len(results) == limit:
break
return results

def _scan(
Expand Down
19 changes: 16 additions & 3 deletions dateparser/search/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ def parse_found_objects( # noqa: PLR0912, PLR0915
settings: Settings,
language: Locale,
already_parsed: Sequence[tuple[DateData, bool]] = (),
limit: int | None = None,
) -> tuple[list[tuple[DateData, bool]], list[str]]:
parsed: list[tuple[DateData, bool]] = list(already_parsed)
substrings = []
Expand All @@ -616,6 +617,8 @@ def parse_found_objects( # noqa: PLR0912, PLR0915
if settings.RELATIVE_BASE:
need_relative_base = False
for i, item in enumerate(to_parse):
if limit is not None and len(parsed) >= limit:
break
if len(item) <= 2:
continue

Expand Down Expand Up @@ -710,7 +713,11 @@ def parse_found_objects( # noqa: PLR0912, PLR0915
return parsed[len(already_parsed) :], substrings

def search_parse(
self, shortname: str, text: str, settings: Settings
self,
shortname: str,
text: str,
settings: Settings,
limit: int | None = None,
) -> list[_DateResult]:
language = self.get_current_language(shortname)
translated, original = self.search(shortname, text, settings)
Expand All @@ -733,6 +740,7 @@ def search_parse(
translated=translated,
settings=settings,
language=language,
limit=limit,
)

skip = {word.lower() for word in language.info.get("skip", [])}
Expand Down Expand Up @@ -896,6 +904,7 @@ def _search_dates(
detect_languages_function: Callable[..., list[str]] | None = None,
strategy: str = "split",
date_formats: Iterable[str] | None = None,
limit: int | None = None,
) -> tuple[str | None, list[_DateResult] | None]:
if strategy not in ("split", "ngram"):
raise ValueError(
Expand Down Expand Up @@ -926,14 +935,18 @@ def _search_dates(

if strategy == "ngram":
dates = self.ngram_search.search_parse(
candidate_languages, text, settings=settings, date_formats=date_formats
candidate_languages,
text,
settings=settings,
date_formats=date_formats,
limit=limit,
)
_add_time_span_results(dates, text, settings)
return language_shortname, dates

for candidate_language in candidate_languages:
dates = self.search.search_parse(
candidate_language, text, settings=settings
candidate_language, text, settings=settings, limit=limit
)
if dates:
return candidate_language, dates
Expand Down
63 changes: 63 additions & 0 deletions tests/benchmarks/test_benchmark_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

from dateparser.search import search_dates, search_first_date

if TYPE_CHECKING:
from datetime import datetime

from pytest_codspeed import BenchmarkFixture

# The language is given so that language detection, which both functions run
# the same way, does not dilute the difference between them.
LANGUAGES = ["en"]

TEXT = (
"The first artificial Earth satellite was launched on 4 October 1957. "
"It orbited for three weeks before its batteries died on 26 October 1957, "
"and it burned up on re-entry on 4 January 1958. The second one followed "
"on 3 November 1957, carrying a dog, and the American Explorer 1 reached "
"orbit on 1 February 1958, after a failed attempt on 6 December 1957."
)

STRATEGIES = pytest.mark.parametrize("strategy", ["split", "ngram"])


@STRATEGIES
def test_search_dates(benchmark: BenchmarkFixture, strategy: str) -> None:
def run() -> (
list[tuple[str, datetime]]
| list[tuple[str, datetime, str | None]]
| list[tuple[str, datetime, str | None, str | None]]
| None
):
return search_dates(TEXT, languages=LANGUAGES, strategy=strategy)

# Dates after the first one are what search_first_date() gets to skip.
# Running the search here also loads the locale data and warms the parser
# caches outside the measurement.
dates = run()
assert dates is not None
assert len(dates) > 1

benchmark(run)


@STRATEGIES
def test_search_first_date(benchmark: BenchmarkFixture, strategy: str) -> None:
def run() -> (
tuple[str, datetime]
| tuple[str, datetime, str | None]
| tuple[str, datetime, str | None, str | None]
| None
):
return search_first_date(TEXT, languages=LANGUAGES, strategy=strategy)

date = run()
assert date is not None
assert date[0].endswith("4 October 1957")

benchmark(run)
72 changes: 71 additions & 1 deletion tests/test_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from dateparser.conf import Settings, apply_settings
from dateparser.date import DateDataParser
from dateparser.search import search_dates
from dateparser.search import search_dates, search_first_date
from dateparser.search.search import DateSearchWithDetection
from dateparser.timezone_parser import StaticTzInfo
from dateparser_data.settings import default_parsers
Expand Down Expand Up @@ -3032,3 +3032,73 @@ def test_date_formats_string_raises_error(self) -> None:
def test_unknown_strategy_raises_error(self) -> None:
with self.assertRaisesRegex(ValueError, "strategy must be"):
search_dates("4 October 1957", languages=["en"], strategy="unknown")


class TestSearchFirstDate(BaseTestCase):
@parameterized.expand(
[
param(
text, languages, settings, strategy, add_detected_language, add_period
)
for text, languages, settings in [
(
"Launched on 4 October 1957, it fell on 4 January 1958.",
["en"],
None,
),
(
"19 марта 2001 был хороший день. 20 марта тоже был хороший день.",
["en", "ru"],
None,
),
(
"Em outubro de 1936, Alemanha e Itália formaram o Eixo Roma-Berlim.",
None,
{"RELATIVE_BASE": datetime.datetime(2000, 1, 1)},
),
(
"Messages from the past month",
["en"],
{
"RETURN_TIME_SPAN": True,
"RELATIVE_BASE": datetime.datetime(2025, 2, 15),
},
),
("Hello world", ["en"], None),
]
for strategy in ("split", "ngram")
for add_detected_language in (False, True)
for add_period in (False, True)
]
)
def test_matches_first_search_dates_result(
self,
text: str,
languages: list[str],
settings: dict[str, Any] | None,
strategy: str,
add_detected_language: bool,
add_period: bool,
) -> None:
kwargs: dict[str, Any] = {
"languages": languages,
"settings": settings,
"strategy": strategy,
"add_detected_language": add_detected_language,
"add_period": add_period,
}
expected = search_dates(text, **kwargs)
self.assertEqual(
search_first_date(text, **kwargs), expected[0] if expected else None
)

def test_date_formats(self) -> None:
text = "Launched on 04/10/1957, it fell on 04/01/1958."
kwargs: dict[str, Any] = {
"languages": ["en"],
"strategy": "ngram",
"date_formats": ["%d/%m/%Y"],
}
expected = search_dates(text, **kwargs)
assert expected is not None
self.assertEqual(search_first_date(text, **kwargs), expected[0])
Loading