Mercurial > libervia-backend
comparison sat_frontends/jp/cmd_blog.py @ 3040:fee60f17ebac
jp: jp asyncio port:
/!\ this commit is huge. Jp is temporarily not working with `dbus` bridge /!\
This patch implements the port of jp to asyncio, so it is now correctly using the bridge
asynchronously, and it can be used with bridges like `pb`. This also simplify the code,
notably for things which were previously implemented with many callbacks (like pagination
with RSM).
During the process, some behaviours have been modified/fixed, in jp and backends, check
diff for details.
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 25 Sep 2019 08:56:41 +0200 |
parents | ab2696e34d29 |
children | d9f328374473 |
comparison
equal
deleted
inserted
replaced
3039:a1bc34f90fa5 | 3040:fee60f17ebac |
---|---|
16 | 16 |
17 # You should have received a copy of the GNU Affero General Public License | 17 # You should have received a copy of the GNU Affero General Public License |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | 18 # along with this program. If not, see <http://www.gnu.org/licenses/>. |
19 | 19 |
20 | 20 |
21 import json | |
22 import sys | |
23 import os.path | |
24 import os | |
25 import time | |
26 import tempfile | |
27 import subprocess | |
28 import asyncio | |
29 from asyncio.subprocess import DEVNULL | |
30 from pathlib import Path | |
21 from . import base | 31 from . import base |
22 from sat.core.i18n import _ | 32 from sat.core.i18n import _ |
23 from sat_frontends.jp.constants import Const as C | 33 from sat_frontends.jp.constants import Const as C |
24 from sat_frontends.jp import common | 34 from sat_frontends.jp import common |
25 from sat.tools.common.ansi import ANSI as A | 35 from sat.tools.common.ansi import ANSI as A |
26 from sat.tools.common import data_objects | 36 from sat.tools.common import data_objects |
27 from sat.tools.common import uri | 37 from sat.tools.common import uri |
28 from sat.tools import config | 38 from sat.tools import config |
29 from configparser import NoSectionError, NoOptionError | 39 from configparser import NoSectionError, NoOptionError |
30 from functools import partial | |
31 import json | |
32 import sys | |
33 import os.path | |
34 import os | |
35 import time | |
36 import tempfile | |
37 import subprocess | |
38 import codecs | |
39 from sat.tools.common import data_format | 40 from sat.tools.common import data_format |
40 | 41 |
41 __commands__ = ["Blog"] | 42 __commands__ = ["Blog"] |
42 | 43 |
43 SYNTAX_XHTML = "xhtml" | 44 SYNTAX_XHTML = "xhtml" |
62 "comments_service", | 63 "comments_service", |
63 "updated", | 64 "updated", |
64 ) | 65 ) |
65 | 66 |
66 URL_REDIRECT_PREFIX = "url_redirect_" | 67 URL_REDIRECT_PREFIX = "url_redirect_" |
67 INOTIFY_INSTALL = '"pip install inotify"' | 68 AIONOTIFY_INSTALL = '"pip install aionotify"' |
68 MB_KEYS = ( | 69 MB_KEYS = ( |
69 "id", | 70 "id", |
70 "url", | 71 "url", |
71 "atom_id", | 72 "atom_id", |
72 "updated", | 73 "updated", |
84 "title_xhtml", | 85 "title_xhtml", |
85 ) | 86 ) |
86 OUTPUT_OPT_NO_HEADER = "no-header" | 87 OUTPUT_OPT_NO_HEADER = "no-header" |
87 | 88 |
88 | 89 |
89 def guessSyntaxFromPath(host, sat_conf, path): | 90 async def guessSyntaxFromPath(host, sat_conf, path): |
90 """Return syntax guessed according to filename extension | 91 """Return syntax guessed according to filename extension |
91 | 92 |
92 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration | 93 @param sat_conf(ConfigParser.ConfigParser): instance opened on sat configuration |
93 @param path(str): path to the content file | 94 @param path(str): path to the content file |
94 @return(unicode): syntax to use | 95 @return(unicode): syntax to use |
99 for k, v in SYNTAX_EXT.items(): | 100 for k, v in SYNTAX_EXT.items(): |
100 if k and ext == v: | 101 if k and ext == v: |
101 return k | 102 return k |
102 | 103 |
103 # if not found, we use current syntax | 104 # if not found, we use current syntax |
104 return host.bridge.getParamA("Syntax", "Composition", "value", host.profile) | 105 return await host.bridge.getParamA("Syntax", "Composition", "value", host.profile) |
105 | 106 |
106 | 107 |
107 class BlogPublishCommon(object): | 108 class BlogPublishCommon(object): |
108 """handle common option for publising commands (Set and Edit)""" | 109 """handle common option for publising commands (Set and Edit)""" |
109 | 110 |
110 @property | 111 async def get_current_syntax(self): |
111 def current_syntax(self): | 112 """Retrieve current_syntax |
112 if self._current_syntax is None: | 113 |
113 self._current_syntax = self.host.bridge.getParamA( | 114 Use default syntax if --syntax has not been used, else check given syntax. |
115 Will set self.default_syntax_used to True if default syntax has been used | |
116 """ | |
117 if self.args.syntax is None: | |
118 self.default_syntax_used = True | |
119 return await self.host.bridge.getParamA( | |
114 "Syntax", "Composition", "value", self.profile | 120 "Syntax", "Composition", "value", self.profile |
115 ) | 121 ) |
116 return self._current_syntax | 122 else: |
123 self.default_syntax_used = False | |
124 try: | |
125 syntax = await self.host.bridge.syntaxGet(self.current_syntax) | |
126 | |
127 self.current_syntax = self.args.syntax = syntax | |
128 except Exception as e: | |
129 if e.classname == "NotFound": | |
130 self.parser.error(_(f"unknown syntax requested ({self.args.syntax})")) | |
131 else: | |
132 raise e | |
133 return self.args.syntax | |
117 | 134 |
118 def add_parser_options(self): | 135 def add_parser_options(self): |
119 self.parser.add_argument( | 136 self.parser.add_argument( |
120 "-T", "--title", help=_("title of the item") | 137 "-T", "--title", help=_("title of the item") |
121 ) | 138 ) |
141 "-S", | 158 "-S", |
142 "--syntax", | 159 "--syntax", |
143 help=_("syntax to use (default: get profile's default syntax)"), | 160 help=_("syntax to use (default: get profile's default syntax)"), |
144 ) | 161 ) |
145 | 162 |
146 def setMbDataContent(self, content, mb_data): | 163 async def setMbDataContent(self, content, mb_data): |
147 if self.args.syntax is None: | 164 if self.default_syntax_used: |
148 # default syntax has been used | 165 # default syntax has been used |
149 mb_data["content_rich"] = content | 166 mb_data["content_rich"] = content |
150 elif self.current_syntax == SYNTAX_XHTML: | 167 elif self.current_syntax == SYNTAX_XHTML: |
151 mb_data["content_xhtml"] = content | 168 mb_data["content_xhtml"] = content |
152 else: | 169 else: |
153 mb_data["content_xhtml"] = self.host.bridge.syntaxConvert( | 170 mb_data["content_xhtml"] = await self.host.bridge.syntaxConvert( |
154 content, self.current_syntax, SYNTAX_XHTML, False, self.profile | 171 content, self.current_syntax, SYNTAX_XHTML, False, self.profile |
155 ) | 172 ) |
156 | 173 |
157 def setMbDataFromArgs(self, mb_data): | 174 def setMbDataFromArgs(self, mb_data): |
158 """set microblog metadata according to command line options | 175 """set microblog metadata according to command line options |
176 use_pubsub=True, | 193 use_pubsub=True, |
177 pubsub_flags={C.SINGLE_ITEM}, | 194 pubsub_flags={C.SINGLE_ITEM}, |
178 help=_("publish a new blog item or update an existing one"), | 195 help=_("publish a new blog item or update an existing one"), |
179 ) | 196 ) |
180 BlogPublishCommon.__init__(self) | 197 BlogPublishCommon.__init__(self) |
181 self.need_loop = True | |
182 | 198 |
183 def add_parser_options(self): | 199 def add_parser_options(self): |
184 BlogPublishCommon.add_parser_options(self) | 200 BlogPublishCommon.add_parser_options(self) |
185 | 201 |
186 def mbSendCb(self): | 202 async def start(self): |
187 self.disp("Item published") | 203 self.current_syntax = await self.get_current_syntax() |
188 self.host.quit(C.EXIT_OK) | |
189 | |
190 def start(self): | |
191 self._current_syntax = self.args.syntax | |
192 self.pubsub_item = self.args.item | 204 self.pubsub_item = self.args.item |
193 mb_data = {} | 205 mb_data = {} |
194 self.setMbDataFromArgs(mb_data) | 206 self.setMbDataFromArgs(mb_data) |
195 if self.pubsub_item: | 207 if self.pubsub_item: |
196 mb_data["id"] = self.pubsub_item | 208 mb_data["id"] = self.pubsub_item |
197 content = codecs.getreader("utf-8")(sys.stdin).read() | 209 content = sys.stdin.read() |
198 self.setMbDataContent(content, mb_data) | 210 await self.setMbDataContent(content, mb_data) |
199 | 211 |
200 self.host.bridge.mbSend( | 212 try: |
201 self.args.service, | 213 await self.host.bridge.mbSend( |
202 self.args.node, | 214 self.args.service, |
203 data_format.serialise(mb_data), | 215 self.args.node, |
204 self.profile, | 216 data_format.serialise(mb_data), |
205 callback=self.exitCb, | 217 self.profile, |
206 errback=partial( | 218 ) |
207 self.errback, | 219 except Exception as e: |
208 msg=_("can't send item: {}"), | 220 self.disp( |
209 exit_code=C.EXIT_BRIDGE_ERRBACK, | 221 f"can't send item: {e}", error=True |
210 ), | 222 ) |
211 ) | 223 self.host.quit(C.EXIT_BRIDGE_ERRBACK) |
224 else: | |
225 self.disp("Item published") | |
226 self.host.quit(C.EXIT_OK) | |
212 | 227 |
213 | 228 |
214 class Get(base.CommandBase): | 229 class Get(base.CommandBase): |
215 TEMPLATE = "blog/articles.html" | 230 TEMPLATE = "blog/articles.html" |
216 | 231 |
225 pubsub_flags={C.MULTI_ITEMS}, | 240 pubsub_flags={C.MULTI_ITEMS}, |
226 use_output=C.OUTPUT_COMPLEX, | 241 use_output=C.OUTPUT_COMPLEX, |
227 extra_outputs=extra_outputs, | 242 extra_outputs=extra_outputs, |
228 help=_("get blog item(s)"), | 243 help=_("get blog item(s)"), |
229 ) | 244 ) |
230 self.need_loop = True | |
231 | 245 |
232 def add_parser_options(self): | 246 def add_parser_options(self): |
233 # TODO: a key(s) argument to select keys to display | 247 # TODO: a key(s) argument to select keys to display |
234 self.parser.add_argument( | 248 self.parser.add_argument( |
235 "-k", | 249 "-k", |
399 if content: | 413 if content: |
400 self.disp(content) | 414 self.disp(content) |
401 | 415 |
402 print(("\n" + sep + "\n")) | 416 print(("\n" + sep + "\n")) |
403 | 417 |
404 def mbGetCb(self, mb_result): | 418 async def start(self): |
405 items, metadata = mb_result | 419 try: |
406 items = [data_format.deserialise(i) for i in items] | 420 mb_result = await self.host.bridge.mbGet( |
407 mb_result = items, metadata | 421 self.args.service, |
408 self.output(mb_result) | 422 self.args.node, |
409 self.host.quit(C.EXIT_OK) | 423 self.args.max, |
410 | 424 self.args.items, |
411 def mbGetEb(self, failure_): | 425 self.getPubsubExtra(), |
412 self.disp("can't get blog items: {reason}".format(reason=failure_), error=True) | 426 self.profile |
413 self.host.quit(C.EXIT_BRIDGE_ERRBACK) | 427 ) |
414 | 428 except Exception as e: |
415 def start(self): | 429 self.disp(f"can't get blog items: {e}", error=True) |
416 self.host.bridge.mbGet( | 430 self.host.quit(C.EXIT_BRIDGE_ERRBACK) |
417 self.args.service, | 431 else: |
418 self.args.node, | 432 items, metadata = mb_result |
419 self.args.max, | 433 items = [data_format.deserialise(i) for i in items] |
420 self.args.items, | 434 mb_result = items, metadata |
421 self.getPubsubExtra(), | 435 await self.output(mb_result) |
422 self.profile, | 436 self.host.quit(C.EXIT_OK) |
423 callback=self.mbGetCb, | |
424 errback=self.mbGetEb, | |
425 ) | |
426 | 437 |
427 | 438 |
428 class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit): | 439 class Edit(base.CommandBase, BlogPublishCommon, common.BaseEdit): |
429 def __init__(self, host): | 440 def __init__(self, host): |
430 base.CommandBase.__init__( | 441 base.CommandBase.__init__( |
450 ) | 461 ) |
451 | 462 |
452 def buildMetadataFile(self, content_file_path, mb_data=None): | 463 def buildMetadataFile(self, content_file_path, mb_data=None): |
453 """Build a metadata file using json | 464 """Build a metadata file using json |
454 | 465 |
455 The file is named after content_file_path, with extension replaced by _metadata.json | 466 The file is named after content_file_path, with extension replaced by |
456 @param content_file_path(str): path to the temporary file which will contain the body | 467 _metadata.json |
468 @param content_file_path(str): path to the temporary file which will contain the | |
469 body | |
457 @param mb_data(dict, None): microblog metadata (for existing items) | 470 @param mb_data(dict, None): microblog metadata (for existing items) |
458 @return (tuple[dict, str]): merged metadata put originaly in metadata file | 471 @return (tuple[dict, Path]): merged metadata put originaly in metadata file |
459 and path to temporary metadata file | 472 and path to temporary metadata file |
460 """ | 473 """ |
461 # we first construct metadata from edited item ones and CLI argumments | 474 # we first construct metadata from edited item ones and CLI argumments |
462 # or re-use the existing one if it exists | 475 # or re-use the existing one if it exists |
463 meta_file_path = os.path.splitext(content_file_path)[0] + common.METADATA_SUFF | 476 meta_file_path = content_file_path.with_name( |
464 if os.path.exists(meta_file_path): | 477 content_file_path.stem + common.METADATA_SUFF) |
478 if meta_file_path.exists(): | |
465 self.disp("Metadata file already exists, we re-use it") | 479 self.disp("Metadata file already exists, we re-use it") |
466 try: | 480 try: |
467 with open(meta_file_path, "rb") as f: | 481 with meta_file_path.open("rb") as f: |
468 mb_data = json.load(f) | 482 mb_data = json.load(f) |
469 except (OSError, IOError, ValueError) as e: | 483 except (OSError, IOError, ValueError) as e: |
470 self.disp( | 484 self.disp( |
471 "Can't read existing metadata file at {path}, aborting: {reason}".format( | 485 f"Can't read existing metadata file at {meta_file_path}, " |
472 path=meta_file_path, reason=e | 486 f"aborting: {e}", |
473 ), | |
474 error=True, | 487 error=True, |
475 ) | 488 ) |
476 self.host.quit(1) | 489 self.host.quit(1) |
477 else: | 490 else: |
478 mb_data = {} if mb_data is None else mb_data.copy() | 491 mb_data = {} if mb_data is None else mb_data.copy() |
489 # then we create the file and write metadata there, as JSON dict | 502 # then we create the file and write metadata there, as JSON dict |
490 # XXX: if we port jp one day on Windows, O_BINARY may need to be added here | 503 # XXX: if we port jp one day on Windows, O_BINARY may need to be added here |
491 with os.fdopen( | 504 with os.fdopen( |
492 os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), "w+b" | 505 os.open(meta_file_path, os.O_RDWR | os.O_CREAT | os.O_TRUNC, 0o600), "w+b" |
493 ) as f: | 506 ) as f: |
494 # we need to use an intermediate unicode buffer to write to the file unicode without escaping characters | 507 # we need to use an intermediate unicode buffer to write to the file |
508 # unicode without escaping characters | |
495 unicode_dump = json.dumps( | 509 unicode_dump = json.dumps( |
496 mb_data, | 510 mb_data, |
497 ensure_ascii=False, | 511 ensure_ascii=False, |
498 indent=4, | 512 indent=4, |
499 separators=(",", ": "), | 513 separators=(",", ": "), |
501 ) | 515 ) |
502 f.write(unicode_dump.encode("utf-8")) | 516 f.write(unicode_dump.encode("utf-8")) |
503 | 517 |
504 return mb_data, meta_file_path | 518 return mb_data, meta_file_path |
505 | 519 |
506 def edit(self, content_file_path, content_file_obj, mb_data=None): | 520 async def edit(self, content_file_path, content_file_obj, mb_data=None): |
507 """Edit the file contening the content using editor, and publish it""" | 521 """Edit the file contening the content using editor, and publish it""" |
508 # we first create metadata file | 522 # we first create metadata file |
509 meta_ori, meta_file_path = self.buildMetadataFile(content_file_path, mb_data) | 523 meta_ori, meta_file_path = self.buildMetadataFile(content_file_path, mb_data) |
524 | |
525 coroutines = [] | |
510 | 526 |
511 # do we need a preview ? | 527 # do we need a preview ? |
512 if self.args.preview: | 528 if self.args.preview: |
513 self.disp("Preview requested, launching it", 1) | 529 self.disp("Preview requested, launching it", 1) |
514 # we redirect outputs to /dev/null to avoid console pollution in editor | 530 # we redirect outputs to /dev/null to avoid console pollution in editor |
515 # if user wants to see messages, (s)he can call "blog preview" directly | 531 # if user wants to see messages, (s)he can call "blog preview" directly |
516 DEVNULL = open(os.devnull, "wb") | 532 coroutines.append( |
517 subprocess.Popen( | 533 asyncio.create_subprocess_exec( |
518 [ | |
519 sys.argv[0], | 534 sys.argv[0], |
520 "blog", | 535 "blog", |
521 "preview", | 536 "preview", |
522 "--inotify", | 537 "--inotify", |
523 "true", | 538 "true", |
524 "-p", | 539 "-p", |
525 self.profile, | 540 self.profile, |
526 content_file_path, | 541 str(content_file_path), |
527 ], | 542 stdout=DEVNULL, |
528 stdout=DEVNULL, | 543 stderr=DEVNULL, |
529 stderr=subprocess.STDOUT, | 544 ) |
530 ) | 545 ) |
531 | 546 |
532 # we launch editor | 547 # we launch editor |
533 self.runEditor( | 548 coroutines.append( |
534 "blog_editor_args", | 549 self.runEditor( |
535 content_file_path, | 550 "blog_editor_args", |
536 content_file_obj, | 551 content_file_path, |
537 meta_file_path=meta_file_path, | 552 content_file_obj, |
538 meta_ori=meta_ori, | 553 meta_file_path=meta_file_path, |
539 ) | 554 meta_ori=meta_ori, |
540 | 555 ) |
541 def publish(self, content, mb_data): | 556 ) |
542 self.setMbDataContent(content, mb_data) | 557 |
558 await asyncio.gather(*coroutines) | |
559 | |
560 async def publish(self, content, mb_data): | |
561 await self.setMbDataContent(content, mb_data) | |
543 | 562 |
544 if self.pubsub_item: | 563 if self.pubsub_item: |
545 mb_data["id"] = self.pubsub_item | 564 mb_data["id"] = self.pubsub_item |
546 | 565 |
547 mb_data = data_format.serialise(mb_data) | 566 mb_data = data_format.serialise(mb_data) |
548 | 567 |
549 self.host.bridge.mbSend( | 568 await self.host.bridge.mbSend( |
550 self.pubsub_service, self.pubsub_node, mb_data, self.profile | 569 self.pubsub_service, self.pubsub_node, mb_data, self.profile |
551 ) | 570 ) |
552 self.disp("Blog item published") | 571 self.disp("Blog item published") |
553 | 572 |
554 def getTmpSuff(self): | 573 def getTmpSuff(self): |
555 # we get current syntax to determine file extension | 574 # we get current syntax to determine file extension |
556 return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[""]) | 575 return SYNTAX_EXT.get(self.current_syntax, SYNTAX_EXT[""]) |
557 | 576 |
558 def getItemData(self, service, node, item): | 577 async def getItemData(self, service, node, item): |
559 items = [item] if item else [] | 578 items = [item] if item else [] |
560 mb_data = self.host.bridge.mbGet(service, node, 1, items, {}, self.profile)[0][0] | 579 |
561 mb_data = data_format.deserialise(mb_data) | 580 mb_data = await self.host.bridge.mbGet( |
581 service, node, 1, items, {}, self.profile) | |
582 mb_data = data_format.deserialise(mb_data[0][0]) | |
583 | |
562 try: | 584 try: |
563 content = mb_data["content_xhtml"] | 585 content = mb_data["content_xhtml"] |
564 except KeyError: | 586 except KeyError: |
565 content = mb_data["content"] | 587 content = mb_data["content"] |
566 if content: | 588 if content: |
567 content = self.host.bridge.syntaxConvert( | 589 content = await self.host.bridge.syntaxConvert( |
568 content, "text", SYNTAX_XHTML, False, self.profile | 590 content, "text", SYNTAX_XHTML, False, self.profile |
569 ) | 591 ) |
592 | |
570 if content and self.current_syntax != SYNTAX_XHTML: | 593 if content and self.current_syntax != SYNTAX_XHTML: |
571 content = self.host.bridge.syntaxConvert( | 594 content = await self.host.bridge.syntaxConvert( |
572 content, SYNTAX_XHTML, self.current_syntax, False, self.profile | 595 content, SYNTAX_XHTML, self.current_syntax, False, self.profile |
573 ) | 596 ) |
597 | |
574 if content and self.current_syntax == SYNTAX_XHTML: | 598 if content and self.current_syntax == SYNTAX_XHTML: |
575 content = content.strip() | 599 content = content.strip() |
576 if not content.startswith('<div>'): | 600 if not content.startswith('<div>'): |
577 content = '<div>' + content + '</div>' | 601 content = '<div>' + content + '</div>' |
578 try: | 602 try: |
584 root = etree.fromstring(content, parser) | 608 root = etree.fromstring(content, parser) |
585 content = etree.tostring(root, encoding=str, pretty_print=True) | 609 content = etree.tostring(root, encoding=str, pretty_print=True) |
586 | 610 |
587 return content, mb_data, mb_data["id"] | 611 return content, mb_data, mb_data["id"] |
588 | 612 |
589 def start(self): | 613 async def start(self): |
590 # if there are user defined extension, we use them | 614 # if there are user defined extension, we use them |
591 SYNTAX_EXT.update(config.getConfig(self.sat_conf, "jp", CONF_SYNTAX_EXT, {})) | 615 SYNTAX_EXT.update(config.getConfig(self.sat_conf, "jp", CONF_SYNTAX_EXT, {})) |
592 self._current_syntax = self.args.syntax | 616 self.current_syntax = await self.get_current_syntax() |
593 if self._current_syntax is not None: | 617 |
594 try: | 618 (self.pubsub_service, self.pubsub_node, self.pubsub_item, content_file_path, |
595 self._current_syntax = self.args.syntax = self.host.bridge.syntaxGet( | 619 content_file_obj, mb_data,) = await self.getItemPath() |
596 self.current_syntax | 620 |
597 ) | 621 await self.edit(content_file_path, content_file_obj, mb_data=mb_data) |
598 except Exception as e: | 622 self.host.quit() |
599 if "NotFound" in str( | |
600 e | |
601 ): # FIXME: there is not good way to check bridge errors | |
602 self.parser.error( | |
603 _("unknown syntax requested ({syntax})").format( | |
604 syntax=self.args.syntax | |
605 ) | |
606 ) | |
607 else: | |
608 raise e | |
609 | |
610 ( | |
611 self.pubsub_service, | |
612 self.pubsub_node, | |
613 self.pubsub_item, | |
614 content_file_path, | |
615 content_file_obj, | |
616 mb_data, | |
617 ) = self.getItemPath() | |
618 | |
619 self.edit(content_file_path, content_file_obj, mb_data=mb_data) | |
620 | 623 |
621 | 624 |
622 class Preview(base.CommandBase, common.BaseEdit): | 625 class Preview(base.CommandBase, common.BaseEdit): |
623 # TODO: need to be rewritten with template output | 626 # TODO: need to be rewritten with template output |
624 | 627 |
641 nargs="?", | 644 nargs="?", |
642 default="current", | 645 default="current", |
643 help=_("path to the content file"), | 646 help=_("path to the content file"), |
644 ) | 647 ) |
645 | 648 |
646 def showPreview(self): | 649 async def showPreview(self): |
647 # we implement showPreview here so we don't have to import webbrowser and urllib | 650 # we implement showPreview here so we don't have to import webbrowser and urllib |
648 # when preview is not used | 651 # when preview is not used |
649 url = "file:{}".format(self.urllib.quote(self.preview_file_path)) | 652 url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path)) |
650 self.webbrowser.open_new_tab(url) | 653 self.webbrowser.open_new_tab(url) |
651 | 654 |
652 def _launchPreviewExt(self, cmd_line, opt_name): | 655 async def _launchPreviewExt(self, cmd_line, opt_name): |
653 url = "file:{}".format(self.urllib.quote(self.preview_file_path)) | 656 url = "file:{}".format(self.urllib.parse.quote(self.preview_file_path)) |
654 args = common.parse_args( | 657 args = common.parse_args( |
655 self.host, cmd_line, url=url, preview_file=self.preview_file_path | 658 self.host, cmd_line, url=url, preview_file=self.preview_file_path |
656 ) | 659 ) |
657 if not args: | 660 if not args: |
658 self.disp( | 661 self.disp( |
660 error=True, | 663 error=True, |
661 ) | 664 ) |
662 self.host.quit(1) | 665 self.host.quit(1) |
663 subprocess.Popen(args) | 666 subprocess.Popen(args) |
664 | 667 |
665 def openPreviewExt(self): | 668 async def openPreviewExt(self): |
666 self._launchPreviewExt(self.open_cb_cmd, "blog_preview_open_cmd") | 669 await self._launchPreviewExt(self.open_cb_cmd, "blog_preview_open_cmd") |
667 | 670 |
668 def updatePreviewExt(self): | 671 async def updatePreviewExt(self): |
669 self._launchPreviewExt(self.update_cb_cmd, "blog_preview_update_cmd") | 672 await self._launchPreviewExt(self.update_cb_cmd, "blog_preview_update_cmd") |
670 | 673 |
671 def updateContent(self): | 674 async def updateContent(self): |
672 with open(self.content_file_path, "rb") as f: | 675 with self.content_file_path.open("rb") as f: |
673 content = f.read().decode("utf-8-sig") | 676 content = f.read().decode("utf-8-sig") |
674 if content and self.syntax != SYNTAX_XHTML: | 677 if content and self.syntax != SYNTAX_XHTML: |
675 # we use safe=True because we want to have a preview as close as possible | 678 # we use safe=True because we want to have a preview as close as possible |
676 # to what the people will see | 679 # to what the people will see |
677 content = self.host.bridge.syntaxConvert( | 680 content = await self.host.bridge.syntaxConvert( |
678 content, self.syntax, SYNTAX_XHTML, True, self.profile | 681 content, self.syntax, SYNTAX_XHTML, True, self.profile |
679 ) | 682 ) |
680 | 683 |
681 xhtml = ( | 684 xhtml = ( |
682 '<html xmlns="http://www.w3.org/1999/xhtml">' | 685 f'<html xmlns="http://www.w3.org/1999/xhtml">' |
683 '<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />' | 686 f'<head><meta http-equiv="Content-Type" content="text/html;charset=utf-8" />' |
684 "</head>" | 687 f'</head>' |
685 "<body>{}</body>" | 688 f'<body>{content}</body>' |
686 "</html>" | 689 f'</html>' |
687 ).format(content) | 690 ) |
688 | 691 |
689 with open(self.preview_file_path, "wb") as f: | 692 with open(self.preview_file_path, "wb") as f: |
690 f.write(xhtml.encode("utf-8")) | 693 f.write(xhtml.encode("utf-8")) |
691 | 694 |
692 def start(self): | 695 async def start(self): |
693 import webbrowser | 696 import webbrowser |
694 import urllib.request, urllib.parse, urllib.error | 697 import urllib.request, urllib.parse, urllib.error |
695 | 698 |
696 self.webbrowser, self.urllib = webbrowser, urllib | 699 self.webbrowser, self.urllib = webbrowser, urllib |
697 | 700 |
698 if self.args.inotify != "false": | 701 if self.args.inotify != "false": |
699 try: | 702 try: |
700 import inotify.adapters | 703 import aionotify |
701 import inotify.constants | 704 |
702 from inotify.calls import InotifyError | |
703 except ImportError: | 705 except ImportError: |
704 if self.args.inotify == "auto": | 706 if self.args.inotify == "auto": |
705 inotify = None | 707 aionotify = None |
706 self.disp( | 708 self.disp( |
707 "inotify module not found, deactivating feature. You can install" | 709 f"aionotify module not found, deactivating feature. You can " |
708 " it with {install}".format(install=INOTIFY_INSTALL) | 710 f"install it with {AIONOTIFY_INSTALL}" |
709 ) | 711 ) |
710 else: | 712 else: |
711 self.disp( | 713 self.disp( |
712 "inotify not found, can't activate the feature! Please install " | 714 f"aioinotify not found, can't activate the feature! Please " |
713 "it with {install}".format(install=INOTIFY_INSTALL), | 715 f"install it with {AIONOTIFY_INSTALL}", |
714 error=True, | 716 error=True, |
715 ) | 717 ) |
716 self.host.quit(1) | 718 self.host.quit(1) |
717 else: | 719 else: |
718 # we deactivate logging in inotify, which is quite annoying | 720 aionotify = None |
719 try: | |
720 inotify.adapters._LOGGER.setLevel(40) | |
721 except AttributeError: | |
722 self.disp( | |
723 "Logger doesn't exists, inotify may have chanded", error=True | |
724 ) | |
725 else: | |
726 inotify = None | |
727 | 721 |
728 sat_conf = config.parseMainConf() | 722 sat_conf = config.parseMainConf() |
729 SYNTAX_EXT.update(config.getConfig(sat_conf, "jp", CONF_SYNTAX_EXT, {})) | 723 SYNTAX_EXT.update(config.getConfig(sat_conf, "jp", CONF_SYNTAX_EXT, {})) |
730 | 724 |
731 try: | 725 try: |
748 | 742 |
749 # which file do we need to edit? | 743 # which file do we need to edit? |
750 if self.args.file == "current": | 744 if self.args.file == "current": |
751 self.content_file_path = self.getCurrentFile(self.profile) | 745 self.content_file_path = self.getCurrentFile(self.profile) |
752 else: | 746 else: |
753 self.content_file_path = os.path.abspath(self.args.file) | 747 try: |
754 | 748 self.content_file_path = Path(self.args.file).resolve(strict=True) |
755 self.syntax = guessSyntaxFromPath(self.host, sat_conf, self.content_file_path) | 749 except FileNotFoundError: |
750 self.disp(_(f'File "{self.args.file}" doesn\'t exist!')) | |
751 self.host.quit(C.EXIT_NOT_FOUND) | |
752 | |
753 self.syntax = await guessSyntaxFromPath( | |
754 self.host, sat_conf, self.content_file_path) | |
756 | 755 |
757 # at this point the syntax is converted, we can display the preview | 756 # at this point the syntax is converted, we can display the preview |
758 preview_file = tempfile.NamedTemporaryFile(suffix=".xhtml", delete=False) | 757 preview_file = tempfile.NamedTemporaryFile(suffix=".xhtml", delete=False) |
759 self.preview_file_path = preview_file.name | 758 self.preview_file_path = preview_file.name |
760 preview_file.close() | 759 preview_file.close() |
761 self.updateContent() | 760 await self.updateContent() |
762 | 761 |
763 if inotify is None: | 762 if aionotify is None: |
764 # XXX: we don't delete file automatically because browser need it | 763 # XXX: we don't delete file automatically because browser needs it |
765 # (and webbrowser.open can return before it is read) | 764 # (and webbrowser.open can return before it is read) |
766 self.disp( | 765 self.disp( |
767 "temporary file created at {}\nthis file will NOT BE DELETED " | 766 f"temporary file created at {self.preview_file_path}\nthis file will NOT " |
768 "AUTOMATICALLY, please delete it yourself when you have finished".format( | 767 f"BE DELETED AUTOMATICALLY, please delete it yourself when you have " |
769 self.preview_file_path | 768 f"finished" |
770 ) | 769 ) |
771 ) | 770 await open_cb() |
772 open_cb() | 771 else: |
773 else: | 772 await open_cb() |
774 open_cb() | 773 watcher = aionotify.Watcher() |
775 i = inotify.adapters.Inotify( | 774 watcher_kwargs = { |
776 block_duration_s=60 | 775 # Watcher don't accept Path so we convert to string |
777 ) # no need for 1 s duraction, inotify drive actions here | 776 "path": str(self.content_file_path), |
778 | 777 "alias": 'content_file', |
779 def add_watch(): | 778 "flags": aionotify.Flags.CLOSE_WRITE |
780 i.add_watch( | 779 | aionotify.Flags.DELETE_SELF |
781 self.content_file_path.encode('utf-8'), | 780 | aionotify.Flags.MOVE_SELF, |
782 mask=inotify.constants.IN_CLOSE_WRITE | 781 } |
783 | inotify.constants.IN_DELETE_SELF | 782 watcher.watch(**watcher_kwargs) |
784 | inotify.constants.IN_MOVE_SELF, | 783 |
785 ) | 784 loop = asyncio.get_event_loop() |
786 | 785 await watcher.setup(loop) |
787 add_watch() | |
788 | 786 |
789 try: | 787 try: |
790 for event in i.event_gen(): | 788 while True: |
791 if event is not None: | 789 event = await watcher.get_event() |
792 self.disp("Content updated", 1) | 790 self.disp("Content updated", 1) |
793 if {"IN_DELETE_SELF", "IN_MOVE_SELF"}.intersection(event[1]): | 791 if event.flags & (aionotify.Flags.DELETE_SELF |
792 | aionotify.Flags.MOVE_SELF): | |
793 self.disp( | |
794 "DELETE/MOVE event catched, changing the watch", | |
795 2, | |
796 ) | |
797 try: | |
798 watcher.unwatch('content_file') | |
799 except IOError as e: | |
794 self.disp( | 800 self.disp( |
795 "{} event catched, changing the watch".format( | 801 f"Can't remove the watch: {e}", |
796 ", ".join(event[1]) | |
797 ), | |
798 2, | 802 2, |
799 ) | 803 ) |
800 i.remove_watch(self.content_file_path) | 804 watcher = aionotify.Watcher() |
801 try: | 805 watcher.watch(**watcher_kwargs) |
802 add_watch() | 806 try: |
803 except InotifyError: | 807 await watcher.setup(loop) |
804 # if the new file is not here yet we can have an error | 808 except OSError: |
805 # as a workaround, we do a little rest | 809 # if the new file is not here yet we can have an error |
806 time.sleep(1) | 810 # as a workaround, we do a little rest and try again |
807 add_watch() | 811 await asyncio.sleep(1) |
808 self.updateContent() | 812 await watcher.setup(loop) |
809 update_cb() | 813 await self.updateContent() |
810 except InotifyError: | 814 await update_cb() |
811 self.disp( | 815 except FileNotFoundError: |
812 "Can't catch inotify events, as the file been deleted?", error=True | 816 self.disp("The file seems to have been deleted.", error=True) |
813 ) | 817 self.host.quit(C.EXIT_NOT_FOUND) |
814 finally: | 818 finally: |
815 os.unlink(self.preview_file_path) | 819 os.unlink(self.preview_file_path) |
816 try: | 820 try: |
817 i.remove_watch(self.content_file_path) | 821 watcher.unwatch('content_file') |
818 except InotifyError: | 822 except IOError as e: |
819 pass | 823 self.disp( |
820 | 824 f"Can't remove the watch: {e}", |
821 | 825 2, |
822 class Import(base.CommandAnswering): | 826 ) |
827 | |
828 | |
829 class Import(base.CommandBase): | |
823 def __init__(self, host): | 830 def __init__(self, host): |
824 super(Import, self).__init__( | 831 super(Import, self).__init__( |
825 host, | 832 host, |
826 "import", | 833 "import", |
827 use_pubsub=True, | 834 use_pubsub=True, |
828 use_progress=True, | 835 use_progress=True, |
829 help=_("import an external blog"), | 836 help=_("import an external blog"), |
830 ) | 837 ) |
831 self.need_loop = True | |
832 | 838 |
833 def add_parser_options(self): | 839 def add_parser_options(self): |
834 self.parser.add_argument( | 840 self.parser.add_argument( |
835 "importer", | 841 "importer", |
836 nargs="?", | 842 nargs="?", |
869 "importer data location (see importer description), nothing to show " | 875 "importer data location (see importer description), nothing to show " |
870 "importer description" | 876 "importer description" |
871 ), | 877 ), |
872 ) | 878 ) |
873 | 879 |
874 def onProgressStarted(self, metadata): | 880 async def onProgressStarted(self, metadata): |
875 self.disp(_("Blog upload started"), 2) | 881 self.disp(_("Blog upload started"), 2) |
876 | 882 |
877 def onProgressFinished(self, metadata): | 883 async def onProgressFinished(self, metadata): |
878 self.disp(_("Blog uploaded successfully"), 2) | 884 self.disp(_("Blog uploaded successfully"), 2) |
879 redirections = { | 885 redirections = { |
880 k[len(URL_REDIRECT_PREFIX) :]: v | 886 k[len(URL_REDIRECT_PREFIX) :]: v |
881 for k, v in metadata.items() | 887 for k, v in metadata.items() |
882 if k.startswith(URL_REDIRECT_PREFIX) | 888 if k.startswith(URL_REDIRECT_PREFIX) |
895 ), | 901 ), |
896 ] | 902 ] |
897 ) | 903 ) |
898 self.disp( | 904 self.disp( |
899 _( | 905 _( |
900 "\nTo redirect old URLs to new ones, put the following lines in your" | 906 f"\nTo redirect old URLs to new ones, put the following lines in your" |
901 " sat.conf file, in [libervia] section:\n\n{conf}".format(conf=conf) | 907 f" sat.conf file, in [libervia] section:\n\n{conf}" |
902 ) | 908 ) |
903 ) | 909 ) |
904 | 910 |
905 def onProgressError(self, error_msg): | 911 async def onProgressError(self, error_msg): |
906 self.disp(_("Error while uploading blog: {}").format(error_msg), error=True) | 912 self.disp(_(f"Error while uploading blog: {error_msg}"), error=True) |
907 | 913 |
908 def error(self, failure): | 914 async def start(self): |
909 self.disp( | |
910 _("Error while trying to upload a blog: {reason}").format(reason=failure), | |
911 error=True, | |
912 ) | |
913 self.host.quit(1) | |
914 | |
915 def start(self): | |
916 if self.args.location is None: | 915 if self.args.location is None: |
917 for name in ("option", "service", "no_images_upload"): | 916 for name in ("option", "service", "no_images_upload"): |
918 if getattr(self.args, name): | 917 if getattr(self.args, name): |
919 self.parser.error( | 918 self.parser.error( |
920 _( | 919 _( |
921 "{name} argument can't be used without location argument" | 920 f"{name} argument can't be used without location argument" |
922 ).format(name=name) | 921 ) |
923 ) | 922 ) |
924 if self.args.importer is None: | 923 if self.args.importer is None: |
925 self.disp( | 924 self.disp( |
926 "\n".join( | 925 "\n".join( |
927 [ | 926 [ |
928 "{}: {}".format(name, desc) | 927 f"{name}: {desc}" |
929 for name, desc in self.host.bridge.blogImportList() | 928 for name, desc in await self.host.bridge.blogImportList() |
930 ] | 929 ] |
931 ) | 930 ) |
932 ) | 931 ) |
933 else: | 932 else: |
934 try: | 933 try: |
935 short_desc, long_desc = self.host.bridge.blogImportDesc( | 934 short_desc, long_desc = await self.host.bridge.blogImportDesc( |
936 self.args.importer | 935 self.args.importer |
937 ) | 936 ) |
938 except Exception as e: | 937 except Exception as e: |
939 msg = [l for l in str(e).split("\n") if l][ | 938 msg = [l for l in str(e).split("\n") if l][ |
940 -1 | 939 -1 |
941 ] # we only keep the last line | 940 ] # we only keep the last line |
942 self.disp(msg) | 941 self.disp(msg) |
943 self.host.quit(1) | 942 self.host.quit(1) |
944 else: | 943 else: |
945 self.disp( | 944 self.disp(f"{self.args.importer}: {short_desc}\n\n{long_desc}") |
946 "{name}: {short_desc}\n\n{long_desc}".format( | |
947 name=self.args.importer, | |
948 short_desc=short_desc, | |
949 long_desc=long_desc, | |
950 ) | |
951 ) | |
952 self.host.quit() | 945 self.host.quit() |
953 else: | 946 else: |
954 # we have a location, an import is requested | 947 # we have a location, an import is requested |
955 options = {key: value for key, value in self.args.option} | 948 options = {key: value for key, value in self.args.option} |
956 if self.args.host: | 949 if self.args.host: |
965 "is set" | 958 "is set" |
966 ) | 959 ) |
967 elif self.args.upload_ignore_host: | 960 elif self.args.upload_ignore_host: |
968 options["upload_ignore_host"] = self.args.upload_ignore_host | 961 options["upload_ignore_host"] = self.args.upload_ignore_host |
969 | 962 |
970 def gotId(id_): | 963 try: |
971 self.progress_id = id_ | 964 progress_id = await self.host.bridge.blogImport( |
972 | 965 self.args.importer, |
973 self.host.bridge.blogImport( | 966 self.args.location, |
974 self.args.importer, | 967 options, |
975 self.args.location, | 968 self.args.service, |
976 options, | 969 self.args.node, |
977 self.args.service, | 970 self.profile, |
978 self.args.node, | 971 ) |
979 self.profile, | 972 except Exception as e: |
980 callback=gotId, | 973 self.disp( |
981 errback=self.error, | 974 _(f"Error while trying to import a blog: {e}"), |
982 ) | 975 error=True, |
983 | 976 ) |
977 self.host.quit(1) | |
978 | |
979 await self.set_progress_id(progress_id) | |
984 | 980 |
985 class Blog(base.CommandBase): | 981 class Blog(base.CommandBase): |
986 subcommands = (Set, Get, Edit, Preview, Import) | 982 subcommands = (Set, Get, Edit, Preview, Import) |
987 | 983 |
988 def __init__(self, host): | 984 def __init__(self, host): |