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
« 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
8@dataclass
9class Python(Renderer):
10 """a line-for-line markdown to python translator"""
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
24 front_matter_loader = '__import__("midgy").front_matter.load'
25 QUOTE = QUOTES[0]
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)
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"])
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)
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 + ")")
67 def comment(self, block, env):
68 yield from self.get_wrapped_lines(block, pre=SP * self.get_computed_indent(env) + "# ")
70 def dedent_block(self, block, dedent):
71 yield from (x[dedent:] for x in block)
73 def fence(self, token, env):
74 """the fence renderer is pluggable.
76 if token_{token.info} exists then that method is called to render the token"""
78 if token.info:
79 method = getattr(self, f"fence_{token.info}", None)
80 if method:
81 return method(token, env)
83 if token.meta["is_magic_info"]:
84 return self._fence_info_magic(token, env)
86 # def _flush_magic(self):
88 def _fence_info_magic(self, token, env):
89 """return a modified code fence that identifies as code"""
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) :])
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 + ")")
104 self.get_updated_env(token, env)
105 yield from self.comment(self.get_block(env, token.map[1]), env)
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)
117 fence_ipython = fence_python
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)
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
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
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
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"
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)
193 def render(self, src):
194 if MAGIC.match(src):
195 from textwrap import dedent
197 return "".join(self.code_block_magic(StringIO(dedent(src)), 0, {}))
198 return super().render(src)
200 def shebang(self, token, env):
201 yield from self.get_block(env, token.map[1])