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):