view.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. #!/usr/bin/env python3
  2. #
  3. # mmgen = Multi-Mode GENerator, command-line Bitcoin cold storage solution
  4. # Copyright (C)2013-2022 The MMGen Project <mmgen@tuta.io>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. tw.view: base class for tracking wallet view classes
  20. """
  21. import sys,time,asyncio
  22. from collections import namedtuple
  23. from ..globalvars import g
  24. from ..objmethods import Hilite,InitErrors,MMGenObject
  25. from ..obj import get_obj,MMGenIdx,MMGenList
  26. from ..color import nocolor,yellow,green,red,blue
  27. from ..util import msg,msg_r,fmt,die,capfirst,make_timestr
  28. from ..rpc import rpc_init
  29. from ..base_obj import AsyncInit
  30. # base class for TwUnspentOutputs,TwAddresses,TwTxHistory:
  31. class TwView(MMGenObject,metaclass=AsyncInit):
  32. class display_type:
  33. class squeezed:
  34. detail = False
  35. fmt_method = 'gen_squeezed_display'
  36. line_fmt_method = 'squeezed_format_line'
  37. hdr_fmt_method = 'squeezed_col_hdr'
  38. need_column_widths = True
  39. item_separator = '\n'
  40. print_header = '[screen print truncated to width {}]\n'
  41. class detail:
  42. detail = True
  43. fmt_method = 'gen_detail_display'
  44. line_fmt_method = 'detail_format_line'
  45. hdr_fmt_method = 'detail_col_hdr'
  46. need_column_widths = True
  47. item_separator = '\n'
  48. print_header = ''
  49. class line_processing:
  50. class print:
  51. color = False
  52. def do(method,data,cw,fs,color,fmt_method):
  53. return [l.rstrip() for l in method(data,cw,fs,color,fmt_method)]
  54. has_wallet = True
  55. has_amt2 = False
  56. dates_set = False
  57. cols = None
  58. reverse = False
  59. group = False
  60. txid_w = 64
  61. sort_key = 'age'
  62. interactive = False
  63. _display_data = {}
  64. filters = ()
  65. fp = namedtuple('fs_params',['fs_key','hdr_fs_repl','fs_repl','hdr_fs','fs'])
  66. fs_params = {
  67. 'num': fp('n', True, True, ' {n:>%s}', ' {n:>%s}'),
  68. 'txid': fp('t', True, False, ' {t:%s}', ' {t}'),
  69. 'vout': fp('v', True, False, '{v:%s}', '{v}'),
  70. 'used': fp('u', True, False, ' {u:%s}', ' {u}'),
  71. 'addr': fp('a', True, False, ' {a:%s}', ' {a}'),
  72. 'mmid': fp('m', True, False, ' {m:%s}', ' {m}'),
  73. 'comment': fp('c', True, False, ' {c:%s}', ' {c}'),
  74. 'amt': fp('A', True, False, ' {A:%s}', ' {A}'),
  75. 'amt2': fp('B', True, False, ' {B:%s}', ' {B}'),
  76. 'date': fp('d', True, True, ' {d:%s}', ' {d:<%s}'),
  77. 'date_time': fp('D', True, True, ' {D:%s}', ' {D:%s}'),
  78. 'block': fp('b', True, True, ' {b:%s}', ' {b:<%s}'),
  79. 'inputs': fp('i', True, False, ' {i:%s}', ' {i}'),
  80. 'outputs': fp('o', True, False, ' {o:%s}', ' {o}'),
  81. }
  82. age_fmts = ('confs','block','days','date','date_time')
  83. age_fmts_date_dependent = ('days','date','date_time')
  84. age_fmts_interactive = ('confs','block','days','date','date_time')
  85. _age_fmt = 'confs'
  86. age_col_params = {
  87. 'confs': (7, 'Confs'),
  88. 'block': (8, 'Block'),
  89. 'days': (6, 'Age(d)'),
  90. 'date': (8, 'Date'),
  91. 'date_time': (16, 'Date/Time'),
  92. }
  93. date_formatter = {
  94. 'days': lambda rpc,secs: (rpc.cur_date - secs) // 86400 if secs else 0,
  95. 'date': (
  96. lambda rpc,secs: '{}-{:02}-{:02}'.format(*time.gmtime(secs)[:3])[2:]
  97. if secs else '- '),
  98. 'date_time': (
  99. lambda rpc,secs: '{}-{:02}-{:02} {:02}:{:02}'.format(*time.gmtime(secs)[:5])
  100. if secs else '- '),
  101. }
  102. tcols_errmsg = """
  103. --columns or MMGEN_COLUMNS value ({}) is too small to display the {}.
  104. Minimum value for this configuration: {}
  105. """
  106. twidth_errmsg = """
  107. Screen is too narrow to display the {}
  108. Please resize your screen to at least {} characters and hit any key:
  109. """
  110. squeezed_format_line = None
  111. detail_format_line = None
  112. def __new__(cls,proto,*args,**kwargs):
  113. return MMGenObject.__new__(proto.base_proto_subclass(cls,cls.mod_subpath))
  114. async def __init__(self,proto):
  115. self.proto = proto
  116. self.rpc = await rpc_init(proto)
  117. if self.has_wallet:
  118. from .ctl import TwCtl
  119. self.twctl = await TwCtl(proto,mode='w')
  120. self.amt_keys = {'amt':'iwidth','amt2':'iwidth2'} if self.has_amt2 else {'amt':'iwidth'}
  121. @property
  122. def age_w(self):
  123. return self.age_col_params[self.age_fmt][0]
  124. @property
  125. def age_hdr(self):
  126. return self.age_col_params[self.age_fmt][1]
  127. @property
  128. def age_fmt(self):
  129. return self._age_fmt
  130. @age_fmt.setter
  131. def age_fmt(self,val):
  132. ok_vals,op_desc = (
  133. (self.age_fmts_interactive,'interactive') if self.interactive else
  134. (self.age_fmts,'non-interactive') )
  135. if val not in ok_vals:
  136. die('BadAgeFormat',
  137. f'{val!r}: invalid age format for {op_desc} operation (must be one of {ok_vals!r})' )
  138. self._age_fmt = val
  139. def age_disp(self,o,age_fmt):
  140. if age_fmt == 'confs':
  141. return o.confs or '-'
  142. elif age_fmt == 'block':
  143. return self.rpc.blockcount + 1 - o.confs if o.confs else '-'
  144. else:
  145. return self.date_formatter[age_fmt](self.rpc,o.date)
  146. def get_disp_prec(self,wide):
  147. return self.proto.coin_amt.max_prec
  148. sort_disp = {
  149. 'addr': 'Addr',
  150. 'age': 'Age',
  151. 'amt': 'Amt',
  152. 'txid': 'TxID',
  153. 'twmmid': 'MMGenID',
  154. }
  155. sort_funcs = {
  156. 'addr': lambda i: i.addr,
  157. 'age': lambda i: 0 - i.confs,
  158. 'amt': lambda i: i.amt,
  159. 'txid': lambda i: f'{i.txid} {i.vout:04}',
  160. 'twmmid': lambda i: i.twmmid.sort_key
  161. }
  162. def sort_info(self,include_group=True):
  163. ret = ([],['Reverse'])[self.reverse]
  164. ret.append(self.sort_disp[self.sort_key])
  165. if include_group and self.group and (self.sort_key in ('addr','txid','twmmid')):
  166. ret.append('Grouped')
  167. return ret
  168. def do_sort(self,key=None,reverse=False):
  169. key = key or self.sort_key
  170. if key not in self.sort_funcs:
  171. die(1,f'{key!r}: invalid sort key. Valid options: {" ".join(self.sort_funcs)}')
  172. self.sort_key = key
  173. assert type(reverse) == bool
  174. self.data.sort(key=self.sort_funcs[key],reverse=reverse or self.reverse)
  175. async def get_data(self,sort_key=None,reverse_sort=False):
  176. rpc_data = await self.get_rpc_data()
  177. if not rpc_data:
  178. die(0,fmt(self.no_rpcdata_errmsg).strip())
  179. lbl_id = ('account','label')['label_api' in self.rpc.caps]
  180. res = self.gen_data(rpc_data,lbl_id)
  181. self.data = MMGenList(await res if type(res).__name__ == 'coroutine' else res)
  182. self.disp_data = list(self.filter_data())
  183. if not self.data:
  184. die(1,self.no_data_errmsg)
  185. self.do_sort(key=sort_key,reverse=reverse_sort)
  186. def filter_data(self):
  187. return self.data.copy()
  188. def get_term_columns(self,min_cols):
  189. from ..term import get_terminal_size,get_char_raw
  190. while True:
  191. cols = g.columns or get_terminal_size().width
  192. if cols >= min_cols:
  193. return cols
  194. if sys.stdout.isatty():
  195. if g.columns:
  196. die(1,'\n'+fmt(self.tcols_errmsg.format(g.columns,self.desc,min_cols),indent=' '))
  197. else:
  198. get_char_raw('\n'+fmt(self.twidth_errmsg.format(self.desc,min_cols),append=''))
  199. else:
  200. return min_cols
  201. def compute_column_widths(self,widths,maxws,minws,maxws_nice={},wide=False):
  202. def do_ret(freews):
  203. widths.update({k:minws[k] + freews.get(k,0) for k in minws})
  204. widths.update({ikey: widths[key] - self.disp_prec - 1 for key,ikey in self.amt_keys.items()})
  205. return namedtuple('column_widths',widths.keys())(*widths.values())
  206. def do_ret_max():
  207. widths.update({k:max(minws[k],maxws[k]) for k in minws})
  208. widths.update({ikey: widths[key] - self.disp_prec - 1 for key,ikey in self.amt_keys.items()})
  209. return namedtuple('column_widths',widths.keys())(*widths.values())
  210. def get_freews(cols,varws,varw,minw):
  211. freew = cols - minw
  212. if freew and varw:
  213. x = freew / varw
  214. freews = {k:int(varws[k] * x) for k in varws}
  215. remainder = freew - sum(freews.values())
  216. for k in varws:
  217. if not remainder:
  218. break
  219. if freews[k] < varws[k]:
  220. freews[k] += 1
  221. remainder -= 1
  222. return freews
  223. else:
  224. return {k:0 for k in varws}
  225. if wide:
  226. return do_ret_max()
  227. varws = {k:maxws[k] - minws[k] for k in maxws if maxws[k] > minws[k]}
  228. minw = sum(widths.values()) + sum(minws.values())
  229. varw = sum(varws.values())
  230. term_cols = self.get_term_columns(minw)
  231. self.cols = min(term_cols,minw + varw)
  232. if self.cols == minw + varw:
  233. return do_ret_max()
  234. if maxws_nice:
  235. # compute high-priority widths:
  236. varws_hp = {k: maxws_nice[k] - minws[k] if k in maxws_nice else varws[k] for k in varws}
  237. varw_hp = sum(varws_hp.values())
  238. widths_hp = get_freews(
  239. min(term_cols,minw + varw_hp),
  240. varws_hp,
  241. varw_hp,
  242. minw )
  243. # compute low-priority (nice) widths:
  244. varws_lp = {k: varws[k] - varws_hp[k] for k in maxws_nice if k in varws}
  245. widths_lp = get_freews(
  246. self.cols,
  247. varws_lp,
  248. sum(varws_lp.values()),
  249. minw + sum(widths_hp.values()) )
  250. # sum the two for each field:
  251. return do_ret({k:widths_hp[k] + widths_lp.get(k,0) for k in varws})
  252. else:
  253. return do_ret(get_freews(self.cols,varws,varw,minw))
  254. def header(self,color):
  255. Blue,Green = (blue,green) if color else (nocolor,nocolor)
  256. Yes,No,All = (green('yes'),red('no'),yellow('all')) if color else ('yes','no','all')
  257. def fmt_filter(k):
  258. return '{}:{}'.format(k,{0:No,1:Yes,2:All}[getattr(self,k)])
  259. return '{h} (sort order: {s}){f}\nNetwork: {n}\nBlock {b} [{d}]\n{t}'.format(
  260. h = self.hdr_lbl.upper(),
  261. f = '\nFilters: '+' '.join(fmt_filter(k) for k in self.filters) if self.filters else '',
  262. s = Blue(' '.join(self.sort_info())),
  263. n = Green(self.proto.coin + ' ' + self.proto.chain_name.upper()),
  264. b = self.rpc.blockcount.hl(color=color),
  265. d = make_timestr(self.rpc.cur_date),
  266. t = f'Total {self.proto.dcoin}: {self.total.hl(color=color)}\n' if hasattr(self,'total') else '',
  267. )
  268. def subheader(self,color):
  269. return ''
  270. def footer(self,color):
  271. return '\nTOTAL: {} {}\n'.format(
  272. self.total.hl(color=color) if hasattr(self,'total') else None,
  273. self.proto.dcoin
  274. ) if hasattr(self,'total') else ''
  275. def set_amt_widths(self,data):
  276. # width of amts column: min(7,width of integer part) + len('.') + width of fractional part
  277. self.amt_widths = {k:
  278. min(7,max(len(str(getattr(d,k).to_integral_value())) for d in data)) + 1 + self.disp_prec
  279. for k in self.amt_keys}
  280. async def format(self,display_type,color=True,cached=False,interactive=False,line_processing=None):
  281. if not cached:
  282. dt = getattr(self.display_type,display_type)
  283. self.disp_prec = self.get_disp_prec(wide=dt.detail)
  284. if self.has_age and (self.age_fmt in self.age_fmts_date_dependent or dt.detail):
  285. await self.set_dates(self.data)
  286. data = self.disp_data = list(self.filter_data()) # method could be a generator
  287. if data and dt.need_column_widths:
  288. self.set_amt_widths(data)
  289. cw = self.get_column_widths(data,wide=dt.detail)
  290. cwh = cw._asdict()
  291. fp = self.fs_params
  292. hdr_fs = ''.join(fp[name].hdr_fs % ((),cwh[name])[fp[name].hdr_fs_repl]
  293. for name in dt.cols if cwh[name]) + '\n'
  294. fs = ''.join(fp[name].fs % ((),cwh[name])[fp[name].fs_repl]
  295. for name in dt.cols if cwh[name])
  296. else:
  297. cw = hdr_fs = fs = None
  298. if line_processing:
  299. lp_cls = getattr(self.line_processing,line_processing)
  300. color = lp_cls.color
  301. def get_body(method):
  302. if line_processing:
  303. return lp_cls.do(method,data,cw,fs,color,getattr(self,dt.line_fmt_method))
  304. else:
  305. return method(data,cw,fs,color,getattr(self,dt.line_fmt_method))
  306. self._display_data[display_type] = '{a}{b}\n{c}{d}\n'.format(
  307. a = self.header(color),
  308. b = self.subheader(color),
  309. c = getattr(self,dt.hdr_fmt_method)(cw,hdr_fs,color) if data else '',
  310. d = (
  311. dt.item_separator.join(get_body(getattr(self,dt.fmt_method))) if data else
  312. (nocolor,yellow)[color]('[no data for requested parameters]'))
  313. )
  314. return self._display_data[display_type] + ('' if interactive else self.footer(color))
  315. async def view_filter_and_sort(self):
  316. from ..opts import opt
  317. from ..term import get_char
  318. prompt = self.prompt.strip() + '\b'
  319. self.no_output = False
  320. self.oneshot_msg = None
  321. self.interactive = True
  322. immed_chars = ''.join(self.key_mappings.keys())
  323. CUR_RIGHT = lambda n: f'\033[{n}C'
  324. CUR_HOME = '\033[H'
  325. ERASE_ALL = '\033[0J'
  326. self.cursor_to_end_of_prompt = CUR_RIGHT( len(prompt.split('\n')[-1]) - 2 )
  327. clear_screen = '\n\n' if (opt.no_blank or g.test_suite) else CUR_HOME + ERASE_ALL
  328. while True:
  329. reply = get_char(
  330. '' if self.no_output else (
  331. clear_screen
  332. + await self.format('squeezed',interactive=True)
  333. + '\n'
  334. + (self.oneshot_msg or '')
  335. + prompt
  336. ),
  337. immed_chars = immed_chars )
  338. self.no_output = False
  339. self.oneshot_msg = '' if self.oneshot_msg else None # tristate, saves previous state
  340. if reply not in immed_chars:
  341. msg_r('\ninvalid keypress ')
  342. await asyncio.sleep(0.3)
  343. continue
  344. action = self.key_mappings[reply]
  345. if hasattr(self.action,action):
  346. await self.action().run(self,action)
  347. elif action.startswith('s_'): # put here to allow overriding by action method
  348. self.do_sort(action[2:])
  349. elif hasattr(self.item_action,action):
  350. await self.item_action().run(self,action)
  351. elif action == 'a_quit':
  352. msg('')
  353. return self.disp_data
  354. class action:
  355. async def run(self,parent,action):
  356. ret = getattr(self,action)(parent)
  357. if type(ret).__name__ == 'coroutine':
  358. await ret
  359. def d_days(self,parent):
  360. af = parent.age_fmts_interactive
  361. parent.age_fmt = af[(af.index(parent.age_fmt) + 1) % len(af)]
  362. if parent.update_widths_on_age_toggle: # TODO
  363. pass
  364. def d_redraw(self,parent):
  365. pass
  366. def d_reverse(self,parent):
  367. parent.data.reverse()
  368. parent.reverse = not parent.reverse
  369. async def a_print_detail(self,parent):
  370. return await self._print(parent,output_type='detail')
  371. async def a_print_squeezed(self,parent):
  372. return await self._print(parent,output_type='squeezed')
  373. async def _print(self,parent,output_type):
  374. outfile = '{}{}-{}{}[{}].out'.format(
  375. parent.dump_fn_pfx,
  376. f'-{output_type}' if len(parent.print_output_types) > 1 else '',
  377. parent.proto.dcoin,
  378. ('' if parent.proto.network == 'mainnet' else '-'+parent.proto.network.upper()),
  379. ','.join(parent.sort_info(include_group=False)).replace(' ','') )
  380. msg('')
  381. from ..fileutil import write_data_to_file
  382. from ..exception import UserNonConfirmation
  383. print_hdr = getattr(parent.display_type,output_type).print_header.format(parent.cols)
  384. try:
  385. write_data_to_file(
  386. outfile = outfile,
  387. data = print_hdr + await parent.format(
  388. display_type = output_type,
  389. line_processing = 'print' ),
  390. desc = f'{parent.desc} listing' )
  391. except UserNonConfirmation as e:
  392. parent.oneshot_msg = yellow(f'File {outfile!r} not overwritten by user request\n\n')
  393. else:
  394. parent.oneshot_msg = green(f'Data written to {outfile!r}\n\n')
  395. async def a_view(self,parent):
  396. from ..ui import do_pager
  397. do_pager( await parent.format('squeezed',color=True,cached=True) )
  398. self.post_view(parent)
  399. async def a_view_detail(self,parent):
  400. from ..ui import do_pager
  401. do_pager( await parent.format('detail',color=True) )
  402. self.post_view(parent)
  403. def post_view(self,parent):
  404. if g.platform == 'linux' and parent.oneshot_msg == None:
  405. msg_r(parent.cursor_to_end_of_prompt)
  406. parent.no_output = True
  407. class item_action:
  408. async def run(self,parent,action):
  409. msg('')
  410. from ..ui import line_input
  411. while True:
  412. ret = line_input(f'Enter {parent.item_desc} number (or ENTER to return to main menu): ')
  413. if ret == '':
  414. return None
  415. idx = get_obj(MMGenIdx,n=ret,silent=True)
  416. if not idx or idx < 1 or idx > len(parent.disp_data):
  417. msg(f'Choice must be a single number between 1 and {len(parent.disp_data)}')
  418. elif (await getattr(self,action)(parent,idx)) != 'redo':
  419. break
  420. async def a_balance_refresh(self,parent,idx):
  421. from ..ui import keypress_confirm
  422. if not keypress_confirm(
  423. f'Refreshing tracking wallet {parent.item_desc} #{idx}. Is this what you want?'):
  424. return 'redo'
  425. await parent.twctl.get_balance( parent.disp_data[idx-1].addr, force_rpc=True )
  426. await parent.get_data()
  427. parent.oneshot_msg = yellow(f'{parent.proto.dcoin} balance for account #{idx} refreshed\n\n')
  428. async def a_addr_delete(self,parent,idx):
  429. from ..ui import keypress_confirm
  430. if not keypress_confirm(
  431. 'Removing {} {} from tracking wallet. Is this what you want?'.format(
  432. parent.item_desc, red(f'#{idx}') )):
  433. return 'redo'
  434. if await parent.twctl.remove_address( parent.disp_data[idx-1].addr ):
  435. await parent.get_data()
  436. parent.oneshot_msg = yellow(f'{capfirst(parent.item_desc)} #{idx} removed\n\n')
  437. else:
  438. await asyncio.sleep(3)
  439. parent.oneshot_msg = red('Address could not be removed\n\n')
  440. async def a_comment_add(self,parent,idx):
  441. async def do_comment_add(comment):
  442. if await parent.twctl.set_comment( entry.twmmid, comment, entry.addr ):
  443. entry.comment = comment
  444. parent.oneshot_msg = yellow('Label {a} {b}{c}\n\n'.format(
  445. a = 'for' if cur_comment and comment else 'added to' if comment else 'removed from',
  446. b = desc,
  447. c = ' edited' if cur_comment and comment else '' ))
  448. return True
  449. else:
  450. await asyncio.sleep(3)
  451. parent.oneshot_msg = red('Label for {desc} could not be {action}\n\n'.format(
  452. desc = desc,
  453. action = 'edited' if cur_comment and comment else 'added' if comment else 'removed'
  454. ))
  455. return False
  456. entry = parent.disp_data[idx-1]
  457. desc = f'{parent.item_desc} #{idx}'
  458. cur_comment = parent.disp_data[idx-1].comment
  459. msg('Current label: {}'.format(cur_comment.hl() if cur_comment else '(none)'))
  460. from ..ui import line_input
  461. res = line_input(
  462. 'Enter label text for {} {}: '.format(parent.item_desc,red(f'#{idx}')),
  463. insert_txt = cur_comment )
  464. if res == cur_comment:
  465. parent.oneshot_msg = green(f'Label for {desc} unchanged\n\n')
  466. return None
  467. elif res == '':
  468. from ..ui import keypress_confirm
  469. if not keypress_confirm(f'Removing label for {desc}. Is this what you want?'):
  470. return None
  471. return await do_comment_add(res)