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

92 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-24 15:45 -0700

1"""a minimal conversion from markdown to python code based on indented code blocks""" 

2 

3from dataclasses import dataclass, field 

4from pathlib import Path 

5from textwrap import dedent, indent 

6 

7from .render import DedentCodeBlock, escape 

8 

9__all__ = "Python", "md_to_python" 

10SP, QUOTES = chr(32), ('"' * 3, "'" * 3) 

11 

12# the Python class translates markdown to python with the minimum number 

13# of modifications necessary to have valid python code. midgy will: 

14## add triple quotes to make python block strings of markdown blocks 

15## escape quotes in markdown blocks 

16## add indents to conform with python concepts 

17# overall spaces, quotes, unicode escapes will be added to your markdown source. 

18@dataclass 

19class Python(DedentCodeBlock): 

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

21 

22 include_docstring: bool = True 

23 include_doctest: bool = False 

24 include_front_matter: bool = True 

25 include_markdown: bool = True 

26 extend_continuations: bool = True 

27 include_code_fences: list = field(default_factory=list) 

28 

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

30 quote_char = chr(34) 

31 

32 def code_block(self, token, env): 

33 if self.include_indented_code: 

34 yield from super().code_block(token, env) 

35 left = token.content.rstrip() 

36 continued = left.endswith("\\") 

37 if continued: 

38 left = left[:-1] 

39 env.update( 

40 colon_block=left.endswith(":"), 

41 quoted_block=left.endswith(QUOTES), 

42 continued=continued, 

43 ) 

44 

45 def code_fence_block(self, token, env): 

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

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

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

49 env.update(colon_block=False, quoted_block=False, continued=False) 

50 

51 def comment(self, body, env): 

52 return indent(dedent("".join(body)), SP * self._compute_indent(env) + "# ") 

53 

54 def doctest_comment(self, token, env): 

55 yield from self.non_code(env, token) 

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

57 

58 def doctest_code(self, token, env): 

59 ref = env["min_indent"] 

60 yield from self.non_code(env, token) 

61 for line in self.get_block(env, token.meta["input"][1]): 

62 right = line.lstrip() 

63 yield line[ref : len(line) - len(right)] + right[4:] 

64 if token.meta["output"]: 

65 yield self.comment(self.get_block(env, token.meta["output"][1]), env) 

66 env.update(colon_block=False, quoted_block=False, continued=False) 

67 

68 def fence_pycon(self, token, env): 

69 if self.include_doctest: 

70 yield from self.doctest_code(token, env) 

71 elif self.include_docstring and self.include_markdown: 

72 return 

73 else: 

74 yield from self.doctest_comment(token, env) 

75 

76 def format(self, body): 

77 """blacken the python""" 

78 from black import FileMode, format_str 

79 

80 return format_str(body, mode=FileMode()) 

81 

82 def front_matter(self, token, env): 

83 if self.include_front_matter: 

84 trail = self.quote_char * 3 

85 lead = f"locals().update({self.front_matter_loader}(" + trail 

86 trail += "))" 

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

88 yield from self.wrap_lines(body, lead=lead, trail=trail) 

89 else: 

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

91 

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

93 if env.get("quoted_block", False): 

94 yield from super().non_code(env, next) 

95 elif self.include_markdown: 

96 yield from self.non_code_block_string(env, next) 

97 else: 

98 yield from self.non_code_comment(env, next) 

99 

100 def non_code_block_string(self, env, next=None): 

101 body = super().non_code(env, next) 

102 trail = self.quote_char * 3 

103 lead = SP * self._compute_indent(env) + trail 

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

105 yield from self.wrap_lines( 

106 map(escape, body), 

107 lead=lead, 

108 trail=trail, 

109 continuation=self.extend_continuations and env.get("continued") and "\\" or "", 

110 ) 

111 

112 def non_code_comment(self, env, next=None): 

113 yield self.comment(super().non_code(env, next), env) 

114 

115 def render_tokens(self, tokens, env=None, stop=None, src=None): 

116 return dedent(super().render_tokens(tokens, env=env, stop=stop, src=src)) 

117 

118 def shebang(self, token, env): 

119 yield "".join(self.get_block(env, token.map[1])) 

120 

121 def _compute_indent(self, env): 

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

123 next = env.get("next_code") 

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

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

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

127 if next_indent > prior_indent: 

128 spaces = next_indent # prefer greater trailing indent 

129 else: 

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

131 return spaces - env.get("min_indent", 0) 

132 

133 

134tangle = md_to_python = Python.code_from_string 

135 

136 

137@dataclass 

138class FencedPython(Python): 

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

140 

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

142 

143 

144tangle = md_to_python = Python.code_from_string