Coverage for src/midgy/python.py: 100%

147 statements  

« prev     ^ index     » next       coverage.py v7.1.0, created at 2023-02-14 16:02 -0800

1"""the Python class that translates markdown to python code""" 

2from dataclasses import dataclass, field 

3from io import StringIO 

4from copy import copy 

5from functools import partial 

6from .render import Renderer, escape, FENCE, SP, QUOTES 

7from .lexers import MAGIC 

8from typing import Tuple 

9DEFAULT_FENCE_LANGS = "python", "ipython" 

10 

11@dataclass 

12class Python(Renderer): 

13 """a line-for-line markdown to python translator""" 

14 

15 # include markdown as docstrings of functions and classes 

16 include_docstring: bool = True 

17 # include docstring as a code block 

18 include_doctest: bool = False 

19 # include front matter as a code block 

20 include_front_matter: bool = True 

21 # include markdown in that code as strings, (False) uses comments 

22 include_markdown: bool = True 

23 # code fence languages that indicate a code block 

24 include_code_fences: Tuple[str] = field(default_factory=partial(copy, DEFAULT_FENCE_LANGS)) 

25 include_magic: bool = True 

26 

27 front_matter_loader = '__import__("midgy").front_matter.load' 

28 QUOTE = QUOTES[0] 

29 

30 def code_block(self, token, env): 

31 if token.meta["is_doctest"]: 

32 if self.include_doctest: 

33 yield from self.code_block_doctest(token, env) 

34 elif self.include_indented_code: 

35 yield from self.non_code(env, token) 

36 yield from self.code_block_body(self.get_block(env, token.map[1]), token, env) 

37 self.get_updated_env(token, env) 

38 

39 def code_block_body(self, block, token, env): 

40 if token.meta["is_doctest"]: 

41 block = self.get_block_sans_doctest(block) 

42 if self.is_magic(token): 

43 block = self.code_block_magic(block, self.get_computed_indent(env), env) 

44 yield from self.dedent_block(block, (not token.meta["is_magic"]) * env["min_indent"]) 

45 

46 def code_block_doctest(self, token, env): 

47 yield from self.non_code(env, token) 

48 yield from self.code_block_body(self.get_block(env, token.meta["input"][1]), token, env) 

49 if token.meta["output"]: 

50 block = self.get_block(env, token.meta["output"][1]) 

51 block = self.dedent_block(block, token.meta["min_indent"]) 

52 yield from self.comment(block, env) 

53 self.get_updated_env(token, env, colon_block=False, quoted_block=False, continued=False) 

54 

55 def code_block_magic(self, block, indent, env, dedent=True): 

56 line = next(block) 

57 left = line.rstrip() 

58 # split magic name and arguments 

59 program, _, args = left.lstrip().lstrip("%").partition(" ") 

60 # add whitespace relative to the indents allowing for condition magics 

61 yield SP * self.get_computed_indent(env) 

62 # prefix the ipython run cell magic caller 

63 yield from ("get_ipython().run_cell_magic('", program, "', '") 

64 yield from (args, "',", line[len(left) :]) 

65 if dedent: 

66 block = self.dedent_block(block, indent + env.get("min_indent", 0)) 

67 # quote the block of the cell body 

68 yield from self.get_wrapped_lines(block, lead=self.QUOTE, trail=self.QUOTE + ")") 

69 

70 def comment(self, block, env): 

71 yield from self.get_wrapped_lines(block, pre=SP * self.get_computed_indent(env) + "# ") 

72 

73 def dedent_block(self, block, dedent): 

74 yield from (x[dedent:] for x in block) 

75 

76 def fence(self, token, env): 

77 """the fence renderer is pluggable. 

78 

79 if token_{token.info} exists then that method is called to render the token""" 

80 

81 if token.info: 

82 method = getattr(self, f"fence_{token.info}", None) 

83 if method: 

84 return method(token, env) 

85 

86 if token.meta["is_magic_info"]: 

87 return self._fence_info_magic(token, env) 

88 

89 

90 def _fence_info_magic(self, token, env): 

91 """return a modified code fence that identifies as code""" 

92 

93 yield from self.non_code(env, token) 

94 line = next(self.get_block(env, token.map[0]+1)) 

95 left = line.rstrip() 

96 right = left.lstrip() 

97 markup = right[0] 

98 program, _, args = right.lstrip("`~").lstrip("%").partition(" ") 

99 yield from ("get_ipython().run_cell_magic('", program, "', '") 

100 yield from (args, "', # ", markup * 3 , line[len(left) :]) 

101 

102 block = self.get_block(env, token.map[1] - 1) 

103 block = self.dedent_block(block, token.meta["min_indent"]) 

104 yield from self.get_wrapped_lines(block, lead=self.QUOTE, trail=self.QUOTE + ")") 

105 

106 self.get_updated_env(token, env) 

107 yield from self.comment(self.get_block(env, token.map[1]), env) 

108 

109 def fence_python(self, token, env): 

110 """return a modified code fence that identifies as code""" 

111 if token.info in self.include_code_fences: 

112 yield from self.non_code(env, token) 

113 yield from self.comment(self.get_block(env, token.map[0] + 1), env) 

114 block = self.get_block(env, token.map[1] - 1) 

115 yield from self.code_block_body(block, token, env) 

116 self.get_updated_env(token, env) 

117 yield from self.comment(self.get_block(env, token.map[1]), env) 

118 

119 fence_ipython = fence_python 

120 

121 def front_matter(self, token, env): 

122 """comment, codify, or stringify blocks of front matter""" 

123 if self.include_front_matter: 

124 lead = f"locals().update({self.front_matter_loader}(" + self.QUOTE 

125 trail = self.QUOTE + "))" 

126 body = self.get_block(env, token.map[1]) 

127 yield from self.get_wrapped_lines(body, lead=lead, trail=trail) 

128 else: 

129 yield from self.comment(self.get_block(env, token.map[1]), env) 

130 

131 def get_block_sans_doctest(self, block): 

132 for line in block: 

133 right = line.lstrip() 

134 if right: 

135 line = line[: len(line) - len(right)] + right[4:] 

136 yield line 

137 

138 def get_computed_indent(self, env): 

139 """compute the indent for the first line of a non-code block.""" 

140 next = env.get("next_code") 

141 next_indent = next.meta["first_indent"] if next else 0 

142 spaces = prior_indent = env.get("last_indent", 0) 

143 if env.get("colon_block", False): # inside a python block 

144 if next_indent > prior_indent: 

145 spaces = next_indent # prefer greater trailing indent 

146 else: 

147 spaces += 4 # add post colon default spaces. 

148 min_indent = env.get("min_indent", 0) 

149 return max(spaces, min_indent) - min_indent 

150 

151 def get_wrapped_lines(self, lines, lead="", pre="", trail="", continuation=""): 

152 """a utility function to manipulate a buffer of content line-by-line.""" 

153 # can do this better with buffers 

154 ws, any, continued = "", False, False 

155 for line in lines: 

156 LL = len(line.rstrip()) 

157 if LL: 

158 continued = line[LL - 1] == "\\" 

159 LL -= 1 * continued 

160 if any: 

161 yield ws 

162 else: 

163 for i, l in enumerate(StringIO(ws)): 

164 yield from (pre, l[:-1], continuation, l[-1]) 

165 yield from (pre, lead, line[:LL]) 

166 lead, any, ws = "", True, line[LL:] 

167 else: 

168 ws += line 

169 if any: 

170 yield trail 

171 if continued: 

172 for i, line in enumerate(StringIO(ws)): 

173 yield from (i and pre or "", line[:-1], i and "\\" or "", line[-1]) 

174 else: 

175 yield ws 

176 

177 def is_magic(self, token): 

178 if self.include_magic and token.meta["is_magic"]: 

179 return True 

180 return token.type == FENCE and token.info == "ipython" 

181 

182 def non_code(self, env, next=None): 

183 block = super().non_code(env, next) 

184 if self.include_markdown: 

185 lead = trail = "" if env.get("quoted_block", False) else self.QUOTE 

186 lead = SP * self.get_computed_indent(env) + lead 

187 trail += "" if next else ";" 

188 continued = env.get("continued") and "\\" or "" 

189 yield from self.get_wrapped_lines( 

190 map(escape, block), lead=lead, trail=trail, continuation=continued 

191 ) 

192 else: 

193 yield from self.comment(block, env) 

194 

195 def render(self, src): 

196 if MAGIC.match(src): 

197 from textwrap import dedent 

198 

199 return "".join(self.code_block_magic(StringIO(dedent(src)), 0, {})) 

200 return super().render(src) 

201 

202 def shebang(self, token, env): 

203 yield from self.get_block(env, token.map[1])