Coverage for ghost/abs_resources.py: 83%

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

2

3 import abc

4 import json

5 from abc import ABC

6 from typing import Iterable

7

8# noinspection PyUnreachableCode

9 if False:

10 # prevent circular import

11 from .client import GhostClient

12

13 from .exceptions import GhostResourceNotFoundException

14 from .results import GhostResult, GhostResultSet

15

16

17 def 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

32 class 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

319 class 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

517 class GhostContentResource(GhostResource, ABC):

518 api = "content"