Coverage for icloudpy/services/drive.py: 100%

183 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-15 15:10 +0000

1"""Drive service.""" 

2 

3import io 

4import json 

5import mimetypes 

6import os 

7import time 

8from datetime import datetime, timedelta 

9from re import search 

10 

11from requests import Response 

12 

13 

14class DriveService: 

15 """The 'Drive' iCloud service.""" 

16 

17 def __init__(self, service_root, document_root, session, params): 

18 self._service_root = service_root 

19 self._document_root = document_root 

20 self.session = session 

21 self.params = dict(params) 

22 self._root = None 

23 

24 def _get_token_from_cookie(self): 

25 for cookie in self.session.cookies: 

26 if cookie.name == "X-APPLE-WEBAUTH-VALIDATE": 

27 match = search(r"\bt=([^:]+)", cookie.value) 

28 if match is None: 

29 raise Exception(f"Can't extract token from {cookie.value}") 

30 return {"token": match.group(1)} 

31 raise Exception("Token cookie not found") 

32 

33 def get_node_data(self, drivewsid): 

34 """Returns the node data.""" 

35 request = self.session.post( 

36 self._service_root + "/retrieveItemDetailsInFolders", 

37 params=self.params, 

38 data=json.dumps( 

39 [ 

40 { 

41 "drivewsid": drivewsid, 

42 "partialData": False, 

43 }, 

44 ], 

45 ), 

46 ) 

47 if not request.ok: 

48 self.session.raise_error(request.status_code, request.reason) 

49 return request.json()[0] 

50 

51 def get_file(self, file_id, zone="com.apple.CloudDocs", **kwargs): 

52 """Returns iCloud Drive file.""" 

53 file_params = dict(self.params) 

54 file_params.update({"document_id": file_id}) 

55 # The lookup and the transfer are separate requests. A timeout given 

56 # only to the transfer leaves the lookup free to stall its thread 

57 # forever, so forward it here too. ``stream`` is deliberately not 

58 # forwarded: the lookup is a small JSON reply read in full. 

59 lookup_kwargs = {"timeout": kwargs["timeout"]} if "timeout" in kwargs else {} 

60 response = self.session.get( 

61 self._document_root + f"/ws/{zone}/download/by_id", 

62 params=file_params, 

63 **lookup_kwargs, 

64 ) 

65 if not response.ok: 

66 self.session.raise_error(response.status_code, response.reason) 

67 package_token = response.json().get("package_token") 

68 data_token = response.json().get("data_token") 

69 if data_token and data_token.get("url"): 

70 return self.session.get(data_token["url"], params=self.params, **kwargs) 

71 elif package_token and package_token.get("url"): 

72 return self.session.get(package_token["url"], params=self.params, **kwargs) 

73 else: 

74 raise KeyError("'data_token' nor 'package_token' found in response.") 

75 

76 def get_app_data(self): 

77 """Returns the app library.""" 

78 request = self.session.get( 

79 self._service_root + "/retrieveAppLibraries", 

80 params=self.params, 

81 ) 

82 if not request.ok: 

83 self.session.raise_error(request.status_code, request.reason) 

84 return request.json()["items"] 

85 

86 def get_app_node(self, app_id, folder="documents"): 

87 """Returns the node of the app.""" 

88 return DriveNode(self, self.get_node_data("FOLDER::" + app_id + "::" + folder)) 

89 

90 def _get_upload_contentws_url(self, file_object, zone="com.apple.CloudDocs"): 

91 """Get the contentWS endpoint URL to add a new file.""" 

92 content_type = mimetypes.guess_type(file_object.name)[0] 

93 if content_type is None: 

94 content_type = "" 

95 

96 # Get filesize from file object 

97 orig_pos = file_object.tell() 

98 file_object.seek(0, os.SEEK_END) 

99 file_size = file_object.tell() 

100 file_object.seek(orig_pos, os.SEEK_SET) 

101 

102 file_params = self.params 

103 file_params.update(self._get_token_from_cookie()) 

104 

105 request = self.session.post( 

106 self._document_root + f"/ws/{zone}/upload/web", 

107 params=file_params, 

108 headers={"Content-Type": "text/plain"}, 

109 data=json.dumps( 

110 { 

111 "filename": file_object.name, 

112 "type": "FILE", 

113 "content_type": content_type, 

114 "size": file_size, 

115 }, 

116 ), 

117 ) 

118 if not request.ok: 

119 self.session.raise_error(request.status_code, request.reason) 

120 return (request.json()[0]["document_id"], request.json()[0]["url"]) 

121 

122 def _update_contentws( 

123 self, 

124 folder_id, 

125 sf_info, 

126 document_id, 

127 file_object, 

128 zone="com.apple.CloudDocs", 

129 ): 

130 data = { 

131 "data": { 

132 "signature": sf_info["fileChecksum"], 

133 "wrapping_key": sf_info["wrappingKey"], 

134 "reference_signature": sf_info["referenceChecksum"], 

135 "size": sf_info["size"], 

136 }, 

137 "command": "add_file", 

138 "create_short_guid": True, 

139 "document_id": document_id, 

140 "path": { 

141 "starting_document_id": folder_id, 

142 "path": os.path.basename(file_object.name), 

143 }, 

144 "allow_conflict": True, 

145 "file_flags": { 

146 "is_writable": True, 

147 "is_executable": False, 

148 "is_hidden": False, 

149 }, 

150 "mtime": int(time.time() * 1000), 

151 "btime": int(time.time() * 1000), 

152 } 

153 

154 # Add the receipt if we have one. Will be absent for 0-sized files 

155 if sf_info.get("receipt"): 

156 data["data"].update({"receipt": sf_info["receipt"]}) 

157 

158 request = self.session.post( 

159 self._document_root + f"/ws/{zone}/update/documents", 

160 params=self.params, 

161 headers={"Content-Type": "text/plain"}, 

162 data=json.dumps(data), 

163 ) 

164 if not request.ok: 

165 self.session.raise_error(request.status_code, request.reason) 

166 return request.json() 

167 

168 def send_file(self, folder_id, file_object, zone="com.apple.CloudDocs"): 

169 """Send new file to iCloud Drive.""" 

170 document_id, content_url = self._get_upload_contentws_url(file_object, zone) 

171 

172 request = self.session.post(content_url, files={file_object.name: file_object}) 

173 if not request.ok: 

174 self.session.raise_error(request.status_code, request.reason) 

175 content_response = request.json()["singleFile"] 

176 

177 self._update_contentws( 

178 folder_id, 

179 content_response, 

180 document_id, 

181 file_object, 

182 zone, 

183 ) 

184 

185 def create_folders(self, parent, name): 

186 """Creates a new iCloud Drive folder""" 

187 request = self.session.post( 

188 self._service_root + "/createFolders", 

189 params=self.params, 

190 headers={"Content-Type": "text/plain"}, 

191 data=json.dumps( 

192 { 

193 "destinationDrivewsId": parent, 

194 "folders": [ 

195 { 

196 "clientId": self.params["clientId"], 

197 "name": name, 

198 }, 

199 ], 

200 }, 

201 ), 

202 ) 

203 return request.json() 

204 

205 def rename_items(self, node_id, etag, name): 

206 """Renames an iCloud Drive node""" 

207 request = self.session.post( 

208 self._service_root + "/renameItems", 

209 params=self.params, 

210 data=json.dumps( 

211 { 

212 "items": [ 

213 { 

214 "drivewsid": node_id, 

215 "etag": etag, 

216 "name": name, 

217 }, 

218 ], 

219 }, 

220 ), 

221 ) 

222 return request.json() 

223 

224 def move_items_to_trash(self, node_id, etag): 

225 """Moves an iCloud Drive node to the trash bin""" 

226 request = self.session.post( 

227 self._service_root + "/moveItemsToTrash", 

228 params=self.params, 

229 data=json.dumps( 

230 { 

231 "items": [ 

232 { 

233 "drivewsid": node_id, 

234 "etag": etag, 

235 "clientId": self.params["clientId"], 

236 }, 

237 ], 

238 }, 

239 ), 

240 ) 

241 if not request.ok: 

242 self.session.raise_error(request.status_code, request.reason) 

243 return request.json() 

244 

245 @property 

246 def root(self): 

247 """Returns the root node.""" 

248 if not self._root: 

249 self._root = DriveNode( 

250 self, 

251 self.get_node_data("FOLDER::com.apple.CloudDocs::root"), 

252 ) 

253 return self._root 

254 

255 def __getattr__(self, attr): 

256 return getattr(self.root, attr) 

257 

258 def __getitem__(self, key): 

259 return self.root[key] 

260 

261 

262class DriveNode: 

263 """Drive node.""" 

264 

265 def __init__(self, conn, data): 

266 self.data = data 

267 self.connection = conn 

268 self._children = None 

269 

270 @property 

271 def name(self): 

272 """Gets the node name.""" 

273 if "extension" in self.data: 

274 return f"{self.data['name']}.{self.data['extension']}" 

275 return self.data["name"] 

276 

277 @property 

278 def type(self): 

279 """Gets the node type.""" 

280 node_type = self.data.get("type") 

281 return node_type and node_type.lower() 

282 

283 def get_children(self): 

284 """Gets the node children.""" 

285 if not self._children: 

286 if "items" not in self.data: 

287 self.data.update(self.connection.get_node_data(self.data["drivewsid"])) 

288 if "items" not in self.data: 

289 raise KeyError(f"No items in folder, status: {self.data['status']}") 

290 self._children = [DriveNode(self.connection, item_data) for item_data in self.data["items"]] 

291 return self._children 

292 

293 @property 

294 def size(self): 

295 """Gets the node size.""" 

296 size = self.data.get("size") # Folder does not have size 

297 if not size: 

298 return None 

299 return int(size) 

300 

301 @property 

302 def date_created(self): 

303 """Gets the node created date (in UTC).""" 

304 return _date_to_utc(self.data.get("dateCreated")) 

305 

306 @property 

307 def date_changed(self): 

308 """Gets the node changed date (in UTC).""" 

309 return _date_to_utc(self.data.get("dateChanged")) # Folder does not have date 

310 

311 @property 

312 def date_modified(self): 

313 """Gets the node modified date (in UTC).""" 

314 return _date_to_utc(self.data.get("dateModified")) # Folder does not have date 

315 

316 @property 

317 def date_last_open(self): 

318 """Gets the node last open date (in UTC).""" 

319 return _date_to_utc(self.data.get("lastOpenTime")) # Folder does not have date 

320 

321 def open(self, **kwargs): 

322 """Gets the node file.""" 

323 # iCloud returns 400 Bad Request for 0-byte files 

324 if self.data["size"] == 0: 

325 response = Response() 

326 response.raw = io.BytesIO() 

327 return response 

328 return self.connection.get_file( 

329 self.data["docwsid"], 

330 zone=self.data["zone"], 

331 **kwargs, 

332 ) 

333 

334 def upload(self, file_object, **kwargs): 

335 """ "Upload a new file.""" 

336 return self.connection.send_file( 

337 self.data["docwsid"], 

338 file_object, 

339 zone=self.data["zone"], 

340 **kwargs, 

341 ) 

342 

343 def dir(self): 

344 """Gets the node list of directories.""" 

345 if self.type == "file": 

346 return None 

347 return [child.name for child in self.get_children()] 

348 

349 def mkdir(self, folder): 

350 """Create a new directory directory.""" 

351 # remove cached entries information first so that it will be re-read on next get_children() 

352 self._children = None 

353 if "items" in self.data: 

354 self.data.pop("items") 

355 return self.connection.create_folders(self.data["drivewsid"], folder) 

356 

357 def rename(self, name): 

358 """Rename an iCloud Drive item.""" 

359 return self.connection.rename_items( 

360 self.data["drivewsid"], 

361 self.data["etag"], 

362 name, 

363 ) 

364 

365 def delete(self): 

366 """Delete an iCloud Drive item.""" 

367 return self.connection.move_items_to_trash( 

368 self.data["drivewsid"], 

369 self.data["etag"], 

370 ) 

371 

372 def get(self, name): 

373 """Gets the node child.""" 

374 if self.type == "file": 

375 return None 

376 return [child for child in self.get_children() if child.name == name][0] 

377 

378 def __getitem__(self, key): 

379 try: 

380 return self.get(key) 

381 except IndexError as error: 

382 raise KeyError(f"No child named '{key}' exists") from error 

383 

384 def __unicode__(self): 

385 return f"{{type: {self.type}, name: {self.name}}}" 

386 

387 def __str__(self): 

388 return self.__unicode__() 

389 

390 def __repr__(self): 

391 return f"<{type(self).__name__}: {str(self)}>" 

392 

393 

394def _date_to_utc(date): 

395 if not date: 

396 return None 

397 # jump through hoops to return time in UTC rather than California time 

398 match = search(r"^(.+?)([\+\-]\d+):(\d\d)$", date) 

399 if not match: 

400 # Already in UTC 

401 return datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") 

402 base = datetime.strptime(match.group(1), "%Y-%m-%dT%H:%M:%S") 

403 diff = timedelta(hours=int(match.group(2)), minutes=int(match.group(3))) 

404 return base - diff