Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/xlsxwriter/url.py: 26%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

127 statements  

1############################################################################### 

2# 

3# Url - A class to represent URLs in Excel. 

4# 

5# SPDX-License-Identifier: BSD-2-Clause 

6# 

7# Copyright (c) 2013-2026, John McNamara, jmcnamara@cpan.org 

8# 

9 

10 

11import re 

12from enum import Enum 

13from typing import Any, Dict, Optional 

14 

15from xlsxwriter.utility import _utf16_len 

16 

17 

18class UrlTypes(Enum): 

19 """ 

20 Enum to represent different types of URLS. 

21 """ 

22 

23 UNKNOWN = 0 

24 URL = 1 

25 INTERNAL = 2 

26 EXTERNAL = 3 

27 

28 

29class Url: 

30 """ 

31 A class to represent URLs in Excel. 

32 

33 """ 

34 

35 MAX_URL_LEN = 2080 

36 MAX_PARAMETER_LEN = 255 

37 

38 def __init__(self, link: str) -> None: 

39 self._link_type: UrlTypes = UrlTypes.UNKNOWN 

40 self._original_url: str = link 

41 self._link: str = link 

42 self._relationship_link: str = link 

43 self._text: str = "" 

44 self._tip: str = "" 

45 self._anchor: str = "" 

46 self._is_object_link: bool = False 

47 self._rel_index: int = 0 

48 

49 self._parse_url() 

50 

51 if _utf16_len(self._link) > self.MAX_URL_LEN: 

52 raise ValueError("URL exceeds Excel's maximum length.") 

53 

54 if _utf16_len(self._anchor) > self.MAX_URL_LEN: 

55 raise ValueError("Anchor segment or url exceeds Excel's maximum length.") 

56 

57 self._escape_strings() 

58 

59 def __repr__(self) -> str: 

60 """ 

61 Return a string representation of the Url instance. 

62 

63 """ 

64 return ( 

65 "\n" 

66 f"Url:\n" 

67 f" _link_type = {self._link_type.name}\n" 

68 f" _original_url = {self._original_url}\n" 

69 f" _link = {self._link}\n" 

70 f" _relationship_link = {self._relationship_link}\n" 

71 f" _text = {self._text}\n" 

72 f" _tip = {self._tip}\n" 

73 f" _anchor = {self._anchor}\n" 

74 f" _is_object_link = {self._is_object_link}\n" 

75 f" _rel_index = {self._rel_index}\n" 

76 ) 

77 

78 @classmethod 

79 def from_options(cls, options: Dict[str, Any]) -> Optional["Url"]: 

80 """ 

81 For backward compatibility, convert the 'url' key and 'tip' keys in an 

82 options dictionary to a Url object, or return the Url object if already 

83 an instance. 

84 

85 Args: 

86 options (dict): A dictionary that may contain a 'url' key. 

87 

88 Returns: 

89 url: A Url object or None. 

90 

91 """ 

92 if not isinstance(options, dict): 

93 raise TypeError("The 'options' parameter must be a dictionary.") 

94 

95 url = options.get("url") 

96 

97 if isinstance(url, str): 

98 url = cls(options["url"]) 

99 if options.get("tip"): 

100 url.tip = options["tip"] 

101 

102 return url 

103 

104 @property 

105 def text(self) -> str: 

106 """Get the alternative, user-friendly, text for the URL.""" 

107 return self._text 

108 

109 @text.setter 

110 def text(self, value: str) -> None: 

111 """Set the alternative, user-friendly, text for the URL.""" 

112 self._text = value 

113 

114 @property 

115 def tip(self) -> str: 

116 """Get the screen tip for the URL.""" 

117 return self._tip 

118 

119 @tip.setter 

120 def tip(self, value: str) -> None: 

121 """Set the screen tip for the URL.""" 

122 if _utf16_len(value) > self.MAX_PARAMETER_LEN: 

123 raise ValueError("Hyperlink tool tip exceeds Excel's maximum length.") 

124 self._tip = value 

125 

126 def _parse_url(self) -> None: 

127 """Parse the URL and determine its type.""" 

128 

129 # Handle mail address links. 

130 if self._link.startswith("mailto:"): 

131 self._link_type = UrlTypes.URL 

132 

133 if not self._text: 

134 self._text = self._link.replace("mailto:", "", 1) 

135 

136 # Handle links to cells within the workbook. 

137 elif self._link.startswith("internal:"): 

138 self._link_type = UrlTypes.INTERNAL 

139 self._relationship_link = self._link.replace("internal:", "#", 1) 

140 self._link = self._link.replace("internal:", "", 1) 

141 self._anchor = self._link 

142 

143 if not self._text: 

144 self._text = self._anchor 

145 

146 # Handle links to other files or cells in other Excel files. 

147 elif self._link.startswith("file://") or self._link.startswith("external:"): 

148 self._link_type = UrlTypes.EXTERNAL 

149 

150 # Handle backward compatibility with external: links. 

151 file_url = self._original_url.replace("external:", "file:///", 1) 

152 

153 link_path = file_url 

154 link_path = link_path.replace("file:///", "", 1) 

155 link_path = link_path.replace("file://", "", 1) 

156 link_path = link_path.replace("/", "\\") 

157 

158 if self._is_relative_path(link_path): 

159 self._link = link_path 

160 else: 

161 self._link = "file:///" + link_path 

162 

163 if not self._text: 

164 self._text = link_path 

165 

166 if "#" in self._link: 

167 self._link, self._anchor = self._link.split("#", 1) 

168 

169 # Set up the relationship link. This doesn't usually contain the 

170 # anchor unless it is a link from an object like an image. 

171 if self._is_object_link: 

172 if self._is_relative_path(link_path): 

173 self._relationship_link = self._link.replace("\\", "/") 

174 else: 

175 self._relationship_link = file_url 

176 

177 else: 

178 self._relationship_link = self._link 

179 

180 # Convert a .\dir\file.xlsx link to dir\file.xlsx. 

181 if self._relationship_link.startswith(".\\"): 

182 self._relationship_link = self._relationship_link.replace(".\\", "", 1) 

183 

184 # Handle standard Excel links like http://, https://, ftp://, ftps:// 

185 # but also allow custom "foo://bar" URLs. 

186 elif "://" in self._link: 

187 self._link_type = UrlTypes.URL 

188 

189 if not self._text: 

190 self._text = self._link 

191 

192 if "#" in self._link: 

193 self._link, self._anchor = self._link.split("#", 1) 

194 

195 # Set up the relationship link. This doesn't usually contain the 

196 # anchor unless it is a link from an object like an image. 

197 if self._is_object_link: 

198 self._relationship_link = self._original_url 

199 else: 

200 self._relationship_link = self._link 

201 

202 else: 

203 raise ValueError(f"Unknown URL type: {self._original_url}") 

204 

205 def _set_object_link(self) -> None: 

206 """ 

207 Set the _is_object_link flag and re-parse the URL since the relationship 

208 link is different for object links. 

209 

210 """ 

211 self._is_object_link = True 

212 self._link = self._original_url 

213 self._parse_url() 

214 self._escape_strings() 

215 

216 def _escape_strings(self) -> None: 

217 """Escape special characters in the URL strings.""" 

218 

219 if self._link_type != UrlTypes.INTERNAL: 

220 self._link = self._escape_url(self._link) 

221 self._relationship_link = self._escape_url(self._relationship_link) 

222 

223 # Excel additionally escapes # to %23 in file paths. 

224 if self._link_type == UrlTypes.EXTERNAL: 

225 self._relationship_link = self._relationship_link.replace("#", "%23") 

226 

227 def _target(self) -> str: 

228 """Get the target for relationship IDs.""" 

229 return self._relationship_link 

230 

231 def _target_mode(self) -> str: 

232 """Get the target mode for relationship IDs.""" 

233 if self._link_type == UrlTypes.INTERNAL: 

234 return "" 

235 

236 return "External" 

237 

238 @staticmethod 

239 def _is_relative_path(url: str) -> bool: 

240 """Check if a URL is a relative path.""" 

241 if url.startswith(r"\\"): 

242 return False 

243 

244 if url[0].isalpha() and url[1] == ":": # noqa: SIM103 

245 return False 

246 

247 return True 

248 

249 @staticmethod 

250 def _escape_url(url: str) -> str: 

251 """Escape special characters in a URL.""" 

252 # Don't escape URL if it looks already escaped. 

253 if re.search("%[0-9a-fA-F]{2}", url): 

254 return url 

255 

256 # Can't use url.quote() here because it doesn't match Excel. 

257 return ( 

258 url.replace("%", "%25") 

259 .replace('"', "%22") 

260 .replace(" ", "%20") 

261 .replace("<", "%3c") 

262 .replace(">", "%3e") 

263 .replace("[", "%5b") 

264 .replace("]", "%5d") 

265 .replace("^", "%5e") 

266 .replace("`", "%60") 

267 .replace("{", "%7b") 

268 .replace("}", "%7d") 

269 )