Coverage for kataglyphis_webdavclient/webdavclient.py: 95%

201 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-04 00:43 +0000

1# Copyright (c) 2024 Jonas Heinle 

2 

3"""WebDAV client implementation for listing and downloading remote files.""" 

4 

5import sys 

6import threading 

7import time 

8import urllib.parse 

9import uuid 

10from pathlib import Path, PurePosixPath 

11 

12import requests 

13from defusedxml import ElementTree 

14from loguru import logger 

15from requests.auth import HTTPBasicAuth 

16 

17 

18REQUEST_TIMEOUT_SECONDS = 30 

19LOG_FILE = "logs/downloadMd_s.log" 

20 

21# One loguru sink per log file: a sink per client wrote every line once per client and rotated the file N times. 

22_log_sinks: set[str] = set() 

23_log_sinks_lock = threading.Lock() 

24 

25 

26def _add_log_sink(log_file: str) -> None: 

27 path = str(Path(log_file).absolute()) 

28 with _log_sinks_lock: 

29 if path in _log_sinks: 

30 return 

31 logger.add(path, rotation="500 MB") 

32 _log_sinks.add(path) 

33 

34 

35def _join_remote_url(*parts: str) -> str: 

36 cleaned_parts = [part.strip("/") for part in parts if part] 

37 return "/".join(cleaned_parts) 

38 

39 

40def _remote_relative_path(href: str, base_url: str) -> tuple[str, ...]: 

41 """Return validated, decoded path components below the same-origin DAV base.""" 

42 if any(ord(char) < 32 for char in href): 

43 message = "WebDAV href contains control characters" 

44 raise ValueError(message) 

45 raw_path = urllib.parse.unquote(urllib.parse.urlsplit(href).path, errors="strict") 

46 if any(part in {".", ".."} for part in raw_path.split("/")): 

47 message = "WebDAV href contains an unsafe path component" 

48 raise ValueError(message) 

49 base = urllib.parse.urlsplit(base_url.rstrip("/") + "/") 

50 remote = urllib.parse.urlsplit(urllib.parse.urljoin(base.geturl(), href)) 

51 if (remote.scheme, remote.netloc) != (base.scheme, base.netloc): 

52 message = "WebDAV href belongs to another origin" 

53 raise ValueError(message) 

54 if remote.query or remote.fragment: 

55 message = "WebDAV href contains a query or fragment" 

56 raise ValueError(message) 

57 path = urllib.parse.unquote(remote.path, errors="strict") 

58 base_path = urllib.parse.unquote(base.path, errors="strict").rstrip("/") 

59 parts = tuple(path.strip("/").split("/")) if path.strip("/") else () 

60 base_parts = tuple(base_path.strip("/").split("/")) if base_path else () 

61 # Validate before prefix matching so neither dot segments nor Windows syntax can change the destination. 

62 if any( 

63 not part 

64 or part in {".", ".."} 

65 or any(ord(char) < 32 or char in '\\:<>"|?*' for char in part) 

66 or part.endswith((".", " ")) 

67 or part.split(".", 1)[0].rstrip(" ").upper() 

68 in { 

69 "CON", 

70 "PRN", 

71 "AUX", 

72 "NUL", 

73 *(f"COM{i}" for i in "123456789¹²³"), 

74 *(f"LPT{i}" for i in "123456789¹²³"), 

75 } 

76 for part in parts 

77 ): 

78 message = "WebDAV href contains an unsafe path component" 

79 raise ValueError(message) 

80 if parts[: len(base_parts)] != base_parts: 

81 message = "WebDAV href is outside the remote base path" 

82 raise ValueError(message) 

83 return parts[len(base_parts) :] 

84 

85 

86def _download_target(base: Path, relative: tuple[str, ...]) -> Path: 

87 """Resolve existing symlinks and reject a destination outside the download root.""" 

88 target = base.joinpath(*relative).resolve() 

89 if not relative or not target.is_relative_to(base): 

90 message = "WebDAV download target is outside the local base path" 

91 raise ValueError(message) 

92 return target 

93 

94 

95def _replace(part: Path, target: Path) -> None: 

96 # Windows refuses a replace while another thread replaces the same target (PermissionError); it clears in milliseconds. 

97 attempts = 40 if sys.platform == "win32" else 1 

98 for attempt in range(attempts): 

99 try: 

100 part.replace(target) 

101 except PermissionError: 

102 if attempt == attempts - 1: 

103 raise 

104 time.sleep(0.005 * (attempt + 1)) 

105 else: 

106 return 

107 

108 

109def _write_atomically(target: Path, response: requests.Response) -> None: 

110 # A private file renamed into place: two downloads of one file never interleave, and a reader never sees half of it. 

111 part = target.with_name(f".{target.name}.{uuid.uuid4().hex}.part") 

112 try: 

113 with part.open("xb") as f: 

114 f.writelines(response.iter_content(chunk_size=8192)) 

115 _replace(part, target) 

116 except BaseException: 

117 part.unlink(missing_ok=True) 

118 raise 

119 

120 

121class WebDavClient: 

122 """A simple WebDav client for listing and downloading files and folders.""" 

123 

124 def __init__(self, hostname: str, username: str, password: str) -> None: 

125 """Initialize a client with credentials and logging output folder.""" 

126 self.hostname: str = hostname 

127 self.username: str = username 

128 self.password: str = password 

129 self.auth: HTTPBasicAuth = HTTPBasicAuth( 

130 username, 

131 password, 

132 ) 

133 

134 self.ensure_folder_exists(str(Path(LOG_FILE).parent)) 

135 _add_log_sink(LOG_FILE) 

136 

137 def _hrefs(self, url: str) -> list[str]: 

138 headers: dict[str, str] = {"Content-Type": "application/xml", "Depth": "1"} 

139 with requests.request( 

140 "PROPFIND", 

141 url, 

142 auth=self.auth, 

143 headers=headers, 

144 timeout=REQUEST_TIMEOUT_SECONDS, 

145 ) as response: 

146 if response.status_code != 207: 

147 message = f"Failed to list {url}: HTTP {response.status_code}" 

148 raise OSError(message) 

149 tree = ElementTree.fromstring(response.content) 

150 hrefs = [] 

151 for item in tree.findall("{DAV:}response"): 

152 href = item.findtext("{DAV:}href") 

153 if not href: 

154 message = "WebDAV listing contains a response without an href" 

155 raise ValueError(message) 

156 hrefs.append(href) 

157 return hrefs 

158 

159 def list_files(self, url: str) -> list[str]: 

160 """List the files directly below a WebDAV URL; OSError unless PROPFIND answers 207.""" 

161 files = [] 

162 for href in self._hrefs(url): 

163 if not href.endswith("/"): 

164 logger.debug("Found file: {} for the following url: {}", href, url) 

165 files.append(href) 

166 return files 

167 

168 def list_folders(self, remote_base_path: str) -> list[str]: 

169 """List the non-hidden folders directly below a remote base path; OSError unless PROPFIND answers 207.""" 

170 url: str = _join_remote_url(self.hostname, remote_base_path) 

171 folders = [] 

172 for href in self._hrefs(url): 

173 if not href.endswith("/"): 

174 continue 

175 relative = _remote_relative_path(href, url) 

176 if not relative: 

177 continue 

178 if len(relative) != 1: 

179 message = "WebDAV folder is not a direct child of its parent" 

180 raise ValueError(message) 

181 if not relative[0].startswith("."): 

182 folders.append(urllib.parse.quote(relative[0], safe="")) 

183 return folders 

184 

185 def filter_after_global_base_path(self, path: str, remote_base_path: str) -> str: 

186 """Return what follows /remote_base_path/ in path, or path unchanged when it is absent.""" 

187 search_str = "/" + remote_base_path + "/" 

188 if search_str in path: 

189 logger.debug( 

190 "Found folder {} for path {} and remote base path: {}", 

191 search_str, 

192 path, 

193 remote_base_path, 

194 ) 

195 url_after_removing_everything_before_and_including_remote_base_name = ( 

196 path.split(search_str, 1)[1] 

197 ) 

198 logger.info( 

199 "Folder structure everything after the remote_base_path is: {}", 

200 url_after_removing_everything_before_and_including_remote_base_name, 

201 ) 

202 return url_after_removing_everything_before_and_including_remote_base_name 

203 logger.error("Could not find search string: {} in path: {}", search_str, path) 

204 return path 

205 

206 def ensure_folder_exists(self, path: str) -> None: 

207 """Ensure that the given folder exists.""" 

208 folder_path = Path(path) 

209 if not folder_path.exists(): 

210 folder_path.mkdir(parents=True, exist_ok=True) 

211 logger.debug("Folder created: {}", path) 

212 else: 

213 logger.debug("Folder already exists: {}", path) 

214 

215 def get_sub_path(self, full_path: str, initial_part: str) -> str: 

216 """Return the part of a host-less full_path after initial_part; ValueError when it is absent.""" 

217 logger.debug("We are in the 'get_sub_path' method.") 

218 decoded_full_path = urllib.parse.unquote(full_path) 

219 logger.debug("The decoded full file path is: {}", decoded_full_path) 

220 decoded_initial_part = urllib.parse.unquote(initial_part) 

221 logger.debug("The decoded initial file path is: {}", decoded_initial_part) 

222 # A trailing slash makes the match a whole path segment. 

223 if not decoded_initial_part.endswith("/"): 

224 decoded_initial_part += "/" 

225 

226 start_idx = decoded_full_path.find(decoded_initial_part) 

227 

228 if start_idx == -1: 

229 logger.error( 

230 "The {} string is not in the full_path={}", initial_part, full_path 

231 ) 

232 error_details = "The full path does not contain the initial part." 

233 raise ValueError(error_details) 

234 

235 # Handle the edge case where the full path is exactly the initial part 

236 decoded_initial_part = "/" + decoded_initial_part 

237 if full_path == decoded_initial_part.rstrip("/"): 

238 logger.debug("The get_sub_path() method returns empty string") 

239 return "" 

240 

241 if full_path.startswith(initial_part): 

242 logger.debug( 

243 "The get_sub_path() method returns {}", full_path[len(initial_part) :] 

244 ) 

245 return full_path[len(initial_part) :] 

246 sub_path_start_idx = start_idx + len(decoded_initial_part) - 1 

247 

248 sub_path = decoded_full_path[sub_path_start_idx:] 

249 

250 logger.debug("The get_sub_path() method returns {}", sub_path) 

251 

252 return sub_path 

253 

254 def download_files( 

255 self, 

256 global_remote_base_path: str, 

257 remote_base_path: str, 

258 local_base_path: str, 

259 *, 

260 extension: str | None = None, 

261 ) -> int: 

262 """Atomically download matching files, preserving paths below the global base; return the count.""" 

263 base = Path(local_base_path).resolve() 

264 base.mkdir(parents=True, exist_ok=True) 

265 global_url = _join_remote_url(self.hostname, global_remote_base_path) 

266 url = _join_remote_url(self.hostname, remote_base_path) 

267 files_on_host = self.list_files(url) 

268 written = 0 

269 for file_path in files_on_host: 

270 relative = _remote_relative_path(file_path, global_url) 

271 local_file_path = _download_target(base, relative) 

272 if extension and not relative[-1].lower().endswith(extension.lower()): 

273 continue 

274 remote_file_url = urllib.parse.urljoin(url.rstrip("/") + "/", file_path) 

275 with requests.get( 

276 remote_file_url, 

277 auth=self.auth, 

278 stream=True, 

279 timeout=REQUEST_TIMEOUT_SECONDS, 

280 ) as response: 

281 if response.status_code != 200: 

282 message = f"Failed to download {remote_file_url}: HTTP {response.status_code}" 

283 raise OSError(message) 

284 local_file_path.parent.mkdir(parents=True, exist_ok=True) 

285 _write_atomically(_download_target(base, relative), response) 

286 written += 1 

287 return written 

288 

289 def download_all_files_iterative( 

290 self, 

291 remote_base_path: str, 

292 local_base_path: str, 

293 *, 

294 extension: str | None = None, 

295 ) -> int: 

296 """Download a tree with an optional case-insensitive suffix; fail on any error and return the count.""" 

297 stack: list[str] = [remote_base_path] 

298 written = 0 

299 global_remote_base_path: str = remote_base_path 

300 

301 while stack: 

302 current_remote_path: str = stack.pop() 

303 logger.debug("Current remote path is: {}", current_remote_path) 

304 

305 written += self.download_files( 

306 global_remote_base_path, 

307 current_remote_path, 

308 local_base_path, 

309 extension=extension, 

310 ) 

311 

312 folders: list[str] = self.list_folders(current_remote_path) 

313 if len(folders) == 0: 

314 logger.info( 

315 "Found no subfolders for current folder: {}", current_remote_path 

316 ) 

317 for folder in folders: 

318 # Folder listings return one encoded component, never a remote path to traverse. 

319 relative = _remote_relative_path( 

320 folder, 

321 _join_remote_url(self.hostname, current_remote_path), 

322 ) 

323 if len(relative) != 1: 

324 message = "WebDAV folder is not a direct child of its parent" 

325 raise ValueError(message) 

326 logger.info( 

327 "Found subfolder {} for current folder: {}.", 

328 folder, 

329 current_remote_path, 

330 ) 

331 relative_folder_path: str = str( 

332 PurePosixPath(current_remote_path) / folder 

333 ) 

334 stack.append(relative_folder_path) 

335 return written