Skip to content

Commit 4742d52

Browse files
authored
feat(parser): add tag access fields to Message objects (#308)
1 parent 0928792 commit 4742d52

3 files changed

Lines changed: 176 additions & 2 deletions

File tree

irclib/parser.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
Backported from async-irc (https://github.com/snoonetIRC/async-irc.git)
44
"""
55

6+
import datetime
67
import re
8+
import warnings
79
from abc import ABCMeta, abstractmethod
810
from collections.abc import Iterable, Iterator, Sequence
911
from typing import Final, Literal, Optional, Union, cast
@@ -56,6 +58,17 @@
5658
}
5759

5860

61+
def parse_server_time(
62+
value: Optional[str], default: datetime.datetime
63+
) -> datetime.datetime:
64+
if value:
65+
return datetime.datetime.strptime(
66+
value, "%Y-%m-%dT%H:%M:%S.%fZ"
67+
).replace(tzinfo=datetime.timezone.utc)
68+
69+
return default
70+
71+
5972
class Parseable(metaclass=ABCMeta):
6073
"""Abstract class for parseable objects."""
6174

@@ -597,13 +610,59 @@ def __init__(
597610
prefix: Union[str, Prefix, None, Iterable[str]],
598611
command: str,
599612
*parameters: Union[str, list[str], ParamList],
613+
time: Optional[datetime.datetime] = None,
600614
) -> None:
601615
"""Construct message object."""
602616
self._tags = _parse_tags(tags)
603617
self._prefix = _parse_prefix(prefix)
604618
self._command = command
605619
self._parameters = _parse_params(parameters)
606620

621+
if time is None:
622+
time = datetime.datetime.now(datetime.timezone.utc)
623+
624+
if time.tzinfo is None:
625+
time = time.replace(tzinfo=datetime.timezone.utc)
626+
warnings.warn(
627+
"Timezone-naive datetime is deprecated for 'time'.",
628+
DeprecationWarning,
629+
stacklevel=2,
630+
)
631+
632+
self._time = time
633+
634+
def has_tag(self, name: str) -> bool:
635+
"""Return whether this message has a particular message tag."""
636+
if not self.tags:
637+
return False
638+
639+
return name in self.tags
640+
641+
def get_tag_value(self, name: str) -> Optional[str]:
642+
"""Get value for a message tag, or None if not set."""
643+
if self.tags and name in self.tags:
644+
return self.tags[name].value
645+
646+
return None
647+
648+
@property
649+
def time(self) -> datetime.datetime:
650+
"""Time the message was sent/received.
651+
652+
Uses server-time tag if available.
653+
"""
654+
return parse_server_time(self.get_tag_value("time"), self._time)
655+
656+
@property
657+
def message_id(self) -> Optional[str]:
658+
"""Unique message ID provided by server."""
659+
return self.get_tag_value("msgid")
660+
661+
@property
662+
def batch_id(self) -> Optional[str]:
663+
"""Batch ID provided by server."""
664+
return self.get_tag_value("batch")
665+
607666
@property
608667
def tags(self) -> MsgTagList:
609668
"""IRC tag list."""
@@ -629,7 +688,12 @@ def as_tuple(self) -> MessageTuple:
629688
return self.tags, self.prefix, self.command, self.parameters
630689

631690
@classmethod
632-
def parse(cls, text: Union[str, bytes]) -> Self:
691+
def parse(
692+
cls,
693+
text: Union[str, bytes],
694+
*,
695+
time: Optional[datetime.datetime] = None,
696+
) -> Self:
633697
"""Parse an IRC message in to objects."""
634698
if isinstance(text, memoryview):
635699
text = text.tobytes().decode(errors="ignore")
@@ -652,7 +716,7 @@ def parse(cls, text: Union[str, bytes]) -> Self:
652716
prefix_obj = Prefix.parse(prefix[1:]) if prefix else None
653717
command = command.upper()
654718
param_obj = ParamList.parse(params)
655-
return cls(tags_obj, prefix_obj, command, param_obj)
719+
return cls(tags_obj, prefix_obj, command, param_obj, time=time)
656720

657721
def __eq__(self, other: object) -> bool:
658722
"""Compare to another message which can be str, bytes, or a Message object."""

tests/parser_test.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Test IRC parser."""
22

3+
import datetime
34
from typing import Optional, TypedDict
45

56
import parser_tests.data
@@ -826,6 +827,100 @@ def test_bool_false(self, obj: Message) -> None:
826827
"""Test all the cases where bool(Message) should return False."""
827828
assert not obj
828829

830+
@pytest.mark.parametrize(
831+
("msg", "expected"),
832+
[
833+
(
834+
"@time=2025-09-01T00:11:22.123Z FOO #bar :baz blah",
835+
datetime.datetime(
836+
year=2025,
837+
month=9,
838+
day=1,
839+
hour=0,
840+
minute=11,
841+
second=22,
842+
microsecond=123000,
843+
tzinfo=datetime.timezone.utc,
844+
),
845+
),
846+
(
847+
"FOO #bar :baz blah",
848+
datetime.datetime(
849+
year=2006,
850+
month=1,
851+
day=2,
852+
hour=3,
853+
minute=4,
854+
second=5,
855+
microsecond=123456,
856+
tzinfo=datetime.timezone(datetime.timedelta(hours=-7)),
857+
),
858+
),
859+
],
860+
)
861+
def test_parse_server_time(
862+
self, msg: str, expected: datetime.datetime
863+
) -> None:
864+
"""Test server time parsing."""
865+
default_time = datetime.datetime(
866+
year=2006,
867+
month=1,
868+
day=2,
869+
hour=3,
870+
minute=4,
871+
second=5,
872+
microsecond=123456,
873+
tzinfo=datetime.timezone(datetime.timedelta(hours=-7)),
874+
)
875+
876+
assert Message.parse(msg, time=default_time).time == expected
877+
878+
@pytest.mark.parametrize(
879+
("msg", "expected"),
880+
[
881+
("@msgid=foo FOO #bar :baz blah", "foo"),
882+
("FOO #bar :baz blah", None),
883+
],
884+
)
885+
def test_parse_msgid(self, msg: str, expected: Optional[str]) -> None:
886+
"""Ensure message IDs are retrieved."""
887+
assert Message.parse(msg).message_id == expected
888+
889+
@pytest.mark.parametrize(
890+
("msg", "expected"),
891+
[
892+
("@batch=foo FOO #bar :baz blah", "foo"),
893+
("FOO #bar :baz blah", None),
894+
],
895+
)
896+
def test_parse_batch(self, msg: str, expected: Optional[str]) -> None:
897+
"""Ensure batch IDs are retrieved."""
898+
assert Message.parse(msg).batch_id == expected
899+
900+
def test_naive_datetime_warning(self) -> None:
901+
"""Ensure warning about use of naive datetimes is present."""
902+
with pytest.warns(DeprecationWarning, match=".*naive.*"):
903+
Message(
904+
None,
905+
None,
906+
"cmd",
907+
time=datetime.datetime(year=1, month=1, day=1), # noqa: DTZ001
908+
)
909+
910+
@pytest.mark.parametrize(
911+
("msg", "tag", "present"),
912+
[
913+
("", "", False),
914+
("@foo=bar FOO", "foo", True),
915+
("@foo=bar FOO", "bar", False),
916+
("@foo= FOO", "foo", True),
917+
("@foo= FOO", "bar", False),
918+
],
919+
)
920+
def test_has_tag(self, msg: str, tag: str, present: bool) -> None:
921+
"""Test has_tag."""
922+
assert Message.parse(msg).has_tag(tag) == present
923+
829924

830925
def test_trail() -> None:
831926
"""Ensure this parser does not have the same issue as https://github.com/hexchat/hexchat/issues/2271."""

tests/string_test.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ def test_comparisons() -> None:
5151
assert "B" >= str2
5252
assert not "B" <= str2
5353

54+
with pytest.raises(TypeError):
55+
assert not str2 < 1 # type: ignore[operator]
56+
57+
with pytest.raises(TypeError):
58+
assert not str2 <= 1 # type: ignore[operator]
59+
60+
with pytest.raises(TypeError):
61+
assert not str2 > 1 # type: ignore[operator]
62+
63+
with pytest.raises(TypeError):
64+
assert not str2 >= 1 # type: ignore[operator]
65+
66+
assert not str2 == 1
67+
assert str2 != 1
68+
5469
assert str2 != "B"
5570

5671
assert str1 != 5

0 commit comments

Comments
 (0)