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
« 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"
11@dataclass
12class Python(Renderer):
13 """a line-for-line markdown to python translator"""
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
27 front_matter_loader = '__import__("midgy").front_matter.load'
28 QUOTE = QUOTES[0]
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)
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"])
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)
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 + ")")
70 def comment(self, block, env):
71 yield from self.get_wrapped_lines(block, pre=SP * self.get_computed_indent(env) + "# ")
73 def dedent_block(self, block, dedent):
74 yield from (x[dedent:] for x in block)
76 def fence(self, token, env):
77 """the fence renderer is pluggable.
79 if token_{token.info} exists then that method is called to render the token"""
81 if token.info:
82 method = getattr(self, f"fence_{token.info}", None)
83 if method:
84 return method(token, env)
86 if token.meta["is_magic_info"]:
87 return self._fence_info_magic(token, env)
90 def _fence_info_magic(self, token, env):
91 """return a modified code fence that identifies as code"""
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) :])
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 + ")")
106 self.get_updated_env(token, env)
107 yield from self.comment(self.get_block(env, token.map[1]), env)
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)
119 fence_ipython = fence_python
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)
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
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
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
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"
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)
195 def render(self, src):
196 if MAGIC.match(src):
197 from textwrap import dedent
199 return "".join(self.code_block_magic(StringIO(dedent(src)), 0, {}))
200 return super().render(src)
202 def shebang(self, token, env):
203 yield from self.get_block(env, token.map[1])