1"""Functions for manipulating strings and bytes."""
2
3import re
4
5from icalendar.compatibility import deprecate_for_version_8
6from icalendar.parser_tools import DEFAULT_ENCODING, to_unicode
7
8
9def _escape_char(text: str | bytes) -> str:
10 r"""Format value according to iCalendar TEXT escaping rules.
11
12 Escapes special characters in text values according to :rfc:`5545#section-3.3.11`
13 rules.
14 The order of replacements matters to avoid double-escaping.
15
16 Parameters:
17 text: The text to escape.
18
19 Returns:
20 The escaped text with special characters escaped.
21
22 Note:
23 The replacement order is critical:
24
25 1. ``\N`` -> ``\n`` (normalize newlines to lowercase)
26 2. ``\`` -> ``\\`` (escape backslashes)
27 3. ``;`` -> ``\;`` (escape semicolons)
28 4. ``,`` -> ``\,`` (escape commas)
29 5. ``\r\n`` -> ``\n`` (normalize line endings)
30 6. ``"\n"`` -> ``r"\n"`` (transform a newline character to a literal, or raw,
31 newline character)
32 7. ``"\r"`` -> ``r"\n"`` (transform a lone carriage return to a literal
33 newline character)
34
35 Steps 5 to 7 normalize ``\r\n``, ``\n``, or a lone ``\r`` to ``\n``.
36 The line-ending normalization is an implementation convenience,
37 not part of :rfc:`5545`, which only defines ``\n`` or ``\N`` for an
38 intentional line break, and doesn't give an escape form for a lone ``\r``.
39 """
40 assert isinstance(text, (str, bytes))
41 text = to_unicode(text)
42 # NOTE: ORDER MATTERS!
43 return (
44 text.replace(r"\N", "\n")
45 .replace("\\", "\\\\")
46 .replace(";", r"\;")
47 .replace(",", r"\,")
48 .replace("\r\n", r"\n")
49 .replace("\n", r"\n")
50 .replace("\r", r"\n")
51 )
52
53
54escape_char = deprecate_for_version_8(_escape_char)
55"""Format value according to iCalendar TEXT escaping rules.
56
57.. deprecated:: 7.0.0
58 Use the private :func:`_escape_char` internally. For external use,
59 this function is deprecated. Please use alternative escaping methods
60 or contact the maintainers.
61"""
62
63
64def _unescape_char(text: str | bytes) -> str | bytes | None:
65 r"""Unescape iCalendar TEXT values.
66
67 Reverses the escaping applied by :func:`_escape_char` according to
68 :rfc:`5545#section-3.3.11` TEXT escaping rules.
69
70 Parameters:
71 text: The escaped text.
72
73 Returns:
74 The unescaped text, or ``None`` if ``text`` is neither ``str`` nor ``bytes``.
75
76 Note:
77 The replacement order is critical to avoid double-unescaping:
78
79 1. ``\N`` -> ``\n`` (intermediate step)
80 2. ``\r\n`` -> ``\n`` (normalize line endings)
81 3. ``\n`` -> newline (unescape newlines)
82 4. ``\,`` -> ``,`` (unescape commas)
83 5. ``\;`` -> ``;`` (unescape semicolons)
84 6. ``\\`` -> ``\`` (unescape backslashes last)
85 """
86 assert isinstance(text, (str, bytes))
87 # NOTE: ORDER MATTERS!
88 if isinstance(text, str):
89 return (
90 text.replace("\\N", "\\n")
91 .replace("\r\n", "\n")
92 .replace("\\n", "\n")
93 .replace("\\,", ",")
94 .replace("\\;", ";")
95 .replace("\\\\", "\\")
96 )
97 if isinstance(text, bytes):
98 return (
99 text.replace(b"\\N", b"\\n")
100 .replace(b"\r\n", b"\n")
101 .replace(b"\\n", b"\n")
102 .replace(b"\\,", b",")
103 .replace(b"\\;", b";")
104 .replace(b"\\\\", b"\\")
105 )
106 return None
107
108
109unescape_char = deprecate_for_version_8(_unescape_char)
110"""Unescape iCalendar TEXT values.
111
112.. deprecated:: 7.0.0
113 Use the private :func:`_unescape_char` internally. For external use,
114 this function is deprecated. Please use alternative unescaping methods
115 or contact the maintainers.
116"""
117
118
119def _foldline(line: str, limit: int = 75, fold_sep: str = "\r\n ") -> str:
120 """Make a string folded as defined in RFC5545.
121
122 Lines of text SHOULD NOT be longer than 75 octets, excluding the line
123 break. Long content lines SHOULD be split into a multiple line
124 representations using a line "folding" technique. That is, a long
125 line can be split between any two characters by inserting a CRLF
126 immediately followed by a single linear white-space character (i.e.,
127 SPACE or HTAB).
128 """
129 assert isinstance(line, str)
130 assert "\n" not in line
131
132 folded_lines: list[str] = []
133 current_chars: list[str] = []
134 byte_count = 0
135 for char in line:
136 char_byte_len = len(char.encode(DEFAULT_ENCODING))
137 if current_chars and byte_count + char_byte_len >= limit:
138 # For compatibility with existing clients, avoid splitting escaped
139 # values such as TEXT backslash escapes or RFC 6868 parameter
140 # escapes across a folded line boundary. See issue #1501.
141 if len(current_chars) > 1 and current_chars[-1] in r"\^":
142 escaped_prefix = current_chars.pop()
143 folded_lines.append("".join(current_chars))
144 current_chars = [escaped_prefix]
145 byte_count = len(escaped_prefix.encode(DEFAULT_ENCODING))
146 else:
147 folded_lines.append("".join(current_chars))
148 current_chars = []
149 byte_count = 0
150 current_chars.append(char)
151 byte_count += char_byte_len
152
153 if current_chars:
154 folded_lines.append("".join(current_chars))
155
156 return fold_sep.join(folded_lines)
157
158
159foldline = deprecate_for_version_8(_foldline)
160"""Make a string folded as defined in RFC5545.
161
162.. deprecated:: 7.0.0
163 Use the private :func:`_foldline` internally.
164"""
165
166
167def _escape_string(val: str) -> str:
168 r"""Escape backslash sequences to URL-encoded hex values.
169
170 Converts backslash-escaped characters to their percent-encoded hex
171 equivalents. This is used for parameter parsing to preserve escaped
172 characters during processing.
173
174 Parameters:
175 val: The string with backslash escapes.
176
177 Returns:
178 The string with backslash escapes converted to percent encoding.
179
180 Note:
181 Conversions:
182
183 - ``%`` -> ``%25``
184 - ``\,`` -> ``%2C``
185 - ``\:`` -> ``%3A``
186 - ``\;`` -> ``%3B``
187 - ``\\`` -> ``%5C``
188
189 A literal ``%`` is escaped first so that percent sequences already in
190 the value (e.g. ``%2C`` in a URI) are not confused with the markers
191 introduced here. :func:`_unescape_string` reverses it.
192 """
193 # f'{i:02X}'
194 return (
195 val.replace("%", "%25")
196 .replace(r"\,", "%2C")
197 .replace(r"\:", "%3A")
198 .replace(r"\;", "%3B")
199 .replace(r"\\", "%5C")
200 )
201
202
203escape_string = deprecate_for_version_8(_escape_string)
204"""Escape backslash sequences to URL-encoded hex values.
205
206.. deprecated:: 7.0.0
207 Use the private :func:`_escape_string` internally. For external use,
208 this function is deprecated.
209"""
210
211
212def _unescape_string(val: str) -> str:
213 r"""Unescape URL-encoded hex values to their original characters.
214
215 Reverses :func:`_escape_string` by converting percent-encoded hex values
216 back to their original characters. This is used for parameter parsing.
217
218 Parameters:
219 val: The string with percent-encoded values.
220
221 Returns:
222 The string with percent encoding converted to characters.
223
224 Note:
225 Conversions:
226
227 - ``%2C`` -> ``,``
228 - ``%3A`` -> ``:``
229 - ``%3B`` -> ``;``
230 - ``%5C`` -> ``\``
231 - ``%25`` -> ``%``
232
233 ``%25`` is restored last so a literal ``%`` that :func:`_escape_string`
234 protected does not re-trigger the marker replacements above.
235 """
236 return (
237 val.replace("%2C", ",")
238 .replace("%3A", ":")
239 .replace("%3B", ";")
240 .replace("%5C", "\\")
241 .replace("%25", "%")
242 )
243
244
245unescape_string = deprecate_for_version_8(_unescape_string)
246"""Unescape URL-encoded hex values to their original characters.
247
248.. deprecated:: 7.0.0
249 Use the private :func:`_unescape_string` internally. For external use,
250 this function is deprecated.
251"""
252
253
254# [\w-] because of the iCalendar RFC
255# . because of the vCard RFC
256NAME = re.compile(r"[\w.-]+")
257
258
259def validate_token(name: str) -> None:
260 r"""Validate that a name is a valid iCalendar token.
261
262 Checks if the name matches the :rfc:`5545` token syntax using the NAME
263 regex pattern (``[\w.-]+``).
264
265 Parameters:
266 name: The token name to validate.
267
268 Raises:
269 ValueError: If the name is not a valid token.
270 """
271 match = NAME.findall(name)
272 if len(match) == 1 and name == match[0]:
273 return
274 raise ValueError(name)
275
276
277__all__ = [
278 "_escape_char",
279 "_escape_string",
280 "_foldline",
281 "_unescape_char",
282 "_unescape_string",
283 "escape_char",
284 "escape_string",
285 "foldline",
286 "unescape_char",
287 "unescape_string",
288 "validate_token",
289]