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

143 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-01-02 16:08 -0800

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

2from dataclasses import dataclass, field 

3from io import StringIO 

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

5from .lexers import MAGIC 

6 

7 

8@dataclass 

9class Python(Renderer): 

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

11 

12 # include markdown as docstrings of functions and classes 

13 include_docstring: bool = True 

14 # include docstring as a code block 

15 include_doctest: bool = False 

16 # include front matter as a code block 

17 include_front_matter: bool = True 

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

19 include_markdown: bool = True 

20 # code fence languages that indicate a code block 

21 include_code_fences: list = field(default_factory=["python", "ipython"].copy) 

22 include_magic: bool = True 

23 

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

25 QUOTE = QUOTES[0] 

26 

27 def code_block(self, token, env): 

28 if token.meta["is_doctest"]: 

29 if self.include_doctest: 

30 yield from self.code_block_doctest(token, env) 

31 elif self.include_indented_code: 

32 yield from self.non_code(env, token) 

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

34 self.get_updated_env(token, env) 

35 

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

37 if token.meta["is_doctest"]: 

38 block = self.get_block_sans_doctest(block) 

39 if self.is_magic(token): 

40 block = self.code_block_magic(block, token.meta["min_indent"], env) 

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

42 

43 def code_block_doctest(self, token, env): 

44 yield from self.non_code(env, token) 

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

46 if token.meta["output"]: 

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

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

49 yield from self.comment(block, env) 

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

51 

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

53 line = next(block) 

54 left = line.rstrip() 

55 # split magic name and arguments 

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

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

58 yield SP * self.get_computed_indent(env) 

59 # prefix the ipython run cell magic caller 

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

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

62 if dedent: 

63 block = self.dedent_block(block, indent) 

64 # quote the block of the cell body 

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

66 

67 def comment(self, block, env): 

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

69 

70 def dedent_block(self, block, dedent): 

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

72 

73 def fence(self, token, env): 

74 """the fence renderer is pluggable. 

75 

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

77 

78 if token.info: 

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

80 if method: 

81 return method(token, env) 

82 

83 if token.meta["is_magic_info"]: 

84 return self._fence_info_magic(token, env) 

85 

86 # def _flush_magic(self): 

87 

88 def _fence_info_magic(self, token, env): 

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

90 

91 yield from self.non_code(env, token) 

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

93 left = line.rstrip() 

94 right = left.lstrip() 

95 markup = right[0] 

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

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

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

99 

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

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

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

103 

104 self.get_updated_env(token, env) 

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

106 

107 def fence_python(self, token, env): 

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

109 if token.info in self.include_code_fences: 

110 yield from self.non_code(env, token) 

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

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

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

114 self.get_updated_env(token, env) 

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

116 

117 fence_ipython = fence_python 

118 

119 def front_matter(self, token, env): 

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

121 if self.include_front_matter: 

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

123 trail = self.QUOTE + "))" 

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

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

126 else: 

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

128 

129 def get_block_sans_doctest(self, block): 

130 for line in block: 

131 right = line.lstrip() 

132 if right: 

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

134 yield line 

135 

136 def get_computed_indent(self, env): 

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

138 next = env.get("next_code") 

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

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

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

142 if next_indent > prior_indent: 

143 spaces = next_indent # prefer greater trailing indent 

144 else: 

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

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

147 return max(spaces, min_indent) - min_indent 

148 

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

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

151 # can do this better with buffers 

152 ws, any, continued = "", False, False 

153 for line in lines: 

154 LL = len(line.rstrip()) 

155 if LL: 

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

157 LL -= 1 * continued 

158 if any: 

159 yield ws 

160 else: 

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

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

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

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

165 else: 

166 ws += line 

167 if any: 

168 yield trail 

169 if continued: 

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

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

172 else: 

173 yield ws 

174 

175 def is_magic(self, token): 

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

177 return True 

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

179 

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

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

182 if self.include_markdown: 

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

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

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

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

187 yield from self.get_wrapped_lines( 

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

189 ) 

190 else: 

191 yield from self.comment(block, env) 

192 

193 def render(self, src): 

194 if MAGIC.match(src): 

195 from textwrap import dedent 

196 

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

198 return super().render(src) 

199 

200 def shebang(self, token, env): 

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