Coverage for ghost/abs_resources.py: 83%

179 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2024-06-17 17:19 +0200

1from __future__ import annotations # allow type hint without actual import 

2 

3import abc 

4import json 

5from abc import ABC 

6from typing import Iterable 

7 

8# noinspection PyUnreachableCode 

9if False: 

10 # prevent circular import 

11 from .client import GhostClient 

12 

13from .exceptions import GhostResourceNotFoundException 

14from .results import GhostResult, GhostResultSet 

15 

16 

17def is_iterable(value): 

18 """ 

19 "If the value is iterable and not a string, return True." 

20 

21 The first part of the function checks if the value is iterable. The second part checks if the value is not a string 

22 

23 Args: 

24 value: The value to check. 

25 

26 Returns: 

27 A boolean value. 

28 """ 

29 return isinstance(value, Iterable) and not isinstance(value, str) 

30 

31 

32class GhostResource(abc.ABC): 

33 single: bool 

34 content: bool 

35 ga: GhostClient 

36 

37 def __repr__(self): 

38 return f"<GhostResource {self.resource}>" 

39 

40 @property 

41 def resource(self): 

42 raise NotImplementedError("Please choose a resource") 

43 

44 @property 

45 def api(self): 

46 raise NotImplementedError("Please choose an api") 

47 

48 def __init__(self, ghost_admin: GhostClient, single=False, content=False): 

49 self.ga = ghost_admin 

50 self.single = single 

51 self.content = content 

52 

53 # def __call__(self, id: str = None, /, **filters): # <- Python 3.8+ 

54 def __call__(self, id: str = None, **filters): 

55 """ 

56 Magic method to make it possible to call something like ghost.pages(tag='sometag') or ghost.page('some id') 

57 instead of ghots.pages.get(tag='sometag') 

58 """ 

59 

60 return self.get(id, **filters) 

61 

62 def _list_join(self, value: list, paren: str = "square"): 

63 """ 

64 Join a list and wrap it in different parentheses 

65 """ 

66 

67 value = ",".join(value) 

68 if not paren: 

69 return value 

70 elif paren == "square": 

71 return f"[{value}]" 

72 elif paren == "round": 

73 return f"({value})" 

74 else: 

75 # todo: other? 

76 raise NotImplementedError(f"Parentheses type '{paren}' not supported.") 

77 

78 def _filters_to_ghost(self, filters: dict): 

79 """ 

80 Ghost requires params to be supplied as strings, 

81 but for developer experience it is much nicer to work with e.g. lists. 

82 This method converts this for ease of use. 

83 

84 

85 Returns: 

86 str: filters joined by + 

87 """ 

88 ghost_filters = [] 

89 

90 for key, value in filters.items(): 

91 if is_iterable(value): 

92 value = self._list_join(value) 

93 

94 ghost_filters.append(f"{key}:{value}") 

95 

96 return "+".join(ghost_filters) 

97 

98 def _create_url(self, path: Iterable): 

99 """ 

100 Build a valid Ghost API endpoint URL. 

101 

102 Args: 

103 path (Iterable): parts of path to combine 

104 

105 Returns: 

106 str: URL path, joined by / 

107 """ 

108 api = "content" if self.content else self.api 

109 return "/".join([api, self.resource, *path]) 

110 

111 def GET(self, *path, **args): 

112 """ 

113 Perform a GET request 

114 (forwarded to the Client passed to this class) 

115 

116 Returns: 

117 GhostResult | GhostResultSet: depending on the request 

118 """ 

119 url = self._create_url(path) 

120 return self.ga.GET(url, **args) 

121 

122 def POST(self, *path, **args): 

123 """ 

124 Perform a POST request 

125 (forwarded to the Client passed to this class) 

126 

127 Returns: 

128 dict: json response 

129 """ 

130 url = self._create_url(path) 

131 return self.ga.POST(url, **args) 

132 

133 def PUT(self, *path, **args): 

134 """ 

135 Perform a PUT request 

136 (forwarded to the Client passed to this class) 

137 

138 Returns: 

139 dict: json reponse 

140 """ 

141 url = self._create_url(path) 

142 return self.ga.PUT(url, **args) 

143 

144 def DELETE(self, *path, **args): 

145 """ 

146 Perform a DELETE request 

147 (forwarded to the Client passed to this class) 

148 

149 Returns: 

150 bool: if successfull 

151 """ 

152 url = self._create_url(path) 

153 return self.ga.DELETE(url, **args) 

154 

155 def _create_args(self, d: dict): 

156 """ 

157 Turn arguments such as fields, page, order, filters etc. into the format Ghost expects 

158 """ 

159 

160 # todo: use Operators (greater than etc), combinations (+ for AND), etc. 

161 d.pop("self") # needed since locals() is passed to this method 

162 args = {} 

163 

164 for key, value in d.items(): 

165 if value is None: 

166 continue 

167 if isinstance(value, dict): 

168 value = self._filters_to_ghost(value) 

169 elif is_iterable(value): 

170 value = self._list_join(value, paren="") 

171 

172 args[key] = value 

173 

174 return args 

175 

176 def _get(self, path: str = "", params: dict = None, single: bool = None): 

177 """ 

178 GET some resource and handle the result(s) 

179 

180 Args: 

181 path (str): The path to the resource you want to get. 

182 params (dict): A dictionary of query parameters to be passed to the API. 

183 single (bool): If True, a GhostResult will be returned, even when the Resource is not single. 

184 Otherwise, a GhostResultSet will be returned. 

185 

186 Returns: 

187 GhostResult | GhostResultSet 

188 """ 

189 

190 if params is None: 

191 params = {} 

192 

193 resp = self.GET(path, params=params) 

194 data = resp.get(self.resource) 

195 

196 if not data and self.single: 

197 raise GhostResourceNotFoundException(200, "Resource Not Found", path) 

198 

199 if self.single or single: 

200 return GhostResult(data[0] if isinstance(data, list) else data, self) 

201 else: 

202 return GhostResultSet( 

203 data, 

204 self, 

205 meta=resp["meta"], 

206 request={ 

207 "path": path, 

208 "params": params, 

209 "single": single, 

210 }, 

211 ) 

212 

213 def paginate(self, *, per: int = 25, **filters): 

214 """ 

215 Generator that yields all the data for this resource 

216 

217 Args: 

218 per (int): The number of results to return per page. Defaults to 25 

219 filters: modifiers passed to the GET request 

220 

221 Yields: 

222 GhostResult: items matching the supplied filters 

223 """ 

224 

225 data = True 

226 page = 1 

227 filters["limit"] = per 

228 

229 while data: 

230 filters["page"] = page 

231 try: 

232 data = self.get(**filters) 

233 for d in data: 

234 yield d 

235 except GhostResourceNotFoundException: 

236 break 

237 page += 1 

238 

239 def _get_by_id(self, id: str, **_params): 

240 """ 

241 Get a specific instance of this resource, by ID. 

242 

243 Args: 

244 id (str): The id of the item to retrieve. 

245 params: modifiers such as 'fields' to limit which columns to get 

246 

247 Returns: 

248 GhostResult: item with id 

249 """ 

250 

251 params = {"formats": "html,mobiledoc", **_params} 

252 return self._get(id, params, single=True) 

253 

254 def _get_by_filters( 

255 self, 

256 limit: int = None, 

257 page: int = None, 

258 order: str = None, 

259 fields: list = None, 

260 **filter, 

261 ): 

262 """ 

263 Get resource items matching filter 

264 

265 Args: 

266 limit (int): The number of results to return. 

267 page (int): The page number of the results to return. 

268 order (str): The order in which to return the posts. 

269 fields (list): A list of fields to include in the response. 

270 filter: a dictionary of key-value pairs to filter by (e.g. author, slug, etc.) 

271 

272 Returns: 

273 GhostResultSet: 

274 """ 

275 args = self._create_args(locals()) 

276 args["formats"] = "html,mobiledoc" 

277 

278 return self._get(params=args) 

279 

280 # def get(self, id: str = None, /, **filters): # <- Python 3.8+ 

281 def get(self, id: str = None, **filters): 

282 """ 

283 Either get 

284 

285 Args: 

286 id (str): if the ID of the item is known 

287 filters: parameters to filter data on. 

288 See https://ghost.org/docs/admin-api/#parameters and https://ghost.org/docs/content-api/#parameters 

289 for more info 

290 

291 Returns: 

292 GhostResult | GhostResultSet: depending on if 'single' is used. 

293 """ 

294 

295 if id is not None: 

296 return self._get_by_id(id) 

297 elif "slug" in filters and len(filters) == 1: 

298 return self._get_by_id(f"slug/{filters['slug']}") 

299 else: 

300 if "fields" in filters: 

301 if "id" not in filters["fields"]: 

302 filters["fields"].append("id") 

303 

304 if "updated_at" not in filters["fields"]: 

305 filters["fields"].append("updated_at") 

306 

307 return self._get_by_filters(**filters) 

308 

309 def delete(self, *_, **__): 

310 raise NotImplementedError("Implement this in the Admin Resources") 

311 

312 def update(self, *_, **__): 

313 raise NotImplementedError("Implement this in the Admin Resources") 

314 

315 def create(self, *_, **__): 

316 raise NotImplementedError("Implement this in the Admin Resources") 

317 

318 

319class GhostAdminResource(GhostResource, ABC): 

320 api = "admin" 

321 

322 def __md_card(self, md: str, idx: int = 0): 

323 """ 

324 Generate a mobiledoc card for markdown 

325 

326 Args: 

327 md (str): markdown text 

328 idx (int): index, used to give cards a unique cardName 

329 

330 Returns: 

331 list: mobiledoc formatted card 

332 """ 

333 return [ 

334 "markdown", 

335 {"cardName": f"markdown-{idx}", "markdown": md}, 

336 ] 

337 

338 def _transform_markdown(self, item: dict): 

339 """ 

340 Allow developers to use markdown with mobiledoc more easily. 

341 Currently not implemented as only one block of markdown shows up in Ghost instead of everything. 

342 

343 Args: 

344 item (dict): the resource to be created. The value of markdown in item can be a string or a list of strings. 

345 

346 Returns: 

347 None: this method only edits item 

348 """ 

349 if item.get("markdown"): 

350 raise NotImplementedError( 

351 "Creating posts with markdown is currently not yet supported." 

352 ) 

353 md = item["markdown"] 

354 

355 if is_iterable(md): 

356 cards = [self.__md_card(_, i) for i, _ in enumerate(md)] 

357 else: 

358 cards = [self.__md_card(md)] 

359 

360 item["mobiledoc"] = json.dumps( 

361 { 

362 "version": "0.3.1", 

363 "markups": [], 

364 "atoms": [], 

365 "cards": cards, 

366 "sections": [[10, 0]], 

367 } 

368 ) 

369 

370 del item["markdown"] 

371 

372 def _create_multiple(self, items: list): 

373 raise NotImplementedError("Can only create one item at a time!") 

374 data = {self.resource: items} 

375 

376 return self.POST(json=data) 

377 

378 def _create_one(self, item: dict): 

379 """ 

380 Wrapper to create a new item of this resource 

381 """ 

382 

383 self._transform_markdown(item) 

384 

385 data = {self.resource: [item]} 

386 

387 if item.get("html"): 

388 params = {"source": "html"} 

389 # elif item.get('mobiledoc'): 

390 # params = {"source": "mobiledoc"} 

391 else: 

392 params = {} 

393 

394 return self.POST(params=params, json=data) 

395 

396 def create(self, *a, **kw): 

397 """ 

398 Create one or more new items. 

399 One item is created if kwargs are used: 

400 e.g. ghost.posts.create(title="something") 

401 

402 Multiple items are created if args is used: 

403 e.g. ghost.posts.create({...}, {...}) 

404 

405 

406 """ 

407 

408 if a and kw: 

409 raise ValueError( 

410 "Please use either only arguments or only keyword arguments." 

411 ) 

412 elif a: 

413 # return self._create_multiple(a) 

414 return [self._create_one(_) for _ in a] 

415 else: # kw 

416 # return self._create_multiple([kw]) 

417 return self._create_one(kw) 

418 

419 def _delete_by_id(self, id: str): 

420 """ 

421 Delete a specific item by ID 

422 """ 

423 return self.DELETE(id) 

424 

425 def _delete_by_filters(self, filters): 

426 """ 

427 Find all items matching filters and delete these 

428 

429 Returns: 

430 list[bool]: success of each delete 

431 """ 

432 try: 

433 if not filters.get("limit"): 

434 filters["limit"] = "all" 

435 

436 ids = self._get_by_filters(**filters) 

437 if not ids: 

438 return [] 

439 

440 return [self._delete_by_id(_["id"]) for _ in ids] 

441 except GhostResourceNotFoundException: 

442 return [] 

443 

444 # def delete(self, id=None, /, **filters): # <- Python 3.8+ 

445 def delete(self, id=None, **filters): 

446 """ 

447 Delete either one item if 'id' is supplied or all items matching filters 

448 """ 

449 

450 if id is not None: 

451 return self._delete_by_id(id) 

452 else: 

453 filters["fields"] = "id" # not more is needed for delete 

454 return self._delete_by_filters(filters=filters) 

455 

456 def _update_by_id(self, id: str, data: dict, old=None): 

457 """ 

458 PUT new data for ID 

459 

460 Args: 

461 id (str): item to update 

462 data (dict): data to update 

463 old (dict|GhostResult): required for the old updated_at 

464 

465 Returns: 

466 dict: response data 

467 """ 

468 if old is None: 

469 old = self._get_by_id(id, fields=["updated_at"]) 

470 

471 data["updated_at"] = old["updated_at"] 

472 

473 return self.PUT(id, json={self.resource: [data]}) 

474 

475 def _update_by_filters(self, data: dict, filters: dict): 

476 """ 

477 For each item matching filters, update its data 

478 

479 Returns: 

480 list[dict]: result of each PUT 

481 """ 

482 

483 try: 

484 if not filters.get("limit"): 

485 filters["limit"] = "all" 

486 

487 ids = self._get_by_filters(**filters, fields=["id", "updated_at"]) 

488 return [self._update_by_id(old["id"], data, old) for old in ids] 

489 except GhostResourceNotFoundException: 

490 return [] 

491 

492 # def update(self, id: str = None, data: dict = None, old=None, /, **filters): # <- Python 3.8+ 

493 def update(self, id: str = None, data: dict = None, old=None, **filters): 

494 """ 

495 Update either one item if 'id' is supplied or all items matching filters. 

496 

497 Args: 

498 id (str): one item to update 

499 data (dict): new data 

500 old (dict|GhostResult): the old updated at is required to update a post. 

501 If old is not supplied, it will be GET-requested before updating. 

502 filters: to find items 

503 

504 Returns: 

505 dict | list[dict]: response(s) from Ghost 

506 """ 

507 

508 if data is None: 

509 raise ValueError("Please include new and old values in data!") 

510 

511 if id is not None: 

512 return self._update_by_id(id, data=data, old=old) 

513 else: 

514 return self._update_by_filters(data=data, filters=filters) 

515 

516 

517class GhostContentResource(GhostResource, ABC): 

518 api = "content"