diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8f0d01f..dc81fb6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,7 +33,12 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest pytest-cov + pip install pytest pytest-cov mypy + + - name: Check types with Python ${{ matrix.python }} on ${{ matrix.os }} + run: | + mypy --strict src + mypy src tests - name: Test with Python ${{ matrix.python }} on ${{ matrix.os }} run: pytest diff --git a/src/mistune/__init__.py b/src/mistune/__init__.py index 5b9237c..59049d9 100644 --- a/src/mistune/__init__.py +++ b/src/mistune/__init__.py @@ -8,16 +8,26 @@ Documentation: https://mistune.lepture.com/ """ -from .markdown import Markdown -from .core import BlockState, InlineState, BaseRenderer +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union + +from typing_extensions import Literal + from .block_parser import BlockParser +from .core import BaseRenderer, BlockState, InlineState from .inline_parser import InlineParser +from .markdown import Markdown +from .plugins import Plugin, PluginRef, import_plugin from .renderers.html import HTMLRenderer from .util import escape, escape_url, safe_entity, unikey -from .plugins import import_plugin +RendererRef = Union[Literal["html", "ast"], BaseRenderer] -def create_markdown(escape: bool=True, hard_wrap: bool=False, renderer='html', plugins=None) -> Markdown: +def create_markdown( + escape: bool = True, + hard_wrap: bool = False, + renderer: Optional[RendererRef] = "html", + plugins: Optional[Iterable[PluginRef]] = None, +) -> Markdown: """Create a Markdown instance based on the given condition. :param escape: Boolean. If using html renderer, escape html. @@ -41,9 +51,10 @@ def create_markdown(escape: bool=True, hard_wrap: bool=False, renderer='html', p renderer = HTMLRenderer(escape=escape) inline = InlineParser(hard_wrap=hard_wrap) + real_plugins: Optional[Iterable[Plugin]] = None if plugins is not None: - plugins = [import_plugin(n) for n in plugins] - return Markdown(renderer=renderer, inline=inline, plugins=plugins) + real_plugins = [import_plugin(n) for n in plugins] + return Markdown(renderer=renderer, inline=inline, plugins=real_plugins) html: Markdown = create_markdown( @@ -52,11 +63,18 @@ def create_markdown(escape: bool=True, hard_wrap: bool=False, renderer='html', p ) -__cached_parsers = {} +__cached_parsers: Dict[ + Tuple[bool, Optional[RendererRef], Optional[Iterable[Any]]], Markdown +] = {} -def markdown(text, escape=True, renderer='html', plugins=None) -> str: - if renderer == 'ast': +def markdown( + text: str, + escape: bool = True, + renderer: Optional[RendererRef] = "html", + plugins: Optional[Iterable[Any]] = None, +) -> Union[str, List[Dict[str, Any]]]: + if renderer == "ast": # explicit and more similar to 2.x's API renderer = None key = (escape, renderer, plugins) @@ -77,5 +95,5 @@ def markdown(text, escape=True, renderer='html', plugins=None) -> str: 'html', 'create_markdown', 'markdown', ] -__version__ = '3.0.1' -__homepage__ = 'https://mistune.lepture.com/' +__version__: str = "3.0.1" +__homepage__: str = "https://mistune.lepture.com/" diff --git a/src/mistune/__main__.py b/src/mistune/__main__.py index 053a379..20aa7cd 100644 --- a/src/mistune/__main__.py +++ b/src/mistune/__main__.py @@ -1,23 +1,27 @@ -import sys import argparse -from .renderers.rst import RSTRenderer +import sys +from typing import TYPE_CHECKING, Optional + +from . import __version__ as version +from . import create_markdown from .renderers.markdown import MarkdownRenderer -from . import ( - create_markdown, - __version__ as version -) +from .renderers.rst import RSTRenderer + +if TYPE_CHECKING: + from .core import BaseRenderer + from .markdown import Markdown -def _md(args): +def _md(args: argparse.Namespace) -> "Markdown": if args.plugin: plugins = args.plugin else: # default plugins plugins = ['strikethrough', 'footnotes', 'table', 'speedup'] - if args.renderer == 'rst': - renderer = RSTRenderer() - elif args.renderer == 'markdown': + if args.renderer == "rst": + renderer: "BaseRenderer" = RSTRenderer() + elif args.renderer == "markdown": renderer = MarkdownRenderer() else: renderer = args.renderer @@ -29,7 +33,7 @@ def _md(args): ) -def _output(text, args): +def _output(text: str, args: argparse.Namespace) -> None: if args.output: with open(args.output, 'w') as f: f.write(text) @@ -52,7 +56,7 @@ def _output(text, args): ''' -def cli(): +def cli() -> None: parser = argparse.ArgumentParser( prog='python -m mistune', description=CMD_HELP, @@ -102,17 +106,19 @@ def cli(): if message: md = _md(args) text = md(message) + assert isinstance(text, str) _output(text, args) elif args.file: md = _md(args) text = md.read(args.file)[0] + assert isinstance(text, str) _output(text, args) else: print('You MUST specify a message or file') - return sys.exit(1) + sys.exit(1) -def read_stdin(): +def read_stdin() -> Optional[str]: is_stdin_pipe = not sys.stdin.isatty() if is_stdin_pipe: return sys.stdin.read() diff --git a/src/mistune/block_parser.py b/src/mistune/block_parser.py index 1442c3f..a70615e 100644 --- a/src/mistune/block_parser.py +++ b/src/mistune/block_parser.py @@ -1,5 +1,5 @@ import re -from typing import Optional, List, Tuple, Match +from typing import Optional, List, Tuple, Match, Type, Pattern from .util import ( unikey, escape_url, @@ -33,7 +33,9 @@ _STRICT_BLOCK_QUOTE = re.compile(r'( {0,3}>[^\n]*(?:\n|$))+') -class BlockParser(Parser): +class BlockParser(Parser[BlockState]): + state_cls = BlockState + BLANK_LINE = re.compile(r'(^[ \t\v\f]*\n)+', re.M) RAW_HTML = ( @@ -109,18 +111,18 @@ def __init__( name: getattr(self, 'parse_' + name) for name in self.SPECIFICATION } - def parse_blank_line(self, m: Match, state: BlockState) -> int: + def parse_blank_line(self, m: Match[str], state: BlockState) -> int: """Parse token for blank lines.""" state.append_token({'type': 'blank_line'}) return m.end() - def parse_thematic_break(self, m: Match, state: BlockState) -> int: + def parse_thematic_break(self, m: Match[str], state: BlockState) -> int: """Parse token for thematic break, e.g. ``
' + text + '
\n' -def render_admonition_content(self, text): +def render_admonition_content(self: Any, text: str) -> str: return text diff --git a/src/mistune/directives/image.py b/src/mistune/directives/image.py index 5d9d40a..9f676f8 100644 --- a/src/mistune/directives/image.py +++ b/src/mistune/directives/image.py @@ -1,6 +1,15 @@ import re -from ._base import DirectivePlugin -from ..util import escape as escape_text, escape_url +from typing import TYPE_CHECKING, Any, Dict, List, Match, Optional + +from ..util import escape as escape_text +from ..util import escape_url +from ._base import BaseDirective, DirectivePlugin + +if TYPE_CHECKING: + from ..block_parser import BlockParser + from ..core import BlockState + from ..markdown import Markdown + from ..renderers.html import HTMLRenderer __all__ = ['Image', 'Figure'] @@ -8,7 +17,7 @@ _allowed_aligns = ["top", "middle", "bottom", "left", "center", "right"] -def _parse_attrs(options): +def _parse_attrs(options: Dict[str, Any]) -> Dict[str, Any]: attrs = {} if 'alt' in options: attrs['alt'] = options['alt'] @@ -32,19 +41,29 @@ def _parse_attrs(options): class Image(DirectivePlugin): NAME = 'image' - def parse(self, block, m, state): + def parse( + self, block: "BlockParser", m: Match[str], state: "BlockState" + ) -> Dict[str, Any]: options = dict(self.parse_options(m)) attrs = _parse_attrs(options) attrs['src'] = self.parse_title(m) return {'type': 'block_image', 'attrs': attrs} - def __call__(self, directive, md): + def __call__(self, directive: "BaseDirective", md: "Markdown") -> None: directive.register(self.NAME, self.parse) + assert md.renderer is not None if md.renderer.NAME == 'html': md.renderer.register('block_image', render_block_image) -def render_block_image(self, src: str, alt=None, width=None, height=None, **attrs): +def render_block_image( + self: 'HTMLRenderer', + src: str, + alt: Optional[str] = None, + width: Optional[str] = None, + height: Optional[str] = None, + **attrs: Any, +) -> str: img = ' Optional[List[Dict[str, Any]]]: content = self.parse_content(m) if not content: - return + return None - tokens = self.parse_tokens(block, content, state) + tokens = list(self.parse_tokens(block, content, state)) caption = tokens[0] if caption['type'] == 'paragraph': caption['type'] = 'figcaption' @@ -97,8 +118,11 @@ def parse_directive_content(self, block, m, state): 'children': tokens[1:] }) return children + return None - def parse(self, block, m, state): + def parse( + self, block: "BlockParser", m: Match[str], state: "BlockState" + ) -> Dict[str, Any]: options = dict(self.parse_options(m)) image_attrs = _parse_attrs(options) image_attrs['src'] = self.parse_title(m) @@ -121,9 +145,10 @@ def parse(self, block, m, state): 'children': children, } - def __call__(self, directive, md): + def __call__(self, directive: "BaseDirective", md: "Markdown") -> None: directive.register(self.NAME, self.parse) + assert md.renderer is not None if md.renderer.NAME == 'html': md.renderer.register('figure', render_figure) md.renderer.register('block_image', render_block_image) @@ -131,8 +156,14 @@ def __call__(self, directive, md): md.renderer.register('legend', render_legend) -def render_figure(self, text, align=None, figwidth=None, figclass=None): - _cls = 'figure' +def render_figure( + self: Any, + text: str, + align: Optional[str] = None, + figwidth: Optional[str] = None, + figclass: Optional[str] = None, +) -> str: + _cls = "figure" if align: _cls += ' align-' + align if figclass: @@ -144,9 +175,9 @@ def render_figure(self, text, align=None, figwidth=None, figclass=None): return html + '>\n' + text + '\n' -def render_figcaption(self, text): +def render_figcaption(self: Any, text: str) -> str: return '\n' + text + '\n' +def render_html_include(renderer: "BaseRenderer", text: str, **attrs: Any) -> str: + return '
\n' + text + "\n" diff --git a/src/mistune/directives/toc.py b/src/mistune/directives/toc.py index 4084f43..7945dbc 100644 --- a/src/mistune/directives/toc.py +++ b/src/mistune/directives/toc.py @@ -13,19 +13,28 @@ heading levels writers want to include in the table of contents. """ -from ._base import DirectivePlugin +from typing import TYPE_CHECKING, Any, Dict, Match + from ..toc import normalize_toc_item, render_toc_ul +from ._base import BaseDirective, DirectivePlugin + +if TYPE_CHECKING: + from ..block_parser import BlockParser + from ..core import BaseRenderer, BlockState + from ..markdown import Markdown class TableOfContents(DirectivePlugin): - def __init__(self, min_level=1, max_level=3): + def __init__(self, min_level: int = 1, max_level: int = 3) -> None: self.min_level = min_level self.max_level = max_level - def generate_heading_id(self, token, index): + def generate_heading_id(self, token: Dict[str, Any], index: int) -> str: return 'toc_' + str(index + 1) - def parse(self, block, m, state): + def parse( + self, block: "BlockParser", m: Match[str], state: "BlockState" + ) -> Dict[str, Any]: title = self.parse_title(m) options = self.parse_options(m) if options: @@ -51,7 +60,7 @@ def parse(self, block, m, state): } return {'type': 'toc', 'text': title or '', 'attrs': attrs} - def toc_hook(self, md, state): + def toc_hook(self, md: "Markdown", state: "BlockState") -> None: sections = [] headings = [] @@ -74,15 +83,17 @@ def toc_hook(self, md, state): toc = [item for item in toc_items if _min <= item[0] <= _max] sec['attrs']['toc'] = toc - def __call__(self, directive, md): - if md.renderer and md.renderer.NAME == 'html': + def __call__(self, directive: BaseDirective, md: "Markdown") -> None: + if md.renderer and md.renderer.NAME == "html": # only works with HTML renderer directive.register('toc', self.parse) md.before_render_hooks.append(self.toc_hook) md.renderer.register('toc', render_html_toc) -def render_html_toc(renderer, title, collapse=False, **attrs): +def render_html_toc( + renderer: "BaseRenderer", title: str, collapse: bool = False, **attrs: Any +) -> str: if not title: title = 'Table of Contents' toc = attrs['toc'] @@ -95,7 +106,7 @@ def render_html_toc(renderer, title, collapse=False, **attrs): return html + content + '\n' -def _normalize_level(options, name, default): +def _normalize_level(options: Dict[str, Any], name: str, default: Any) -> Any: level = options.get(name) if not level: return default diff --git a/src/mistune/helpers.py b/src/mistune/helpers.py index 04c1df1..be73c70 100644 --- a/src/mistune/helpers.py +++ b/src/mistune/helpers.py @@ -1,5 +1,7 @@ import re import string +from typing import Any, Dict, Tuple, Union + from .util import escape_url PREVENT_BACKSLASH = r'(? str: return _ESCAPE_CHAR_RE.sub(r'\1', text) -def parse_link_text(src, pos): +def parse_link_text(src: str, pos: int) -> Union[Tuple[str, int], Tuple[None, None]]: level = 1 found = False start_pos = pos @@ -77,7 +79,9 @@ def parse_link_text(src, pos): return None, None -def parse_link_label(src, start_pos): +def parse_link_label( + src: str, start_pos: int +) -> Union[Tuple[str, int], Tuple[None, None]]: m = _INLINE_LINK_LABEL_RE.match(src, start_pos) if m: label = m.group(0)[:-1] @@ -85,7 +89,9 @@ def parse_link_label(src, start_pos): return None, None -def parse_link_href(src, start_pos, block=False): +def parse_link_href( + src: str, start_pos: int, block: bool = False +) -> Union[Tuple[str, int], Tuple[None, None]]: m = LINK_BRACKET_START.match(src, start_pos) if m: start_pos = m.end() - 1 @@ -110,7 +116,9 @@ def parse_link_href(src, start_pos, block=False): return href, end_pos - 1 -def parse_link_title(src, start_pos, max_pos): +def parse_link_title( + src: str, start_pos: int, max_pos: int +) -> Union[Tuple[str, int], Tuple[None, None]]: m = LINK_TITLE_RE.match(src, start_pos, max_pos) if m: title = m.group(1)[1:-1] @@ -119,11 +127,13 @@ def parse_link_title(src, start_pos, max_pos): return None, None -def parse_link(src, pos): +def parse_link( + src: str, pos: int +) -> Union[Tuple[Dict[str, Any], int], Tuple[None, None]]: href, href_pos = parse_link_href(src, pos) if href is None: return None, None - + assert href_pos is not None title, title_pos = parse_link_title(src, href_pos, len(src)) next_pos = title_pos or href_pos m = PAREN_END_RE.match(src, next_pos) diff --git a/src/mistune/inline_parser.py b/src/mistune/inline_parser.py index 1fd961d..e14fb6d 100644 --- a/src/mistune/inline_parser.py +++ b/src/mistune/inline_parser.py @@ -1,21 +1,34 @@ import re -from typing import Optional, List, Dict, Any, Match -from .core import Parser, InlineState -from .util import ( - escape, - escape_url, - unikey, +from typing import ( + Any, + ClassVar, + Dict, + Generic, + Iterator, + List, + Match, + MutableMapping, + Optional, + Pattern, + Tuple, + TypeVar, + Union, ) + +from typing_extensions import Literal + +from .core import InlineState, Parser from .helpers import ( + HTML_ATTRIBUTES, + HTML_TAGNAME, PREVENT_BACKSLASH, PUNCTUATION, - HTML_TAGNAME, - HTML_ATTRIBUTES, - unescape_char, parse_link, parse_link_label, parse_link_text, + unescape_char, ) +from .util import escape, escape_url, unikey PAREN_END_RE = re.compile(r'\s*\)') @@ -46,7 +59,7 @@ } -class InlineParser(Parser): +class InlineParser(Parser[InlineState]): sc_flag = 0 state_cls = InlineState @@ -93,7 +106,7 @@ class InlineParser(Parser): 'linebreak', ) - def __init__(self, hard_wrap: bool=False): + def __init__(self, hard_wrap: bool = False) -> None: super(InlineParser, self).__init__() self.hard_wrap = hard_wrap @@ -107,7 +120,7 @@ def __init__(self, hard_wrap: bool=False): name: getattr(self, 'parse_' + name) for name in self.rules } - def parse_escape(self, m: Match, state: InlineState) -> int: + def parse_escape(self, m: Match[str], state: InlineState) -> int: text = m.group(0) text = unescape_char(text) state.append_token({ @@ -116,7 +129,7 @@ def parse_escape(self, m: Match, state: InlineState) -> int: }) return m.end() - def parse_link(self, m: Match, state: InlineState) -> Optional[int]: + def parse_link(self, m: Match[str], state: InlineState) -> Optional[int]: pos = m.end() marker = m.group(0) @@ -133,13 +146,17 @@ def parse_link(self, m: Match, state: InlineState) -> Optional[int]: if label is None: text, end_pos = parse_link_text(state.src, pos) if text is None: - return + return None + + assert end_pos is not None if text is None: text = label + assert text is not None + if end_pos >= len(state.src) and label is None: - return + return None rules = ['codespan', 'prec_auto_link', 'prec_inline_html'] prec_pos = self.precedence_scan(m, state, end_pos, rules) @@ -165,11 +182,11 @@ def parse_link(self, m: Match, state: InlineState) -> Optional[int]: label = label2 if label is None: - return + return None ref_links = state.env.get('ref_links') if not ref_links: - return + return None key = unikey(label) env = ref_links.get(key) @@ -180,8 +197,15 @@ def parse_link(self, m: Match, state: InlineState) -> Optional[int]: token['label'] = label state.append_token(token) return end_pos - - def __parse_link_token(self, is_image, text, attrs, state): + return None + + def __parse_link_token( + self, + is_image: bool, + text: str, + attrs: Optional[Dict[str, Any]], + state: InlineState, + ) -> Dict[str, Any]: new_state = state.copy() new_state.src = text if is_image: @@ -200,7 +224,7 @@ def __parse_link_token(self, is_image, text, attrs, state): } return token - def parse_auto_link(self, m: Match, state: InlineState) -> int: + def parse_auto_link(self, m: Match[str], state: InlineState) -> int: text = m.group(0) pos = m.end() if state.in_link: @@ -211,7 +235,7 @@ def parse_auto_link(self, m: Match, state: InlineState) -> int: self._add_auto_link(text, text, state) return pos - def parse_auto_email(self, m: Match, state: InlineState) -> int: + def parse_auto_email(self, m: Match[str], state: InlineState) -> int: text = m.group(0) pos = m.end() if state.in_link: @@ -223,14 +247,14 @@ def parse_auto_email(self, m: Match, state: InlineState) -> int: self._add_auto_link(url, text, state) return pos - def _add_auto_link(self, url, text, state): + def _add_auto_link(self, url: str, text: str, state: InlineState) -> None: state.append_token({ 'type': 'link', 'children': [{'type': 'text', 'raw': text}], 'attrs': {'url': escape_url(url)}, }) - def parse_emphasis(self, m: Match, state: InlineState) -> int: + def parse_emphasis(self, m: Match[str], state: InlineState) -> int: pos = m.end() marker = m.group(0) @@ -279,17 +303,17 @@ def parse_emphasis(self, m: Match, state: InlineState) -> int: }) return end_pos - def parse_codespan(self, m: Match, state: InlineState) -> int: + def parse_codespan(self, m: Match[str], state: InlineState) -> int: marker = m.group(0) # require same marker with same length at end pattern = re.compile(r'(.*?[^`])' + marker + r'(?!`)', re.S) pos = m.end() - m = pattern.match(state.src, pos) - if m: - end_pos = m.end() - code = m.group(1) + m2 = pattern.match(state.src, pos) + if m2: + end_pos = m2.end() + code = m2.group(1) # Line endings are treated like spaces code = code.replace('\n', ' ') if len(code.strip()): @@ -301,15 +325,15 @@ def parse_codespan(self, m: Match, state: InlineState) -> int: state.append_token({'type': 'text', 'raw': marker}) return pos - def parse_linebreak(self, m: Match, state: InlineState) -> int: + def parse_linebreak(self, m: Match[str], state: InlineState) -> int: state.append_token({'type': 'linebreak'}) return m.end() - def parse_softbreak(self, m: Match, state: InlineState) -> int: + def parse_softbreak(self, m: Match[str], state: InlineState) -> int: state.append_token({'type': 'softbreak'}) return m.end() - def parse_inline_html(self, m: Match, state: InlineState) -> int: + def parse_inline_html(self, m: Match[str], state: InlineState) -> int: end_pos = m.end() html = m.group(0) state.append_token({'type': 'inline_html', 'raw': html}) @@ -319,7 +343,7 @@ def parse_inline_html(self, m: Match, state: InlineState) -> int: state.in_link = False return end_pos - def process_text(self, text: str, state: InlineState): + def process_text(self, text: str, state: InlineState) -> None: state.append_token({'type': 'text', 'raw': text}) def parse(self, state: InlineState) -> List[Dict[str, Any]]: @@ -351,7 +375,13 @@ def parse(self, state: InlineState) -> List[Dict[str, Any]]: self.process_text(state.src[pos:], state) return state.tokens - def precedence_scan(self, m: Match, state: InlineState, end_pos: int, rules=None): + def precedence_scan( + self, + m: Match[str], + state: InlineState, + end_pos: int, + rules: Optional[List[str]] = None, + ) -> Optional[int]: if rules is None: rules = ['codespan', 'link', 'prec_auto_link', 'prec_inline_html'] @@ -359,20 +389,23 @@ def precedence_scan(self, m: Match, state: InlineState, end_pos: int, rules=None sc = self.compile_sc(rules) m1 = sc.search(state.src, mark_pos, end_pos) if not m1: - return + return None - rule_name = m1.lastgroup.replace('prec_', '') + lastgroup = m1.lastgroup + if not lastgroup: + return None + rule_name = lastgroup.replace("prec_", "") sc = self.compile_sc([rule_name]) m2 = sc.match(state.src, m1.start()) if not m2: - return + return None func = self._methods[rule_name] new_state = state.copy() new_state.src = state.src m2_pos = func(m2, new_state) if not m2_pos or m2_pos < end_pos: - return + return None raw_text = state.src[m.start():m2.start()] state.append_token({'type': 'text', 'raw': raw_text}) @@ -380,11 +413,11 @@ def precedence_scan(self, m: Match, state: InlineState, end_pos: int, rules=None state.append_token(token) return m2_pos - def render(self, state: InlineState): + def render(self, state: InlineState) -> List[Dict[str, Any]]: self.parse(state) return state.tokens - def __call__(self, s, env): + def __call__(self, s: str, env: MutableMapping[str, Any]) -> List[Dict[str, Any]]: state = self.state_cls(env) state.src = s return self.render(state) diff --git a/src/mistune/list_parser.py b/src/mistune/list_parser.py index a78e1a5..4bb2875 100644 --- a/src/mistune/list_parser.py +++ b/src/mistune/list_parser.py @@ -1,11 +1,15 @@ +"""because list is complex, split list parser in a new file""" + import re -from .core import BlockState -from .util import ( - strip_end, - expand_tab, - expand_leading_tab, -) -# because list is complex, split list parser in a new file +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, Match + +from typing_extensions import Literal + +from .util import expand_leading_tab, expand_tab, strip_end + +if TYPE_CHECKING: + from .block_parser import BlockParser + from .core import BlockState LIST_PATTERN = ( r'^(?P
' + text + '
\n' - def heading(self, text: str, level: int, **attrs) -> str: + def heading(self, text: str, level: int, **attrs: Any) -> str: tag = 'h' + str(level) html = '<' + tag _id = attrs.get('id') @@ -118,7 +127,7 @@ def thematic_break(self) -> str: def block_text(self, text: str) -> str: return text - def block_code(self, code: str, info=None) -> str: + def block_code(self, code: str, info: Optional[str] = None) -> str: html = ' str:
def block_error(self, text: str) -> str:
return '' + text + '
\n'
- def list(self, text: str, ordered: bool, **attrs) -> str:
+ def list(self, text: str, ordered: bool, **attrs: Any) -> str:
if ordered:
html = ' str:
out = self.render_tokens(tokens, state)
# special handle for line breaks
out += '\n\n'.join(self.render_referrences(state)) + '\n'
return strip_end(out)
- def render_referrences(self, state: BlockState):
+ def render_referrences(self, state: BlockState) -> Iterable[str]:
ref_links = state.env['ref_links']
for key in ref_links:
attrs = ref_links[key]
@@ -28,12 +29,12 @@ def render_referrences(self, state: BlockState):
text += ' "' + title + '"'
yield text
- def render_children(self, token, state: BlockState):
+ def render_children(self, token: Dict[str, Any], state: BlockState) -> str:
children = token['children']
return self.render_tokens(children, state)
def text(self, token: Dict[str, Any], state: BlockState) -> str:
- return token['raw']
+ return cast(str, token["raw"])
def emphasis(self, token: Dict[str, Any], state: BlockState) -> str:
return '*' + self.render_children(token, state) + '*'
@@ -42,7 +43,7 @@ def strong(self, token: Dict[str, Any], state: BlockState) -> str:
return '**' + self.render_children(token, state) + '**'
def link(self, token: Dict[str, Any], state: BlockState) -> str:
- label = token.get('label')
+ label = cast(str, token.get("label"))
text = self.render_children(token, state)
out = '[' + text + ']'
if label:
@@ -69,7 +70,7 @@ def image(self, token: Dict[str, Any], state: BlockState) -> str:
return '!' + self.link(token, state)
def codespan(self, token: Dict[str, Any], state: BlockState) -> str:
- return '`' + token['raw'] + '`'
+ return "`" + cast(str, token["raw"]) + "`"
def linebreak(self, token: Dict[str, Any], state: BlockState) -> str:
return ' \n'
@@ -81,15 +82,15 @@ def blank_line(self, token: Dict[str, Any], state: BlockState) -> str:
return ''
def inline_html(self, token: Dict[str, Any], state: BlockState) -> str:
- return token['raw']
+ return cast(str, token["raw"])
def paragraph(self, token: Dict[str, Any], state: BlockState) -> str:
text = self.render_children(token, state)
return text + '\n\n'
def heading(self, token: Dict[str, Any], state: BlockState) -> str:
- level = token['attrs']['level']
- marker = '#' * level
+ level = cast(int, token["attrs"]["level"])
+ marker = "#" * level
text = self.render_children(token, state)
return marker + ' ' + text + '\n\n'
@@ -100,23 +101,24 @@ def block_text(self, token: Dict[str, Any], state: BlockState) -> str:
return self.render_children(token, state) + '\n'
def block_code(self, token: Dict[str, Any], state: BlockState) -> str:
- attrs = token.get('attrs', {})
- info = attrs.get('info', '')
- code = token['raw']
- if code and code[-1] != '\n':
- code += '\n'
+ attrs = token.get("attrs", {})
+ info = cast(str, attrs.get("info", ""))
+ code = cast(str, token["raw"])
+ if code and code[-1] != "\n":
+ code += "\n"
marker = token.get('marker')
if not marker:
marker = _get_fenced_marker(code)
- return marker + info + '\n' + code + marker + '\n\n'
+ marker2 = cast(str, marker)
+ return marker2 + info + "\n" + code + marker2 + "\n\n"
def block_quote(self, token: Dict[str, Any], state: BlockState) -> str:
text = indent(self.render_children(token, state), '> ')
return text + '\n\n'
def block_html(self, token: Dict[str, Any], state: BlockState) -> str:
- return token['raw'] + '\n\n'
+ return cast(str, token["raw"]) + "\n\n"
def block_error(self, token: Dict[str, Any], state: BlockState) -> str:
return ''
@@ -125,7 +127,7 @@ def list(self, token: Dict[str, Any], state: BlockState) -> str:
return render_list(self, token, state)
-def _get_fenced_marker(code):
+def _get_fenced_marker(code: str) -> str:
found = fenced_re.findall(code)
if not found:
return '```'
diff --git a/src/mistune/renderers/rst.py b/src/mistune/renderers/rst.py
index fa12c21..7022d15 100644
--- a/src/mistune/renderers/rst.py
+++ b/src/mistune/renderers/rst.py
@@ -1,8 +1,9 @@
-from typing import Dict, Any
from textwrap import indent
-from ._list import render_list
+from typing import Any, Dict, Iterable, List, cast
+
from ..core import BaseRenderer, BlockState
from ..util import strip_end
+from ._list import render_list
class RSTRenderer(BaseRenderer):
@@ -20,7 +21,9 @@ class RSTRenderer(BaseRenderer):
}
INLINE_IMAGE_PREFIX = 'img-'
- def iter_tokens(self, tokens, state):
+ def iter_tokens(
+ self, tokens: Iterable[Dict[str, Any]], state: BlockState
+ ) -> Iterable[str]:
prev = None
for tok in tokens:
# ignore blank line
@@ -30,14 +33,14 @@ def iter_tokens(self, tokens, state):
prev = tok
yield self.render_token(tok, state)
- def __call__(self, tokens, state: BlockState):
+ def __call__(self, tokens: Iterable[Dict[str, Any]], state: BlockState) -> str:
state.env['inline_images'] = []
out = self.render_tokens(tokens, state)
# special handle for line breaks
out += '\n\n'.join(self.render_referrences(state)) + '\n'
return strip_end(out)
- def render_referrences(self, state: BlockState):
+ def render_referrences(self, state: BlockState) -> Iterable[str]:
images = state.env['inline_images']
for index, token in enumerate(images):
attrs = token['attrs']
@@ -45,13 +48,13 @@ def render_referrences(self, state: BlockState):
ident = self.INLINE_IMAGE_PREFIX + str(index)
yield '.. |' + ident + '| image:: ' + attrs['url'] + '\n :alt: ' + alt
- def render_children(self, token, state: BlockState):
+ def render_children(self, token: Dict[str, Any], state: BlockState) -> str:
children = token['children']
return self.render_tokens(children, state)
def text(self, token: Dict[str, Any], state: BlockState) -> str:
- text = token['raw']
- return text.replace('|', r'\|')
+ text = cast(str, token["raw"])
+ return text.replace("|", r"\|")
def emphasis(self, token: Dict[str, Any], state: BlockState) -> str:
return '*' + self.render_children(token, state) + '*'
@@ -62,16 +65,16 @@ def strong(self, token: Dict[str, Any], state: BlockState) -> str:
def link(self, token: Dict[str, Any], state: BlockState) -> str:
attrs = token['attrs']
text = self.render_children(token, state)
- return '`' + text + ' <' + attrs['url'] + '>`__'
+ return "`" + text + " <" + cast(str, attrs["url"]) + ">`__"
def image(self, token: Dict[str, Any], state: BlockState) -> str:
- refs: list = state.env['inline_images']
+ refs: List[Dict[str, Any]] = state.env["inline_images"]
index = len(refs)
refs.append(token)
return '|' + self.INLINE_IMAGE_PREFIX + str(index) + '|'
def codespan(self, token: Dict[str, Any], state: BlockState) -> str:
- return '``' + token['raw'] + '``'
+ return "``" + cast(str, token["raw"]) + "``"
def linebreak(self, token: Dict[str, Any], state: BlockState) -> str:
return ''
@@ -87,10 +90,10 @@ def paragraph(self, token: Dict[str, Any], state: BlockState) -> str:
children = token['children']
if len(children) == 1 and children[0]['type'] == 'image':
image = children[0]
- attrs = image['attrs']
- title = attrs.get('title')
+ attrs = image["attrs"]
+ title = cast(str, attrs.get("title"))
alt = self.render_children(image, state)
- text = '.. figure:: ' + attrs['url']
+ text = ".. figure:: " + cast(str, attrs["url"])
if title:
text += '\n :alt: ' + title
text += '\n\n' + indent(alt, ' ')
@@ -114,9 +117,9 @@ def block_text(self, token: Dict[str, Any], state: BlockState) -> str:
return self.render_children(token, state) + '\n'
def block_code(self, token: Dict[str, Any], state: BlockState) -> str:
- attrs = token.get('attrs', {})
- info = attrs.get('info')
- code = indent(token['raw'], ' ')
+ attrs = token.get("attrs", {})
+ info = cast(str, attrs.get("info"))
+ code = indent(cast(str, token["raw"]), " ")
if info:
lang = info.split()[0]
return '.. code:: ' + lang + '\n\n' + code + '\n'
diff --git a/src/mistune/toc.py b/src/mistune/toc.py
index 0c5787f..be4b8b3 100644
--- a/src/mistune/toc.py
+++ b/src/mistune/toc.py
@@ -1,7 +1,18 @@
+from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple
+
+from .core import BlockState
from .util import striptags
+if TYPE_CHECKING:
+ from .markdown import Markdown
+
-def add_toc_hook(md, min_level=1, max_level=3, heading_id=None):
+def add_toc_hook(
+ md: "Markdown",
+ min_level: int = 1,
+ max_level: int = 3,
+ heading_id: Optional[Callable[[Dict[str, Any], int], str]] = None,
+) -> None:
"""Add a hook to save toc items into ``state.env``. This is
usually helpful for doc generator::
@@ -21,10 +32,11 @@ def add_toc_hook(md, min_level=1, max_level=3, heading_id=None):
:param heading_id: a function to generate heading_id
"""
if heading_id is None:
- def heading_id(token, index):
+
+ def heading_id(token: Dict[str, Any], index: int) -> str:
return 'toc_' + str(index + 1)
- def toc_hook(md, state):
+ def toc_hook(md: "Markdown", state: "BlockState") -> None:
headings = []
for tok in state.tokens:
@@ -44,16 +56,17 @@ def toc_hook(md, state):
md.before_render_hooks.append(toc_hook)
-def normalize_toc_item(md, token):
- text = token['text']
+def normalize_toc_item(md: "Markdown", token: Dict[str, Any]) -> Tuple[int, str, str]:
+ text = token["text"]
tokens = md.inline(text, {})
- html = md.renderer(tokens, {})
+ assert md.renderer is not None
+ html = md.renderer(tokens, BlockState())
text = striptags(html)
attrs = token['attrs']
return attrs['level'], attrs['id'], text
-def render_toc_ul(toc):
+def render_toc_ul(toc: Iterable[Tuple[int, str, str]]) -> str:
"""Render a table of content HTML. The param "toc" should
be formatted into this structure::
@@ -74,7 +87,7 @@ def render_toc_ul(toc):
return ''
s = '\n'
- levels = []
+ levels: List[int] = []
for level, k, text in toc:
item = '{}'.format(k, text)
if not levels:
diff --git a/src/mistune/util.py b/src/mistune/util.py
index 5e1b9ed..80275ff 100644
--- a/src/mistune/util.py
+++ b/src/mistune/util.py
@@ -1,24 +1,24 @@
import re
+from html import _replace_charref # type: ignore[attr-defined]
+from typing import Match
from urllib.parse import quote
-from html import _replace_charref
-
_expand_tab_re = re.compile(r'^( {0,3})\t', flags=re.M)
-def expand_leading_tab(text: str, width=4):
- def repl(m):
+def expand_leading_tab(text: str, width: int = 4) -> str:
+ def repl(m: Match[str]) -> str:
s = m.group(1)
return s + ' ' * (width - len(s))
return _expand_tab_re.sub(repl, text)
-def expand_tab(text: str, space: str=' '):
- repl = r'\1' + space
+def expand_tab(text: str, space: str = " ") -> str:
+ repl = r"\1" + space
return _expand_tab_re.sub(repl, text)
-def escape(s: str, quote: bool=True):
+def escape(s: str, quote: bool = True) -> str:
"""Escape characters of ``&<>``. If quote=True, ``"`` will be
converted to ``"e;``."""
s = s.replace("&", "&")
@@ -29,7 +29,7 @@ def escape(s: str, quote: bool=True):
return s
-def escape_url(link: str):
+def escape_url(link: str) -> str:
"""Escape URL for safety."""
safe = (
':/?#@' # gen-delims - '[]' (rfc3986)
@@ -39,12 +39,12 @@ def escape_url(link: str):
return escape(quote(unescape(link), safe=safe))
-def safe_entity(s: str):
+def safe_entity(s: str) -> str:
"""Escape characters for safety."""
return escape(unescape(s))
-def unikey(s: str):
+def unikey(s: str) -> str:
"""Generate a unique key for links and footnotes."""
key = ' '.join(s.split()).strip()
return key.lower().upper()
@@ -57,7 +57,7 @@ def unikey(s: str):
)
-def unescape(s: str):
+def unescape(s: str) -> str:
"""
Copy from `html.unescape`, but `_charref` is different. CommonMark
does not accept entity references without a trailing semicolon
@@ -70,12 +70,12 @@ def unescape(s: str):
_striptags_re = re.compile(r'(|<[^>]*>)')
-def striptags(s: str):
+def striptags(s: str) -> str:
return _striptags_re.sub('', s)
_strip_end_re = re.compile(r'\n\s+$')
-def strip_end(src: str):
+def strip_end(src: str) -> str:
return _strip_end_re.sub('\n', src)
diff --git a/tests/__init__.py b/tests/__init__.py
index 3e4e980..b715104 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,13 +1,15 @@
import re
-from tests import fixtures
+from abc import abstractmethod
from unittest import TestCase
+from tests import fixtures
+
class BaseTestCase(TestCase):
@classmethod
- def load_fixtures(cls, case_file):
- def attach_case(n, text, html):
- def method(self):
+ def load_fixtures(cls, case_file: str) -> None:
+ def attach_case(n: str, text: str, html: str) -> None:
+ def method(self: 'BaseTestCase') -> None:
self.assert_case(n, text, html)
name = 'test_{}'.format(n)
@@ -21,15 +23,18 @@ def method(self):
attach_case(n, text, html)
@classmethod
- def ignore_case(cls, name):
+ def ignore_case(cls, name: str) -> bool:
return False
+
+ @abstractmethod
+ def parse(self, text: str) -> str: ...
- def assert_case(self, name, text, html):
+ def assert_case(self, name: str, text: str, html: str) -> None:
result = self.parse(text)
self.assertEqual(result, html)
-def normalize_html(html):
+def normalize_html(html: str) -> str:
html = re.sub(r'>\n+', '>', html)
html = re.sub(r'\n+<', '<', html)
return html.strip()
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
index c1ad3c1..9a1abe2 100644
--- a/tests/fixtures/__init__.py
+++ b/tests/fixtures/__init__.py
@@ -1,6 +1,7 @@
import os
import re
import json
+from typing import Any, Iterable, Tuple
ROOT = os.path.join(os.path.dirname(__file__))
@@ -12,17 +13,17 @@
)
-def load_ast(filename):
+def load_ast(filename: str) -> Any:
with open(os.path.join(ROOT, 'ast', filename)) as f:
return json.load(f)
-def load_json(filename):
+def load_json(filename: str) -> Any:
with open(os.path.join(ROOT, filename)) as f:
return json.load(f)
-def load_examples(filename):
+def load_examples(filename: str) -> Iterable[Tuple[str, str, str]]:
if filename.endswith('.json'):
data = load_json(filename)
for item in data:
@@ -37,7 +38,7 @@ def load_examples(filename):
-def parse_examples(text):
+def parse_examples(text: str) -> Iterable[Tuple[str, str, str]]:
data = EXAMPLE_PATTERN.findall(text)
section = None
diff --git a/tests/test_directives.py b/tests/test_directives.py
index cd3bd01..388963a 100644
--- a/tests/test_directives.py
+++ b/tests/test_directives.py
@@ -77,7 +77,7 @@ def test_colon_fenced_toc(self):
class TestDirectiveInclude(BaseTestCase):
- md = create_markdown(escape=False, plugins=[RSTDirective([Include()])])
+ md = create_markdown(escape=False, plugins=[RSTDirective([Include()])]) # type: ignore[list-item]
def test_html_include(self):
html = self.md.read(os.path.join(ROOT, 'include/text.md'))[0]