Skip to content
Closed
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
23 changes: 19 additions & 4 deletions crypto_converter/crypto_amount_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
rf"(?<![\w.,:-])(?P<amount>{NUMBER_LITERAL_REGEX})"
rf"(?P<multiplier>{COMPACT_NUMBER_SUFFIX_REGEX})?\s+"
r"(?P<dollar>\$)?"
r"(?P<ticker>(?:(?<=[\s$])[A-Za-z]|[A-Za-z]{2,10}))(?!\w)",
r"(?P<ticker>(?:(?<=[\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(
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -257,16 +271,17 @@ def resolve_crypto_amounts_from_text(
):
continue

match_end = _get_crypto_reference_end(text, coin_match.end)
Comment thread
deKibi marked this conversation as resolved.
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

Expand Down
90 changes: 90 additions & 0 deletions tests/crypto_converter/test_crypto_amount_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
[
Expand Down
23 changes: 23 additions & 0 deletions tests/time_converter/test_utc_time_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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",
[
Expand Down
8 changes: 6 additions & 2 deletions time_converter/utc_time_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
# Time parsing
NAMED_TIME_PATTERN: Final[re.Pattern[str]] = re.compile(
r"(?<![\w:.\-+])(?P<hour>(?:[01]?\d|2[0-3]))"
r"(?::(?P<minute>[0-5]\d))? ?(?P<timezone>UTC|CEST|CET|KYIV)\b"
r"(?::(?P<minute>[0-5]\d))?"
r"(?: ?(?P<timezone>UTC|CEST|CET|KYIV)\b"
r"| ?\((?P<parenthesized_timezone>UTC|CEST|CET|KYIV)\))"
r"(?![+-])",
flags=re.IGNORECASE,
)
Expand Down Expand Up @@ -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(
Expand Down
Loading