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

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

2

3 from dataclasses import dataclass, field

4 from pathlib import Path

5 from textwrap import dedent, indent

6

7 from .render import DedentCodeBlock, escape

8

9 __all__ = "Python", "md_to_python"

10 SP, 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

19 class 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

134 tangle = md_to_python = Python.code_from_string

135

136

137@dataclass

138 class FencedPython(Python):

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

140

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

142

143

144 tangle = md_to_python = Python.code_from_string