From 5d528d3663abee83d93e01707daa20507c54f99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CdeKibi=E2=80=9D?= Date: Thu, 30 Jul 2026 16:14:08 +0300 Subject: [PATCH 1/4] fix: parse embedded token amounts and parenthesized UTC times --- crypto_converter/crypto_amount_parser.py | 23 ++++- .../test_crypto_amount_parser.py | 90 +++++++++++++++++++ tests/time_converter/test_utc_time_parser.py | 23 +++++ time_converter/utc_time_parser.py | 8 +- 4 files changed, 138 insertions(+), 6 deletions(-) diff --git a/crypto_converter/crypto_amount_parser.py b/crypto_converter/crypto_amount_parser.py index 2e7888a..8c23c7f 100644 --- a/crypto_converter/crypto_amount_parser.py +++ b/crypto_converter/crypto_amount_parser.py @@ -26,7 +26,8 @@ rf"(?{NUMBER_LITERAL_REGEX})" rf"(?P{COMPACT_NUMBER_SUFFIX_REGEX})?\s+" r"(?P\$)?" - r"(?P(?:(?<=[\s$])[A-Za-z]|[A-Za-z]{2,10}))(?!\w)", + r"(?P(?:(?<=[\s$])[A-Za-z]|[A-Za-z]{2,10}))(?!\w)" + r"(?:\s+tokens?\b)?", flags=re.IGNORECASE, ) CRYPTO_AMOUNT_PREFIX_PATTERN: Final[re.Pattern[str]] = re.compile( @@ -41,6 +42,10 @@ TIMEZONE_OFFSET_SUFFIX_PATTERN: Final[re.Pattern[str]] = re.compile( r"[+-]\d", ) +TOKEN_SUFFIX_PATTERN: Final[re.Pattern[str]] = re.compile( + r"\s+tokens?\b", + flags=re.IGNORECASE, +) @dataclass(frozen=True) @@ -157,6 +162,15 @@ def _parse_amount_value(match: re.Match[str]) -> Decimal: return amount +def _get_crypto_reference_end(text: str, reference_end: int) -> int: + token_suffix_match = TOKEN_SUFFIX_PATTERN.match(text, reference_end) + + if token_suffix_match is None: + return reference_end + + return token_suffix_match.end() + + def _is_single_letter_reference_allowed( reference: str, is_dollar_prefixed: bool, @@ -257,16 +271,17 @@ def resolve_crypto_amounts_from_text( ): continue + match_end = _get_crypto_reference_end(text, coin_match.end) resolved_amounts.append( ResolvedCryptoAmount( amount=_parse_amount_value(amount_match), coin=coin_match.coin, - matched_text=text[amount_match.start():coin_match.end], + matched_text=text[amount_match.start():match_end], start=amount_match.start(), - end=coin_match.end, + end=match_end, ) ) - previous_match_end = coin_match.end + previous_match_end = match_end return resolved_amounts diff --git a/tests/crypto_converter/test_crypto_amount_parser.py b/tests/crypto_converter/test_crypto_amount_parser.py index 94d0a72..6c8d43c 100644 --- a/tests/crypto_converter/test_crypto_amount_parser.py +++ b/tests/crypto_converter/test_crypto_amount_parser.py @@ -29,6 +29,7 @@ ("1m BNB", Decimal("1000000"), "BNB", "1m BNB"), ("1kk K", Decimal("1000000"), "K", "1kk K"), ("100 000 BNB", Decimal("100000"), "BNB", "100 000 BNB"), + ("250 AEON tokens", Decimal("250"), "AEON", "250 AEON tokens"), ], ) def test_parse_crypto_amounts( @@ -45,6 +46,95 @@ def test_parse_crypto_amounts( assert parsed_amount.matched_text == expected_match +@pytest.mark.parametrize( + ("text", "expected_match"), + [ + ("250 AEON", "250 AEON"), + ("250 AEON token", "250 AEON token"), + ("250 AEON tokens", "250 AEON tokens"), + ], +) +def test_resolve_optional_token_suffix_after_ticker( + monkeypatch: pytest.MonkeyPatch, + text: str, + expected_match: str, +) -> None: + coin = ResolvedCoin("aeon", "AEON", "Aeon") + + def resolve_aeon_reference( + message_text: str, + start: int, + ) -> ResolvedCoinMatch | None: + if not message_text[start:].casefold().startswith("aeon"): + return None + + return ResolvedCoinMatch( + coin=coin, + matched_text=message_text[start:start + 4], + end=start + 4, + ) + + monkeypatch.setattr( + crypto_amount_parser, + "resolve_coin_reference_at", + resolve_aeon_reference, + ) + + resolved_amounts = resolve_crypto_amounts_from_text(text) + + assert len(resolved_amounts) == 1 + assert resolved_amounts[0].amount == Decimal("250") + assert resolved_amounts[0].coin.ticker == "AEON" + assert resolved_amounts[0].matched_text == expected_match + + +def test_resolve_crypto_amount_with_token_suffix_from_long_message( + monkeypatch: pytest.MonkeyPatch, +) -> None: + message_text = ( + "Binance Alpha is the first platform to feature AEON (AEON), with " + "Alpha debut and trading starting on July 27, 2026, at 10:00 (UTC). " + "\n\nUsers with at least 245 Binance Alpha Points can claim an " + "airdrop of 250 AEON tokens on a first-come, first-served basis. " + "If the reward pool is not fully distributed, the score threshold " + "will automatically decrease by 5 points every 5 minutes.\n\n" + "Please note that claiming the airdrop will consume 15 Binance " + "Alpha Points. Users must confirm their claim on the Alpha Events " + "page within 24 hours; otherwise, it will be deemed that users have " + "given up claiming the airdrop." + ) + coin = ResolvedCoin("aeon", "AEON", "Aeon") + + def resolve_aeon_reference( + message_text: str, + start: int, + ) -> ResolvedCoinMatch | None: + if not message_text[start:].casefold().startswith("aeon"): + return None + + return ResolvedCoinMatch( + coin=coin, + matched_text=message_text[start:start + 4], + end=start + 4, + ) + + monkeypatch.setattr( + crypto_amount_parser, + "resolve_top_ranked_coin_reference_at", + resolve_aeon_reference, + ) + + resolved_amounts = resolve_crypto_amounts_from_text( + message_text, + top_ranked_only=True, + ) + + assert len(resolved_amounts) == 1 + assert resolved_amounts[0].amount == Decimal("250") + assert resolved_amounts[0].coin.ticker == "AEON" + assert resolved_amounts[0].matched_text == "250 AEON tokens" + + @pytest.mark.parametrize( "text", [ diff --git a/tests/time_converter/test_utc_time_parser.py b/tests/time_converter/test_utc_time_parser.py index b72a5a9..999cfaf 100644 --- a/tests/time_converter/test_utc_time_parser.py +++ b/tests/time_converter/test_utc_time_parser.py @@ -17,6 +17,7 @@ ("10:00 UTC", "UTC", 10, 0), ("10:00UTC", "UTC", 10, 0), ("10 UTC", "UTC", 10, 0), + ("10:00 (UTC)", "UTC", 10, 0), ("10:30 cet", "CET", 10, 30), ("10 CEST", "CEST", 10, 0), ("10:45kyiv", "KYIV", 10, 45), @@ -94,6 +95,28 @@ def test_parse_utc_time_from_text_skips_non_utc_matches() -> None: assert parsed_datetime.minute == 0 +def test_parse_parenthesized_utc_time_from_long_message() -> None: + message_text = ( + "Binance Alpha is the first platform to feature AEON (AEON), with " + "Alpha debut and trading starting on July 27, 2026, at 10:00 (UTC). " + "\n\nUsers with at least 245 Binance Alpha Points can claim an " + "airdrop of 250 AEON tokens on a first-come, first-served basis. " + "If the reward pool is not fully distributed, the score threshold " + "will automatically decrease by 5 points every 5 minutes.\n\n" + "Please note that claiming the airdrop will consume 15 Binance " + "Alpha Points. Users must confirm their claim on the Alpha Events " + "page within 24 hours; otherwise, it will be deemed that users have " + "given up claiming the airdrop." + ) + + parsed_times = parse_times_from_text(message_text, limit=5) + + assert len(parsed_times) == 1 + assert parsed_times[0].timezone_label == "UTC" + assert parsed_times[0].source_datetime.hour == 10 + assert parsed_times[0].source_datetime.minute == 0 + + @pytest.mark.parametrize( "text", [ diff --git a/time_converter/utc_time_parser.py b/time_converter/utc_time_parser.py index ed3b918..fc8b638 100644 --- a/time_converter/utc_time_parser.py +++ b/time_converter/utc_time_parser.py @@ -13,7 +13,9 @@ # Time parsing NAMED_TIME_PATTERN: Final[re.Pattern[str]] = re.compile( r"(?(?:[01]?\d|2[0-3]))" - r"(?::(?P[0-5]\d))? ?(?PUTC|CEST|CET|KYIV)\b" + r"(?::(?P[0-5]\d))?" + r"(?: ?(?PUTC|CEST|CET|KYIV)\b" + r"| ?\((?PUTC|CEST|CET|KYIV)\))" r"(?![+-])", flags=re.IGNORECASE, ) @@ -41,7 +43,9 @@ def _parse_named_time_match(match: re.Match[str]) -> Optional[ParsedTime]: hour = int(match.group("hour")) minute_group = match.group("minute") minute = int(minute_group) if minute_group is not None else 0 - timezone_label = match.group("timezone").upper() + timezone_label = ( + match.group("timezone") or match.group("parenthesized_timezone") + ).upper() timezone = TIMEZONES_BY_LABEL[timezone_label] return ParsedTime( From fd7920e1e9005217455d62f23f7417b3c409dc74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CdeKibi=E2=80=9D?= Date: Thu, 30 Jul 2026 16:54:40 +0300 Subject: [PATCH 2/4] fix: narrow parser change to parenthesized timezones --- crypto_converter/crypto_amount_parser.py | 23 +---- .../test_crypto_amount_parser.py | 90 ------------------- tests/time_converter/test_utc_time_parser.py | 14 +-- 3 files changed, 8 insertions(+), 119 deletions(-) diff --git a/crypto_converter/crypto_amount_parser.py b/crypto_converter/crypto_amount_parser.py index 8c23c7f..2e7888a 100644 --- a/crypto_converter/crypto_amount_parser.py +++ b/crypto_converter/crypto_amount_parser.py @@ -26,8 +26,7 @@ rf"(?{NUMBER_LITERAL_REGEX})" rf"(?P{COMPACT_NUMBER_SUFFIX_REGEX})?\s+" r"(?P\$)?" - r"(?P(?:(?<=[\s$])[A-Za-z]|[A-Za-z]{2,10}))(?!\w)" - r"(?:\s+tokens?\b)?", + r"(?P(?:(?<=[\s$])[A-Za-z]|[A-Za-z]{2,10}))(?!\w)", flags=re.IGNORECASE, ) CRYPTO_AMOUNT_PREFIX_PATTERN: Final[re.Pattern[str]] = re.compile( @@ -42,10 +41,6 @@ TIMEZONE_OFFSET_SUFFIX_PATTERN: Final[re.Pattern[str]] = re.compile( r"[+-]\d", ) -TOKEN_SUFFIX_PATTERN: Final[re.Pattern[str]] = re.compile( - r"\s+tokens?\b", - flags=re.IGNORECASE, -) @dataclass(frozen=True) @@ -162,15 +157,6 @@ def _parse_amount_value(match: re.Match[str]) -> Decimal: return amount -def _get_crypto_reference_end(text: str, reference_end: int) -> int: - token_suffix_match = TOKEN_SUFFIX_PATTERN.match(text, reference_end) - - if token_suffix_match is None: - return reference_end - - return token_suffix_match.end() - - def _is_single_letter_reference_allowed( reference: str, is_dollar_prefixed: bool, @@ -271,17 +257,16 @@ def resolve_crypto_amounts_from_text( ): continue - match_end = _get_crypto_reference_end(text, coin_match.end) resolved_amounts.append( ResolvedCryptoAmount( amount=_parse_amount_value(amount_match), coin=coin_match.coin, - matched_text=text[amount_match.start():match_end], + matched_text=text[amount_match.start():coin_match.end], start=amount_match.start(), - end=match_end, + end=coin_match.end, ) ) - previous_match_end = match_end + previous_match_end = coin_match.end return resolved_amounts diff --git a/tests/crypto_converter/test_crypto_amount_parser.py b/tests/crypto_converter/test_crypto_amount_parser.py index 6c8d43c..94d0a72 100644 --- a/tests/crypto_converter/test_crypto_amount_parser.py +++ b/tests/crypto_converter/test_crypto_amount_parser.py @@ -29,7 +29,6 @@ ("1m BNB", Decimal("1000000"), "BNB", "1m BNB"), ("1kk K", Decimal("1000000"), "K", "1kk K"), ("100 000 BNB", Decimal("100000"), "BNB", "100 000 BNB"), - ("250 AEON tokens", Decimal("250"), "AEON", "250 AEON tokens"), ], ) def test_parse_crypto_amounts( @@ -46,95 +45,6 @@ def test_parse_crypto_amounts( assert parsed_amount.matched_text == expected_match -@pytest.mark.parametrize( - ("text", "expected_match"), - [ - ("250 AEON", "250 AEON"), - ("250 AEON token", "250 AEON token"), - ("250 AEON tokens", "250 AEON tokens"), - ], -) -def test_resolve_optional_token_suffix_after_ticker( - monkeypatch: pytest.MonkeyPatch, - text: str, - expected_match: str, -) -> None: - coin = ResolvedCoin("aeon", "AEON", "Aeon") - - def resolve_aeon_reference( - message_text: str, - start: int, - ) -> ResolvedCoinMatch | None: - if not message_text[start:].casefold().startswith("aeon"): - return None - - return ResolvedCoinMatch( - coin=coin, - matched_text=message_text[start:start + 4], - end=start + 4, - ) - - monkeypatch.setattr( - crypto_amount_parser, - "resolve_coin_reference_at", - resolve_aeon_reference, - ) - - resolved_amounts = resolve_crypto_amounts_from_text(text) - - assert len(resolved_amounts) == 1 - assert resolved_amounts[0].amount == Decimal("250") - assert resolved_amounts[0].coin.ticker == "AEON" - assert resolved_amounts[0].matched_text == expected_match - - -def test_resolve_crypto_amount_with_token_suffix_from_long_message( - monkeypatch: pytest.MonkeyPatch, -) -> None: - message_text = ( - "Binance Alpha is the first platform to feature AEON (AEON), with " - "Alpha debut and trading starting on July 27, 2026, at 10:00 (UTC). " - "\n\nUsers with at least 245 Binance Alpha Points can claim an " - "airdrop of 250 AEON tokens on a first-come, first-served basis. " - "If the reward pool is not fully distributed, the score threshold " - "will automatically decrease by 5 points every 5 minutes.\n\n" - "Please note that claiming the airdrop will consume 15 Binance " - "Alpha Points. Users must confirm their claim on the Alpha Events " - "page within 24 hours; otherwise, it will be deemed that users have " - "given up claiming the airdrop." - ) - coin = ResolvedCoin("aeon", "AEON", "Aeon") - - def resolve_aeon_reference( - message_text: str, - start: int, - ) -> ResolvedCoinMatch | None: - if not message_text[start:].casefold().startswith("aeon"): - return None - - return ResolvedCoinMatch( - coin=coin, - matched_text=message_text[start:start + 4], - end=start + 4, - ) - - monkeypatch.setattr( - crypto_amount_parser, - "resolve_top_ranked_coin_reference_at", - resolve_aeon_reference, - ) - - resolved_amounts = resolve_crypto_amounts_from_text( - message_text, - top_ranked_only=True, - ) - - assert len(resolved_amounts) == 1 - assert resolved_amounts[0].amount == Decimal("250") - assert resolved_amounts[0].coin.ticker == "AEON" - assert resolved_amounts[0].matched_text == "250 AEON tokens" - - @pytest.mark.parametrize( "text", [ diff --git a/tests/time_converter/test_utc_time_parser.py b/tests/time_converter/test_utc_time_parser.py index 999cfaf..29ec701 100644 --- a/tests/time_converter/test_utc_time_parser.py +++ b/tests/time_converter/test_utc_time_parser.py @@ -14,10 +14,12 @@ @pytest.mark.parametrize( ("text", "timezone_label", "hour", "minute"), [ + ("10:00 utc", "UTC", 10, 0), ("10:00 UTC", "UTC", 10, 0), ("10:00UTC", "UTC", 10, 0), ("10 UTC", "UTC", 10, 0), ("10:00 (UTC)", "UTC", 10, 0), + ("some text 10:00 (UTC)", "UTC", 10, 0), ("10:30 cet", "CET", 10, 30), ("10 CEST", "CEST", 10, 0), ("10:45kyiv", "KYIV", 10, 45), @@ -97,16 +99,8 @@ def test_parse_utc_time_from_text_skips_non_utc_matches() -> None: def test_parse_parenthesized_utc_time_from_long_message() -> None: message_text = ( - "Binance Alpha is the first platform to feature AEON (AEON), with " - "Alpha debut and trading starting on July 27, 2026, at 10:00 (UTC). " - "\n\nUsers with at least 245 Binance Alpha Points can claim an " - "airdrop of 250 AEON tokens on a first-come, first-served basis. " - "If the reward pool is not fully distributed, the score threshold " - "will automatically decrease by 5 points every 5 minutes.\n\n" - "Please note that claiming the airdrop will consume 15 Binance " - "Alpha Points. Users must confirm their claim on the Alpha Events " - "page within 24 hours; otherwise, it will be deemed that users have " - "given up claiming the airdrop." + "Some announcement text before the schedule. Trading starts at " + "10:00 (UTC). Additional unrelated text follows after the time." ) parsed_times = parse_times_from_text(message_text, limit=5) From 6c91b6245c2b8e07158bda60d4447dc38571264c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CdeKibi=E2=80=9D?= Date: Thu, 30 Jul 2026 17:08:06 +0300 Subject: [PATCH 3/4] test(time): document tolerant parenthesized timezone parsing --- tests/time_converter/test_utc_time_parser.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/time_converter/test_utc_time_parser.py b/tests/time_converter/test_utc_time_parser.py index 29ec701..55ba4db 100644 --- a/tests/time_converter/test_utc_time_parser.py +++ b/tests/time_converter/test_utc_time_parser.py @@ -19,7 +19,10 @@ ("10:00UTC", "UTC", 10, 0), ("10 UTC", "UTC", 10, 0), ("10:00 (UTC)", "UTC", 10, 0), + ("10:00 (UTC)abc", "UTC", 10, 0), + ("10:00 (UTC).", "UTC", 10, 0), ("some text 10:00 (UTC)", "UTC", 10, 0), + ("some text 10:00 (UTC)abc", "UTC", 10, 0), ("10:30 cet", "CET", 10, 30), ("10 CEST", "CEST", 10, 0), ("10:45kyiv", "KYIV", 10, 45), @@ -116,6 +119,8 @@ def test_parse_parenthesized_utc_time_from_long_message() -> None: [ "abc10:00UTC", "10:00UTCabc", + "10:00 (UTC)+3", + "10:00 (UTC)-3", "-10:00 UTC", ], ) From b596a48a130f39488a37c841d895e17e97ce7d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CdeKibi=E2=80=9D?= Date: Thu, 30 Jul 2026 17:11:13 +0300 Subject: [PATCH 4/4] fix(time): allow continuations after parenthesized timezone labels --- tests/time_converter/test_utc_time_parser.py | 5 +++-- time_converter/utc_time_parser.py | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/time_converter/test_utc_time_parser.py b/tests/time_converter/test_utc_time_parser.py index 55ba4db..b1ed64a 100644 --- a/tests/time_converter/test_utc_time_parser.py +++ b/tests/time_converter/test_utc_time_parser.py @@ -20,6 +20,9 @@ ("10 UTC", "UTC", 10, 0), ("10:00 (UTC)", "UTC", 10, 0), ("10:00 (UTC)abc", "UTC", 10, 0), + ("10:00 (UTC)+3", "UTC", 10, 0), + ("10:00 (UTC)-3", "UTC", 10, 0), + ("10:00 (UTC),", "UTC", 10, 0), ("10:00 (UTC).", "UTC", 10, 0), ("some text 10:00 (UTC)", "UTC", 10, 0), ("some text 10:00 (UTC)abc", "UTC", 10, 0), @@ -119,8 +122,6 @@ def test_parse_parenthesized_utc_time_from_long_message() -> None: [ "abc10:00UTC", "10:00UTCabc", - "10:00 (UTC)+3", - "10:00 (UTC)-3", "-10:00 UTC", ], ) diff --git a/time_converter/utc_time_parser.py b/time_converter/utc_time_parser.py index fc8b638..fbf2859 100644 --- a/time_converter/utc_time_parser.py +++ b/time_converter/utc_time_parser.py @@ -14,9 +14,8 @@ NAMED_TIME_PATTERN: Final[re.Pattern[str]] = re.compile( r"(?(?:[01]?\d|2[0-3]))" r"(?::(?P[0-5]\d))?" - r"(?: ?(?PUTC|CEST|CET|KYIV)\b" - r"| ?\((?PUTC|CEST|CET|KYIV)\))" - r"(?![+-])", + r"(?: ?(?PUTC|CEST|CET|KYIV)\b(?![+-])" + r"| ?\((?PUTC|CEST|CET|KYIV)\))", flags=re.IGNORECASE, ) OFFSET_TIME_PATTERN: Final[re.Pattern[str]] = re.compile(