1"""BYWEEKDAY, BYDAY, and WKST value type of RECUR from :rfc:`5545`."""
2
3import re
4from typing import Any
5
6from icalendar.caselessdict import CaselessDict
7from icalendar.compatibility import Self
8from icalendar.error import JCalParsingError
9from icalendar.parser import Parameters
10from icalendar.parser_tools import DEFAULT_ENCODING, to_unicode
11
12# Use ``[0-9]`` to explicitly specify that only ASCII digits should be
13# matched, instead of ``\d``. In Python, ``\d`` matches a digit zero through
14# nine in any script except ideographic scripts, and is equivalent to
15# ``\p{Nd}``. For example, the Arabic-Indic "١٢" would match. Here, a
16# malformed ``ordwk`` would get parsed into a valid ``relative`` number.
17# RFC 5545, section 3.3.10, allows only ASCII characters per its definition
18# of ``DIGIT``.
19WEEKDAY_RULE = re.compile(
20 r"(?P<signal>[+-]?)(?P<relative>[0-9]{0,2})(?P<weekday>[\w]{2})\Z"
21)
22
23
24class vWeekday(str):
25 """Either a ``weekday`` or a ``weekdaynum``.
26
27 .. code-block:: pycon
28
29 >>> from icalendar import vWeekday
30 >>> vWeekday("MO") # Simple weekday
31 'MO'
32 >>> vWeekday("2FR").relative # Second friday
33 2
34 >>> vWeekday("2FR").weekday
35 'FR'
36 >>> vWeekday("-1SU").relative # Last Sunday
37 -1
38
39 Definition from :rfc:`5545#section-3.3.10`:
40
41 .. code-block:: text
42
43 weekdaynum = [[plus / minus] ordwk] weekday
44 plus = "+"
45 minus = "-"
46 ordwk = 1*2DIGIT ;1 to 53
47 weekday = "SU" / "MO" / "TU" / "WE" / "TH" / "FR" / "SA"
48 ;Corresponding to SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY,
49 ;FRIDAY, and SATURDAY days of the week.
50
51 """
52
53 params: Parameters
54 __slots__ = ("params", "relative", "weekday")
55
56 week_days = CaselessDict(
57 {
58 "SU": 0,
59 "MO": 1,
60 "TU": 2,
61 "WE": 3,
62 "TH": 4,
63 "FR": 5,
64 "SA": 6,
65 }
66 )
67
68 def __new__(
69 cls,
70 value,
71 encoding=DEFAULT_ENCODING,
72 /,
73 params: dict[str, Any] | None = None,
74 ):
75 value = to_unicode(value, encoding=encoding)
76 self = super().__new__(cls, value)
77 match = WEEKDAY_RULE.match(self)
78 if match is None:
79 raise ValueError(f"Expected weekday abbreviation, got: {self}")
80 match = match.groupdict()
81 sign = match["signal"]
82 weekday = match["weekday"]
83 relative = match["relative"]
84 if weekday not in vWeekday.week_days or sign not in "+-":
85 raise ValueError(f"Expected weekday abbreviation, got: {self}")
86 self.weekday = weekday or None
87 self.relative = (relative and int(relative)) or None
88 if sign == "-" and self.relative:
89 self.relative *= -1
90 self.params = Parameters(params)
91 return self
92
93 def to_ical(self):
94 return self.encode(DEFAULT_ENCODING).upper()
95
96 @classmethod
97 def from_ical(cls, ical):
98 try:
99 return cls(ical.upper())
100 except Exception as e:
101 raise ValueError(f"Expected weekday abbreviation, got: {ical}") from e
102
103 @property
104 def ical_value(self) -> str:
105 """Returns the weekday value as a string, for example, ``MO``, ``+2TH``, or ``-1SU``.
106
107 See Also:
108
109 :rfc:`5545#section-3.3.10` for the ``BYDAY`` rule grammar.
110 """
111 return str(self)
112
113 @classmethod
114 def parse_jcal_value(cls, value: Any) -> Self:
115 """Parse a jCal value for vWeekday.
116
117 Raises:
118 ~error.JCalParsingError: If the value is not a valid weekday.
119 """
120 JCalParsingError.validate_value_type(value, str, cls)
121 try:
122 return cls(value)
123 except ValueError as e:
124 raise JCalParsingError(
125 "The value must be a valid weekday.", cls, value=value
126 ) from e
127
128
129__all__ = ["vWeekday"]