Coverage for larch/inputText.py: 80%
244 statements
« prev ^ index » next coverage.py v7.6.0, created at 2024-10-16 21:04 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2024-10-16 21:04 +0000
1#!/usr/bin/env python
2#
3# InputText for Larch
5import os
6import sys
7import io
8import time
9from pathlib import Path
10from collections import deque
11from copy import copy
13from .utils import read_textfile
15OPENS = '{(['
16CLOSES = '})]'
17PARENS = dict(zip(OPENS, CLOSES))
18QUOTES = '\'"'
19BSLASH = '\\'
20COMMENT = '#'
21DBSLASH = "%s%s" % (BSLASH, BSLASH)
23BLOCK_FRIENDS = {'if': ('else', 'elif'),
24 'for': ('else',),
25 'def': (),
26 'try': ('else', 'except', 'finally'),
27 'while': ('else',),
28 None: ()}
30STARTKEYS = ['if', 'for', 'def', 'try', 'while']
33def find_eostring(txt, eos, istart):
34 """find end of string token for a string"""
35 while True:
36 inext = txt[istart:].find(eos)
37 if inext < 0: # reached end of text before match found
38 return eos, len(txt)
39 elif (txt[istart+inext-1] == BSLASH and
40 txt[istart+inext-2] != BSLASH): # matched quote was escaped
41 istart = istart+inext+len(eos)
42 else: # real match found! skip ahead in string
43 return '', istart+inext+len(eos)-1
45def is_complete(text):
46 """returns whether a text of code is complete
47 for strings quotes and open / close delimiters,
48 including nested delimeters.
49 """
50 itok = istart = 0
51 eos = ''
52 delims = []
53 while itok < len(text):
54 c = text[itok]
55 if c in QUOTES:
56 eos = c
57 if text[itok:itok+3] == c*3:
58 eos = c*3
59 istart = itok + len(eos)
60 # leap ahead to matching quote, ignoring text within
61 eos, itok = find_eostring(text, eos, istart)
62 elif c in OPENS:
63 delims.append(PARENS[c])
64 elif c in CLOSES and len(delims) > 0 and c == delims[-1]:
65 delims.pop()
66 elif c == COMMENT and eos == '': # comment char outside string
67 jtok = itok
68 if '\n' in text[itok:]:
69 itok = itok + text[itok:].index('\n')
70 else:
71 itok = len(text)
72 itok += 1
73 return eos=='' and len(delims)==0 and not text.rstrip().endswith(BSLASH)
75def strip_comments(text, char='#'):
76 """return text with end-of-line comments removed"""
77 out = []
78 for line in text.split('\n'):
79 if line.find(char) > 0:
80 i = 0
81 while i < len(line):
82 tchar = line[i]
83 if tchar == char:
84 line = line[:i]
85 break
86 elif tchar in ('"',"'"):
87 eos = line[i+1:].find(tchar)
88 if eos > 0:
89 i = i + eos
90 i += 1
91 out.append(line.rstrip())
92 return '\n'.join(out)
94def get_key(text):
95 """return keyword: first word of text,
96 isolating keywords followed by '(' and ':' """
97 t = text.replace('(', ' (').replace(':', ' :').strip()
98 if len(t) == 0:
99 return ''
100 return t.split(None, 1)[0].strip()
102def block_start(text):
103 """return whether a complete-extended-line of text
104 starts with a block-starting keyword, one of
105 ('if', 'for', 'try', 'while', 'def')
106 """
107 txt = strip_comments(text)
108 key = get_key(txt)
109 if key in STARTKEYS and txt.endswith(':'):
110 return key
111 return False
113def block_end(text):
114 """return whether a complete-extended-line of text
115 starts wih block-ending keyword,
116 '#end' + ('if', 'for', 'try', 'while', 'def')
117 """
118 txt = text.strip()
119 if txt.startswith('#end') or txt.startswith('end'):
120 n = 3
121 if txt.startswith('#end'):
122 n = 4
123 key = txt[n:].split(None, 1)[0].strip()
124 if key in STARTKEYS:
125 return key
126 return False
128BLANK_TEXT = ('', '<incomplete input>', -1)
131class HistoryBuffer(object):
132 """
133 command history buffer
134 """
135 def __init__(self, filename=None, maxlines=5000, title='larch history'):
136 self.filename = filename
137 self.maxlines = maxlines
138 self.title = title
139 self.session_start = 0
140 self.buffer = []
141 if filename is not None:
142 self.load(filename=filename)
144 def add(self, text=''):
145 if len(text.strip()) > 0:
146 self.buffer.append(text)
148 def clear(self):
149 self.buffer = []
150 self.session_start = 0
152 def load(self, filename=None):
153 if filename is not None:
154 self.filename = filename
155 if Path(filename).exists():
156 self.clear()
157 text = read_textfile(filename).split('\n')
158 for hline in text:
159 if not hline.startswith("# larch history"):
160 self.add(text=hline)
161 self.session_start = len(self.buffer)
163 def get(self, session_only=False, trim_last=False, maxlines=None):
164 if maxlines is None:
165 maxlines = self.maxlines
166 start_ = -maxlines
167 if session_only:
168 start_ = self.session_start
169 end_ = None
170 if trim_last:
171 end_ = -1
173 comment = "# %s saved" % (self.title)
174 out = ["%s %s" % (comment, time.ctime())]
175 for bline in self.buffer[start_:end_]:
176 if not (bline.startswith(comment) or len(bline) < 0):
177 out.append(str(bline))
178 out.append('')
179 return out
181 def save(self, filename=None, session_only=False,
182 trim_last=False, maxlines=None):
183 if filename is None:
184 filename = self.filename
185 out = self.get(session_only=session_only,
186 trim_last=trim_last,
187 maxlines=maxlines)
188 out.append('')
190 with open(filename, 'w', encoding=sys.getdefaultencoding()) as fh:
191 fh.write('\n'.join(out))
193class InputText:
194 """input text for larch, with history"""
195 def __init__(self, _larch=None, historyfile=None, maxhistory=5000,
196 prompt='larch> ',prompt2 = ".....> "):
197 self.deque = deque()
198 self.filename = '<stdin>'
199 self.lineno = 0
200 self.curline = 0
201 self.curtext = ''
202 self.blocks = []
203 self.buffer = []
204 self.larch = _larch
205 self.prompt = prompt
206 self.prompt2 = prompt2
207 self.saved_text = BLANK_TEXT
208 self.history = HistoryBuffer(filename=historyfile,
209 maxlines=maxhistory)
211 def __len__(self):
212 return len(self.deque)
214 def get(self):
215 """get compile-able block of python code"""
216 out = []
217 filename, linenumber = None, None
218 if self.saved_text != BLANK_TEXT:
219 txt, filename, lineno = self.saved_text
220 out.append(txt)
221 text, fn, ln, done = self.deque.popleft()
222 out.append(text)
223 if filename is None:
224 filename = fn
225 if linenumber is None:
226 linenumber = ln
228 while not done:
229 if len(self.deque) == 0:
230 self.saved_text = ("\n".join(out), filename, linenumber)
231 return BLANK_TEXT
232 text, fn, ln, done = self.deque.popleft()
233 out.append(text)
234 self.saved_text = BLANK_TEXT
235 return ("\n".join(out), filename, linenumber)
237 def clear(self):
238 self.deque.clear()
239 self.saved_text = BLANK_TEXT
240 self.curtext = ''
241 self.blocks = []
243 def putfile(self, filename):
244 """add the content of a file at the top of the stack
245 that is, to be run next, as for run('myscript.lar')
247 Parameters
248 ----------
249 filename : file object or string of filename
251 Returns
252 -------
253 None on success,
254 (exception, message) on failure
255 """
257 text = None
258 try:
259 text = read_textfile(filename)
260 except:
261 errtype, errmsg, errtb = sys.exc_info()
262 return (errtype, errmsg)
264 if isinstance(filename, io.IOBase):
265 filename = filename.name
267 if text is None:
268 return (IOError, 'cannot read %s' % filename)
270 current = None
271 if len(self.deque) > 0:
272 current = copy(self.deque)
273 self.deque.clear()
274 self.put(text, filename=filename, lineno=0, add_history=False)
276 if current is not None:
277 self.deque.extend(current)
279 def put(self, text, filename=None, lineno=None, add_history=True):
280 """add a line of input code text"""
281 if filename is not None:
282 self.filename = filename
283 if lineno is not None:
284 self.lineno = lineno
286 if self.larch is not None:
287 getsym = self.larch.symtable.get_symbol
288 self.valid_commands = getsym('_sys.valid_commands', create=True)
290 if self.history is not None and add_history:
291 self.history.add(text)
293 for txt in text.split('\n'):
294 self.lineno += 1
295 if len(self.curtext) == 0:
296 self.curtext = txt
297 self.curline = self.lineno
298 else:
299 self.curtext = "%s\n%s" % (self.curtext, txt)
301 blk_start = False
302 if is_complete(self.curtext) and len(self.curtext)>0:
303 blk_start = block_start(self.curtext)
304 if blk_start:
305 self.blocks.append((blk_start, self.lineno, txt))
306 else:
307 blk_end = block_end(self.curtext)
308 if (blk_end and len(self.blocks) > 0 and
309 blk_end == self.blocks[-1][0]):
310 self.blocks.pop()
311 if self.curtext.strip().startswith('end'):
312 nblank = self.curtext.find(self.curtext.strip())
313 self.curtext = '%s#%s' % (' '*nblank,
314 self.curtext.strip())
316 _delim = None
317 if len(self.blocks) > 0:
318 _delim = self.blocks[-1][0]
320 key = get_key(self.curtext)
321 ilevel = len(self.blocks)
322 if ilevel > 0 and (blk_start or
323 key in BLOCK_FRIENDS[_delim]):
324 ilevel = ilevel - 1
326 sindent = ' '*4*ilevel
327 pytext = "%s%s" % (sindent, self.curtext.strip())
328 # look for valid commands
329 if key in self.valid_commands and '\n' not in self.curtext:
330 argtext = self.curtext.strip()[len(key):].strip()
331 if not (argtext.startswith('(') and
332 argtext.endswith(')') ):
333 pytext = "%s%s(%s)" % (sindent, key, argtext)
335 self.deque.append((pytext, self.filename,
336 self.curline, 0==len(self.blocks)))
338 self.curtext = ''
340 @property
341 def complete(self):
342 return len(self.curtext)==0 and len(self.blocks)==0
344 @property
345 def next_prompt(self):
346 if len(self.curtext)==0 and len(self.blocks)==0:
347 return self.prompt
348 return self.prompt2