1"""BINARY values from :rfc:`5545`."""
2
3import base64
4import binascii
5from typing import ClassVar
6from urllib.parse import quote
7from xml.etree.ElementTree import Element, SubElement
8
9from icalendar.compatibility import Self
10from icalendar.error import JCalParsingError, XCalParsingError
11from icalendar.parser import Parameters
12from icalendar.parser.xcal.base import XCalParser
13from icalendar.parser.xcal.wrapper import from_xcal_wrapper
14
15
16class vBinary:
17 """Binary property values are Base64 encoded.
18
19 .. versionremoved:: 0.0.0
20 Removed ``obj`` attribute, use :attr:`bytes` to access the binary content.
21
22 .. seealso:: :ref:`vbinary-obj-removed`.
23 """
24
25 default_value: ClassVar[str] = "BINARY"
26 params: Parameters
27 bytes: bytes
28 """The raw binary value of the BINARY property.
29
30 This is the authoritative storage method, which round-trips losslessly, including
31 for non-UTF-8 data.
32 """
33
34 def __init__(self, obj: str | bytes, params: dict[str, str] | None = None) -> None:
35 if isinstance(obj, str):
36 self.bytes = obj.encode("utf-8")
37 else:
38 self.bytes = obj
39 self.params = Parameters(encoding="BASE64", value="BINARY")
40 if params:
41 self.params.update(params)
42
43 def __repr__(self) -> str:
44 return f"vBinary({self.to_ical()})"
45
46 def to_ical(self) -> bytes:
47 return base64.b64encode(self.bytes)
48
49 @staticmethod
50 def from_ical(ical: str | bytes) -> bytes:
51 try:
52 return base64.b64decode(ical, validate=True)
53 except (binascii.Error, ValueError) as e:
54 raise ValueError("Not valid base 64 encoding.") from e
55
56 @property
57 def base64data(self) -> str:
58 """The Base64-encoded string view of this value.
59
60 This is the same string that :meth:`to_ical` produces, exposed as
61 a plain :class:`str` instead of :class:`bytes` so you don't need to
62 call :func:`base64.b64encode` yourself. See :issue:`1550`.
63
64 Returns:
65 The Base64-encoded representation of the stored value.
66 """
67 return self.to_ical().decode("ascii")
68
69 @base64data.setter
70 def base64data(self, value: str) -> None:
71 """Set this value from a Base64-encoded string.
72
73 The decoded raw bytes are stored in :attr:`bytes`, so non-UTF-8
74 payloads round-trip losslessly.
75
76 Parameters:
77 value: A Base64-encoded string.
78
79 Raises:
80 ValueError: If ``value`` isn't valid Base64.
81 """
82 self.bytes = self.from_ical(value)
83
84 @property
85 def uri(self) -> str:
86 """Return this value as an RFC 2397 base64 data URI.
87
88 The media type comes from the current ``FMTTYPE`` parameter, or
89 defaults to ``application/octet-stream`` when it is not set. See
90 :rfc:`2397` for the data URI scheme.
91
92 Examples:
93 >>> from icalendar import vBinary
94 >>> vBinary(b"hello", params={"FMTTYPE": "text/plain"}).uri
95 'data:text/plain;base64,aGVsbG8='
96 """
97 media_type = self.params.get("FMTTYPE") or "application/octet-stream"
98 return f"data:{quote(media_type, safe='/;=')};base64,{self.base64data}"
99
100 def __eq__(self, other: object) -> bool:
101 """self == other"""
102 return isinstance(other, vBinary) and self.bytes == other.bytes
103
104 def __hash__(self) -> int:
105 """Hash of the vBinary object."""
106 return hash(self.bytes)
107
108 @classmethod
109 def examples(cls) -> list[Self]:
110 """Examples of vBinary."""
111 return [cls("VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4")]
112
113 from icalendar.param import VALUE
114
115 def to_jcal(self, name: str) -> list:
116 """The jCal representation of this property according to :rfc:`7265`."""
117 params = self.params.to_jcal()
118 if params.get("encoding") == "BASE64":
119 # BASE64 is the only allowed encoding
120 del params["encoding"]
121 return [name, params, self.VALUE.lower(), self.base64data]
122
123 @property
124 def ical_value(self) -> bytes:
125 """The raw ``bytes`` value of the BINARY property.
126
127 .. versionadded:: 7.1.0
128
129 .. versionchanged:: 7.1.3
130 Returns the raw stored bytes. Previously the stored value was
131 Base64-decoded, which raised :class:`ValueError` for non-Base64
132 input. See :pr:`1356`.
133 """
134 return self.bytes
135
136 @classmethod
137 def from_jcal(cls, jcal_property: list) -> Self:
138 """Parse jCal from :rfc:`7265` to a vBinary.
139
140 Parameters:
141 jcal_property: The jCal property to parse.
142
143 Raises:
144 ~icalendar.error.JCalParsingError: If the provided jCal is invalid.
145 """
146 JCalParsingError.validate_property(jcal_property, cls)
147 JCalParsingError.validate_value_type(jcal_property[3], str, cls, 3)
148 return cls(
149 cls.from_ical(jcal_property[3]),
150 params=Parameters.from_jcal_property(jcal_property),
151 )
152
153 def to_xcal(self, element: Element):
154 """The xCal representation of this property according to :rfc:`6321`."""
155 self.params.to_xcal(element)
156 xml = SubElement(element, self.default_value.lower())
157 xml.text = self.base64data
158
159 @classmethod
160 @from_xcal_wrapper
161 def from_xcal(cls, parser: XCalParser, params: Parameters) -> Self:
162 """Parse xCal from :rfc:`6321`.
163
164 Parameters:
165 xml: The XML to parse or a parser.
166
167 Raises:
168 ~error.XCalParsingError: If the provided xCal is invalid.
169 """
170 element = parser.parse_tag(cls.default_value)
171 text = element.get_text_without_whitespace()
172 try:
173 binary = base64.b64decode(text, validate=True)
174 except binascii.Error as e:
175 raise XCalParsingError(
176 "Expected base64 encoded data",
177 None, # do not print potentially huge chunks of data
178 element,
179 ) from e
180 return cls(binary, params=params)
181
182
183__all__ = ["vBinary"]