wordpress-markdown-blog-loader 1.6.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,32 @@
1
+ import logging
2
+ import os
3
+ import click
4
+ from wordpress_markdown_blog_loader import upload, download, new, check_links
5
+
6
+
7
+ @click.group()
8
+ def main():
9
+ """
10
+ Wordpress CLI
11
+ """
12
+ logging.basicConfig(
13
+ level=os.getenv("LOG_LEVEL", "INFO"), format="%(levelname)s: %(message)s"
14
+ )
15
+
16
+
17
+ @main.group()
18
+ def posts():
19
+ """
20
+ Wordpress posts up- and download
21
+ """
22
+
23
+
24
+ posts.add_command(upload.command)
25
+ posts.add_command(download.command)
26
+ posts.add_command(new.command)
27
+ posts.add_command(new.update_banner_command)
28
+ posts.add_command(check_links.command)
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1,658 @@
1
+ import configparser
2
+ import json
3
+ import logging
4
+ import mimetypes
5
+ import os
6
+ import re
7
+ import sys
8
+ from datetime import datetime
9
+ from functools import cache
10
+ from os.path import expanduser
11
+ from pathlib import Path
12
+ from typing import List, Dict, Iterator
13
+ from typing import Optional, Union
14
+ from urllib.parse import urlparse, ParseResult
15
+
16
+ import pytz
17
+ import requests
18
+
19
+
20
+ def get_default_host() -> Optional[str]:
21
+ """
22
+ returns the default api host from .wordpress.ini and/or ~/.wordpress.ini
23
+ """
24
+ config = configparser.ConfigParser()
25
+ config.read([".wordpress.ini", expanduser("~/.wordpress.ini")])
26
+ return config.defaults().get("host")
27
+
28
+
29
+ def get_password(host: str) -> Optional[str]:
30
+ """
31
+ Returns a password from the environment variable for the specified host.
32
+
33
+ The default environment variable is WP_APP_PASSWORD. A password for a specific host can be
34
+ specified by setting an environment variable WP_APP_PASSWORD_<host> where
35
+ the host name is all uppercase and non characters and digits replaced with _.
36
+
37
+ If not set, None is returned.
38
+
39
+ >>> os.environ["WP_APP_PASSWORD_XEBIA_COM"] = "host_password"
40
+ >>> get_password('xebia.com')
41
+ 'host_password'
42
+ >>> get_password('binx.io') is None
43
+ True
44
+ >>> os.environ["WP_APP_PASSWORD"] = "default_password"
45
+ >>> get_password('binx.io')
46
+ 'default_password'
47
+ """
48
+ h = re.sub(r"[^a-zA-Z-0-9]", "_", host).upper()
49
+ if result := os.getenv(f"WP_APP_PASSWORD_{h}"):
50
+ return result
51
+
52
+ return os.getenv("WP_APP_PASSWORD")
53
+
54
+
55
+ class WordpressEndpoint:
56
+ def __init__(self, **kwargs):
57
+ self.host = None
58
+ self.api_host = None
59
+ self.url = None
60
+ self.username = None
61
+ self.password = None
62
+ for key in self.__dict__.keys():
63
+ self.__setattr__(key, kwargs[key] if key in kwargs else None)
64
+
65
+ def read_from_config(self, host: str):
66
+ config = configparser.ConfigParser()
67
+ config.read([".wordpress.ini", expanduser("~/.wordpress.ini")])
68
+
69
+ if not host:
70
+ host = config.defaults().get("host")
71
+ assert host, "no host specified and no default host found"
72
+
73
+ self.host = host
74
+ self.api_host = config.get(host, "api_host", fallback=self.host)
75
+ self.url = f"https://{self.api_host}/wp-json/wp/v2"
76
+
77
+ self.username = config.get(host, "username")
78
+ self.password = get_password(host)
79
+ if not self.password:
80
+ logging.warning(
81
+ "taking plain text app password from configuration file, use WP_APP_PASSWORD environment variable instead!"
82
+ )
83
+ self.password = config.get(host, "password")
84
+
85
+ def is_host_for(self, url: Union[str, ParseResult]) -> bool:
86
+ result = url if isinstance(url, ParseResult) else urlparse(url)
87
+ return result.hostname in [self.host, self.api_host]
88
+
89
+ def normalize_url(self, url: str) -> str:
90
+ """
91
+ >>> WordpressEndpoint(host="binx.io", api_host="binx.wpengine.com").normalize_url("https://binx.io/bla")
92
+ 'https://binx.wpengine.com/bla'
93
+ >>> WordpressEndpoint(host="binx.io", api_host="binx.wpengine.com").normalize_url("https://xebia.com/bla")
94
+ 'https://xebia.com/bla'
95
+ >>> WordpressEndpoint(host="binx.io", api_host="binx.wpengine.com").normalize_url("https://binx.io:443/bla")
96
+ 'https://binx.wpengine.com:443/bla'
97
+ """
98
+ if self.host == self.api_host:
99
+ return url
100
+
101
+ parsed_url = urlparse(url)
102
+ if parsed_url.hostname == self.host:
103
+ return parsed_url._replace(
104
+ netloc=parsed_url.netloc.replace(self.host, self.api_host)
105
+ ).geturl()
106
+
107
+ return url
108
+
109
+ @staticmethod
110
+ def load(host: str = None) -> "WordpressEndpoint":
111
+ result = WordpressEndpoint()
112
+ result.read_from_config(host)
113
+ return result
114
+
115
+
116
+ class User(dict):
117
+ def __init__(self, u):
118
+ self.update(u)
119
+
120
+ @property
121
+ def name(self):
122
+ return self.get("name")
123
+
124
+ @property
125
+ def id(self):
126
+ return self.get("id")
127
+
128
+ @property
129
+ def email(self):
130
+ return self.get("email")
131
+
132
+ @property
133
+ def slug(self):
134
+ return self.get("slug")
135
+
136
+
137
+ class Medium(dict):
138
+ def __init__(self, properties: dict):
139
+ self.update(properties)
140
+
141
+ @property
142
+ def medium_id(self):
143
+ return int(self["id"])
144
+
145
+ @property
146
+ def url(self) -> str:
147
+ return self.get("guid").get("rendered")
148
+
149
+ @property
150
+ def link(self) -> str:
151
+ return self["link"]
152
+
153
+ @property
154
+ def slug(self) -> str:
155
+ return self.get("slug")
156
+
157
+ @property
158
+ def title(self) -> Optional[str]:
159
+ return self.get("title", {}).get("rendered")
160
+
161
+
162
+ class Post(dict):
163
+ def __init__(self, p):
164
+ self.update(p)
165
+
166
+ @property
167
+ def slug(self):
168
+ return self["slug"] if self["slug"] else self.get("generated_slug")
169
+
170
+ @property
171
+ def post_id(self) -> id:
172
+ return int(self["id"])
173
+
174
+ @property
175
+ def link(self):
176
+ return self["link"]
177
+
178
+ @property
179
+ def guid(self) -> str:
180
+ return self["_links"]["self"][0]["href"]
181
+
182
+ @property
183
+ def title(self):
184
+ r = self["title"]["rendered"]
185
+ return r.replace("&#8211;", "-")
186
+
187
+ @property
188
+ def featured_media(self) -> Optional[int]:
189
+ return self.get("featured_media")
190
+
191
+ @featured_media.setter
192
+ def featured_media(self, medium_id: int):
193
+ self["featured_media"] = medium_id
194
+
195
+ @property
196
+ def content(self) -> Optional[str]:
197
+ return self.get("content", {}).get("rendered")
198
+
199
+ @property
200
+ def raw_content(self) -> Optional[str]:
201
+ return self.get("content", {}).get("raw")
202
+
203
+ @property
204
+ def categories(self) -> list[id]:
205
+ return self.get("categories", [])
206
+
207
+ @property
208
+ def industries_taxonomy(self) -> list[id]:
209
+ return self.get("industries_taxonomy", [])
210
+
211
+ @property
212
+ def partners_taxonomy(self) -> list[id]:
213
+ return self.get("partners_taxonomy", [])
214
+
215
+ @property
216
+ def capabilities(self) -> list[id]:
217
+ return self.get("capabilities", [])
218
+
219
+ @property
220
+ def tags(self) -> list[int]:
221
+ return self.get("tags", [])
222
+
223
+ @property
224
+ def author(self) -> int:
225
+ return self["author"]
226
+
227
+ @property
228
+ def excerpt(self) -> Optional[str]:
229
+ return self.get("excerpt", {}).get("rendered")
230
+
231
+ @excerpt.setter
232
+ def excerpt(self, exerpt):
233
+ self["excerpt"] = exerpt
234
+
235
+ @property
236
+ def date(self) -> datetime:
237
+ return (
238
+ pytz.timezone("utc")
239
+ .localize(datetime.fromisoformat(self.get("date_gmt")))
240
+ .astimezone()
241
+ )
242
+
243
+ @date.setter
244
+ def date(self, new_date: datetime):
245
+ self["date"] = new_date
246
+ self["date_gmt"] = new_date.astimezone(pytz.timezone("utc"))
247
+
248
+ @property
249
+ def status(self):
250
+ return self["status"]
251
+
252
+ @status.setter
253
+ def status(self, new_status):
254
+ self["status"] = new_status
255
+
256
+ @property
257
+ def og_images(self) -> list[ParseResult]:
258
+ """
259
+ returns urls to the og:image links
260
+ """
261
+ result = []
262
+ for name in ["rank_math_twitter_image", "rank_math_facebook_image"]:
263
+ if image := self.get("meta", {}).get(name):
264
+ result.append(urlparse(image))
265
+ return result
266
+
267
+ @property
268
+ def og_description(self) -> Optional[str]:
269
+ return self.get("meta", {}).get("rank_math_facebook_description")
270
+
271
+ @property
272
+ def permalink_template(self) -> Optional[str]:
273
+ return self.get("permalink_template")
274
+
275
+ @permalink_template.setter
276
+ def permalink_template(self, template: str):
277
+ self["permalink_template"] = template
278
+
279
+
280
+ class PermissionDenied(Exception):
281
+ def __init__(self, msg):
282
+ super().__init__(msg)
283
+
284
+
285
+ class Wordpress(object):
286
+ def __init__(self, host: Optional[str] = None):
287
+ self.endpoint = WordpressEndpoint.load(host)
288
+
289
+ self._media: List[Medium] = {}
290
+ self.headers = {
291
+ "accept": "application/json",
292
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
293
+ }
294
+ self.session = requests.Session()
295
+
296
+ @property
297
+ def auth(self) -> (str, str):
298
+ return (self.endpoint.username, self.endpoint.password)
299
+
300
+ @property
301
+ def url(self) -> str:
302
+ return self.endpoint.url
303
+
304
+ def normalize_url(self, url: str) -> str:
305
+ return self.endpoint.normalize_url(url)
306
+
307
+ def is_host_for(self, url: Union[str, ParseResult]) -> bool:
308
+ return self.endpoint.is_host_for(url)
309
+
310
+ def get_all(self, resource: str, query: dict = None) -> Iterator[dict]:
311
+ params = query.copy() if query else {}
312
+ params["per_page"] = "100"
313
+ page = 1
314
+ total_pages = 1
315
+ while page <= total_pages:
316
+ params["page"] = str(page)
317
+ response = self.session.get(
318
+ f"{self.url}/{resource}",
319
+ auth=self.auth,
320
+ params=params,
321
+ headers=self.headers,
322
+ )
323
+ if response.status_code == 200:
324
+ for object in response.json():
325
+ yield object
326
+ total_pages = int(response.headers["X-WP-TotalPages"])
327
+ page = page + 1
328
+ else:
329
+ msg = f"failed to get all {resource}: {response.status_code}, {response.text}"
330
+ if response.status_code in [401, 403]:
331
+ raise PermissionDenied(msg)
332
+ else:
333
+ print(msg)
334
+ exit(1)
335
+
336
+ return None
337
+
338
+ def users(self, query: dict = None) -> List["User"]:
339
+ return list(map(lambda u: User(u), self.get_all("users", query)))
340
+
341
+ def get_user_by_id(self, resource_id: int) -> "User":
342
+ return User(self.get("users", resource_id))
343
+
344
+ def get_unique_user_by_name(
345
+ self, name: str, email: Optional[str], author_id: Optional[str]
346
+ ) -> "User":
347
+
348
+ user = self.get_user_by_id("me")
349
+ if user and user.name == name:
350
+ return user
351
+
352
+ users = []
353
+ try:
354
+ # The email field is only returned when the context edit is passed.
355
+ # See: https://developer.wordpress.org/rest-api/reference/users/
356
+ users = self.users({"search": name, "context": "edit"})
357
+ except PermissionDenied as error:
358
+ logging.warning("Permission denied to read user email addresses")
359
+ users = self.users({"search": name})
360
+
361
+ if len(users) == 0:
362
+ raise ValueError(f"author '{name}' not found on {self.endpoint.host}")
363
+ elif len(users) == 1:
364
+ return users[0]
365
+
366
+ if user := next(
367
+ filter(
368
+ lambda u: (author_id and u.slug == author_id)
369
+ or (
370
+ not author_id
371
+ and email
372
+ and u.email
373
+ and u.email.lower() == email.lower()
374
+ ),
375
+ users,
376
+ ),
377
+ None,
378
+ ):
379
+ return user
380
+
381
+ candidates = ", ".join(["{} / {}".format(u.slug, u.email) for u in users])
382
+ if author_id:
383
+ raise ValueError(
384
+ f"Multiple authors named '{name}' found, none with author id {author_id} (possible: {candidates})."
385
+ )
386
+ elif email:
387
+ raise ValueError(
388
+ f"Multiple authors named '{name}' found, but none with email {email}. (possible: {candidates})."
389
+ )
390
+ else:
391
+ raise ValueError(
392
+ f"Multiple authors named '{name}' found. (possible: {candidates})."
393
+ )
394
+
395
+ def posts(self, query: dict = None) -> Iterator["Post"]:
396
+ for p in self.get_all("posts", query):
397
+ yield Post(p)
398
+
399
+ def get_post_by_slug(self, slug: str) -> Optional["Post"]:
400
+ return next(
401
+ filter(
402
+ lambda p: p.slug == slug,
403
+ (
404
+ map(
405
+ lambda u: Post(u),
406
+ self.get_all(
407
+ "posts", {"status": "draft,publish,pending", "slug": slug}
408
+ ),
409
+ )
410
+ ),
411
+ ),
412
+ None,
413
+ )
414
+
415
+ def media(self) -> List[Medium]:
416
+ if not self._media:
417
+ self._media = [Medium(m) for m in self.get_all("media")]
418
+ return self._media
419
+
420
+ @property
421
+ @cache
422
+ def categories(self) -> Dict[str, int]:
423
+ return {c["slug"]: c["id"] for c in self.get_all("categories")}
424
+
425
+ @property
426
+ @cache
427
+ def categories_by_id(self) -> Dict[int, str]:
428
+ return {id: slug for slug, id in self.categories.items()}
429
+
430
+ @property
431
+ @cache
432
+ def industries_taxonomy(self) -> Dict[str, int]:
433
+ return {c["slug"]: c["id"] for c in self.get_all("industries_taxonomy")}
434
+
435
+ @property
436
+ @cache
437
+ def industries_taxonomy_by_id(self) -> Dict[str, int]:
438
+ return {id: slug for slug, id in self.industries_taxonomy.items()}
439
+
440
+ @property
441
+ @cache
442
+ def partners_taxonomy(self) -> Dict[str, int]:
443
+ return {c["slug"]: c["id"] for c in self.get_all("partners_taxonomy")}
444
+
445
+ @property
446
+ @cache
447
+ def partners_taxonomy_by_id(self) -> Dict[str, int]:
448
+ return {id: slug for slug, id in self.partners_taxonomy.items()}
449
+
450
+ @property
451
+ @cache
452
+ def capabilities(self) -> Dict[str, int]:
453
+ return {c["slug"]: c["id"] for c in self.get_all("capabilities")}
454
+
455
+ @property
456
+ @cache
457
+ def capabilities_by_id(self) -> Dict[str, int]:
458
+ return {id: slug for slug, id in self.capabilities.items()}
459
+
460
+
461
+ @property
462
+ @cache
463
+ def tags(self) -> Dict[str, int]:
464
+ return {c["slug"]: c["id"] for c in self.get_all("tags")}
465
+
466
+ @property
467
+ @cache
468
+ def tags_by_id(self) -> Dict[int, str]:
469
+ return {id: slug for slug, id in self.tags.items()}
470
+
471
+ def search_for_image_by_slug(self, slug) -> Optional[Medium]:
472
+ response = self.session.get(
473
+ f"{self.url}/media",
474
+ auth=self.auth,
475
+ headers=self.headers,
476
+ params={"search": slug},
477
+ )
478
+ if response.status_code == 200:
479
+ matches = map(lambda i: Medium(i), response.json())
480
+ return next(filter(lambda i: slug in [i.slug, i.title], matches), None)
481
+ else:
482
+ return None
483
+
484
+ def get_media(self, url: str) -> bytes:
485
+ response = self.session.get(
486
+ url, headers=self.headers, stream=True, auth=self.auth
487
+ )
488
+ assert response.status_code == 200, f"status code {response.status_code}"
489
+ return response.content
490
+
491
+ def upload_media(self, slug: str, path: Path) -> Medium:
492
+ old_content = []
493
+
494
+ with open(path, "rb") as file:
495
+ content = file.read()
496
+
497
+ stored_image = self.search_for_image_by_slug(slug)
498
+ if stored_image:
499
+ response = self.session.get(
500
+ stored_image.url,
501
+ headers={"User-Agent": self.headers["User-Agent"]},
502
+ stream=True,
503
+ auth=self.auth,
504
+ )
505
+ assert response.status_code == 200
506
+ old_content = response.content
507
+
508
+ if old_content != content:
509
+ if stored_image:
510
+ logging.info(
511
+ "force delete existing image under slug %s, id %s",
512
+ slug,
513
+ stored_image.medium_id,
514
+ )
515
+ delete_response = self.session.delete(
516
+ f"{self.url}/media/{stored_image.medium_id}",
517
+ auth=self.auth,
518
+ headers=self.headers,
519
+ params={"force": 1},
520
+ )
521
+ if delete_response.status_code not in [200, 201, 202]:
522
+ raise Exception(delete_response.text)
523
+
524
+ filename = f"{slug}{path.suffix}"
525
+ print(f"INFO: uploading image as {filename}")
526
+
527
+ headers = self.headers | {
528
+ "Content-Disposition": f'attachment; filename="{filename}"',
529
+ "Content-Type": mimetypes.guess_type(path)[0],
530
+ }
531
+
532
+ response = self.session.post(
533
+ f"{self.url}/media/",
534
+ data=content,
535
+ auth=self.auth,
536
+ headers=headers,
537
+ params={
538
+ "slug": slug,
539
+ "title": slug,
540
+ },
541
+ )
542
+ if response.status_code not in [200, 201]:
543
+ raise Exception(response.text)
544
+
545
+ stored_image = Medium(response.json())
546
+ self.session.patch(
547
+ f"{self.url}/media/{stored_image.medium_id}",
548
+ )
549
+
550
+ self._media[slug] = stored_image
551
+ return self._media[slug]
552
+
553
+ def find_image_by_link(self, link: Union[str, ParseResult]) -> Optional[Medium]:
554
+ url = link if isinstance(link, ParseResult) else urlparse(link)
555
+ stem = Path(url.path).stem
556
+
557
+ if not (self.is_host_for(link) and url.path.startswith("/wp-content/uploads/")):
558
+ return None
559
+
560
+ return next(
561
+ filter(
562
+ lambda m: url.geturl() == m.url,
563
+ map(lambda m: Medium(m), self.get_all("media", {"search": stem})),
564
+ ),
565
+ None,
566
+ )
567
+
568
+ def update_post(self, guid: str, properties: dict):
569
+ response = self.session.patch(
570
+ self.normalize_url(guid),
571
+ json=properties,
572
+ auth=self.auth,
573
+ headers=self.headers,
574
+ )
575
+ if response.status_code not in [200, 201]:
576
+ raise Exception(response.text)
577
+ return Post(response.json())
578
+
579
+ def create_post(self, properties: dict) -> "Post":
580
+ response = self.session.post(
581
+ f"{self.url}/posts",
582
+ json=properties,
583
+ auth=self.auth,
584
+ headers=self.headers,
585
+ )
586
+ if response.status_code not in [200, 201]:
587
+ raise Exception(response.text)
588
+ return Post(response.json())
589
+
590
+ def get_resource_by_url(self, url: str, params: dict = {}) -> Optional[dict]:
591
+ response = self.session.get(
592
+ self.normalize_url(url), auth=self.auth, headers=self.headers, params=params
593
+ )
594
+ if response.status_code == 200:
595
+ return response.json()
596
+ elif response.status_code == 404:
597
+ return None
598
+ else:
599
+ raise Exception(response.text)
600
+
601
+ def get(self, resource, resource_id, params: dict = {}) -> Optional[dict]:
602
+ return self.get_resource_by_url(f"{self.url}/{resource}/{resource_id}", params)
603
+
604
+ def connect(self):
605
+ global categories
606
+
607
+ categories = {c["slug"]: c["id"] for c in self.get_all("categories")}
608
+
609
+ def get_category_id_by_name(self, category: str) -> str:
610
+ if category in self.categories:
611
+ return self.categories[category]
612
+
613
+ raise ValueError(
614
+ "invalid category '{}' try one of\n {}".format(
615
+ category, ",\n ".join(self.categories.keys())
616
+ )
617
+ )
618
+
619
+ def get_industry_by_name(self, slug: str) -> str:
620
+ if slug in self.industries_taxonomy:
621
+ return self.industries_taxonomy[slug]
622
+
623
+ raise ValueError(
624
+ "invalid industry '{}' try one of\n {}".format(
625
+ slug, ",\n ".join(self.industries_taxonomy.keys())
626
+ )
627
+ )
628
+
629
+ def get_partner_by_name(self, slug: str) -> str:
630
+ if slug in self.partners_taxonomy:
631
+ return self.partners_taxonomy[slug]
632
+
633
+ raise ValueError(
634
+ "invalid partner '{}' try one of\n {}".format(
635
+ slug, ",\n ".join(self.partners_taxonomy.keys())
636
+ )
637
+ )
638
+
639
+ def get_capabilities_by_name(self, slug: str) -> str:
640
+ if slug in self.capabilities:
641
+ return self.capabilities[slug]
642
+
643
+ raise ValueError(
644
+ "invalid capability '{}' try one of\n {}".format(
645
+ slug, ",\n ".join(self.capabilities.keys())
646
+ )
647
+ )
648
+
649
+
650
+ def get_tag_id_by_name(self, tag: str) -> str:
651
+ if tag in self.tags:
652
+ return self.tags[tag]
653
+
654
+ raise ValueError(
655
+ "invalid tag '{}' try one of\n {}".format(
656
+ tag, ",\n ".join(self.tags.keys())
657
+ )
658
+ )