diff --git a/.github/workflows/test-formats.yml b/.github/workflows/test-formats.yml index 59ffda70..e000c6c0 100644 --- a/.github/workflows/test-formats.yml +++ b/.github/workflows/test-formats.yml @@ -19,10 +19,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip @@ -43,10 +43,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v5 with: - python-version: 3.9 + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip @@ -54,23 +54,22 @@ jobs: - name: Build docs run: | sphinx-build -nW --keep-going -b ${{ matrix.format }} docs/ docs/_build/${{ matrix.format }} - # TODO https://github.com/sphinx-doc/sphinx/issues/12594 - # - name: Make PDF - # uses: xu-cheng/latex-action@v2 - # with: - # working_directory: docs/_build/latex - # root_file: "mystparser.tex" - # # https://github.com/marketplace/actions/github-action-for-latex#it-fails-due-to-xindy-cannot-be-found - # pre_compile: | - # ln -sf /opt/texlive/texdir/texmf-dist/scripts/xindy/xindy.pl /opt/texlive/texdir/bin/x86_64-linuxmusl/xindy - # ln -sf /opt/texlive/texdir/texmf-dist/scripts/xindy/texindy.pl /opt/texlive/texdir/bin/x86_64-linuxmusl/texindy - # wget https://sourceforge.net/projects/xindy/files/xindy-source-components/2.4/xindy-kernel-3.0.tar.gz - # tar xf xindy-kernel-3.0.tar.gz - # cd xindy-kernel-3.0/src - # apk add make - # apk add clisp --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community - # make - # cp -f xindy.mem /opt/texlive/texdir/bin/x86_64-linuxmusl/ - # cd ../../ - # env: - # XINDYOPTS: -L english -C utf8 -M sphinx.xdy + - name: Make PDF + uses: xu-cheng/latex-action@v2 + with: + working_directory: docs/_build/latex + root_file: "mystparser.tex" + # https://github.com/marketplace/actions/github-action-for-latex#it-fails-due-to-xindy-cannot-be-found + pre_compile: | + ln -sf /opt/texlive/texdir/texmf-dist/scripts/xindy/xindy.pl /opt/texlive/texdir/bin/x86_64-linuxmusl/xindy + ln -sf /opt/texlive/texdir/texmf-dist/scripts/xindy/texindy.pl /opt/texlive/texdir/bin/x86_64-linuxmusl/texindy + wget https://sourceforge.net/projects/xindy/files/xindy-source-components/2.4/xindy-kernel-3.0.tar.gz + tar xf xindy-kernel-3.0.tar.gz + cd xindy-kernel-3.0/src + apk add make + apk add clisp --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community + make + cp -f xindy.mem /opt/texlive/texdir/bin/x86_64-linuxmusl/ + cd ../../ + env: + XINDYOPTS: -L english -C utf8 -M sphinx.xdy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5d82faec..5410bfd2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,10 +14,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.10" - uses: pre-commit/action@v3.0.1 tests: @@ -25,19 +25,21 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] - sphinx: [">=7,<8"] + python-version: ["3.10", "3.11", "3.12"] + sphinx: [">=8,<9"] os: [ubuntu-latest] include: - os: ubuntu-latest - python-version: "3.8" - sphinx: ">=6,<7" + python-version: "3.10" + sphinx: ">=7,<8" - os: windows-latest - python-version: "3.8" - sphinx: ">=6,<7" + python-version: "3.10" + sphinx: ">=7,<8" runs-on: ${{ matrix.os }} + name: "pytest: py${{ matrix.python-version }}, sphinx${{ matrix.sphinx }}, on ${{ matrix.os }}" + steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -53,7 +55,7 @@ jobs: pytest --cov=myst_parser --cov-report=xml --cov-report=term-missing coverage xml - name: Upload to Codecov - if: github.repository == 'executablebooks/MyST-Parser' && matrix.python-version == 3.8 && matrix.os == 'ubuntu-latest' + if: github.repository == 'executablebooks/MyST-Parser' && matrix.python-version == 3.10 && matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -70,15 +72,15 @@ jobs: strategy: fail-fast: false matrix: - docutils-version: ["0.18", "0.19", "0.20", "0.21"] + docutils-version: ["0.19", "0.20", "0.21"] steps: - name: Checkout source uses: actions/checkout@v4 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.10" - name: Install setup run: | python -m pip install --upgrade pip @@ -130,10 +132,10 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.10 uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.10" - name: install flit run: | pip install flit~=3.4 @@ -153,10 +155,10 @@ jobs: steps: - name: Checkout source uses: actions/checkout@v4 - - name: Set up Python 3.8 + - name: Set up Python 3.10 uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.10" - name: install flit and tomlkit run: | pip install flit~=3.4 tomlkit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 54da7ee0..70dfd5f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,14 +21,14 @@ repos: - id: trailing-whitespace - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.2 + rev: v0.5.5 hooks: - id: ruff args: [--fix] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.11.1 hooks: - id: mypy args: [--config-file=pyproject.toml] diff --git a/.readthedocs.yml b/.readthedocs.yml index 5b09f7d1..f3a5e31c 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.9" + python: "3.10" python: install: diff --git a/CHANGELOG.md b/CHANGELOG.md index cd489af0..01b78213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ with some minor changes to parsing behaviour: * No significant changes, see * ⬆️ UPGRADE: Add support for `sphinx` v7, and remove v5 support () - * No significant changes, see + * No significant changes, see * ⬆️ UPGRADE: Remove Python 3.7 support and add testing for Python 3.11 () diff --git a/docs/conf.py b/docs/conf.py index 8da6361d..fb3843b9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -4,9 +4,7 @@ # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html -import hashlib from datetime import date -from pathlib import Path from sphinx.application import Sphinx @@ -56,7 +54,7 @@ suppress_warnings = ["myst.strikethrough"] intersphinx_mapping = { - "python": ("https://docs.python.org/3.7", None), + "python": ("https://docs.python.org/3.10", None), "sphinx": ("https://www.sphinx-doc.org/en/master", None), "markdown_it": ("https://markdown-it-py.readthedocs.io/en/latest", None), } @@ -234,16 +232,4 @@ def setup(app: Sphinx): app.add_directive("myst-to-html", MystToHTMLDirective) app.add_post_transform(StripUnsupportedLatex) app.add_post_transform(NumberSections) - app.connect("html-page-context", add_version_to_css) app.add_lexer("myst", MystLexer) - - -def add_version_to_css(app: Sphinx, pagename, templatename, context, doctree): - """Add the version number to the local.css file, to bust the cache for changes.""" - if app.builder.name != "html": - return - if "_static/local.css" in context.get("css_files", {}): - css = Path(app.srcdir, "_static/local.css").read_text("utf8") - hashed = hashlib.sha256(css.encode("utf-8")).hexdigest() - index = context["css_files"].index("_static/local.css") - context["css_files"][index] = f"_static/local.css?hash={hashed}" diff --git a/docs/index.md b/docs/index.md index 3c138b88..df1d25e1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -102,18 +102,6 @@ to modify parsing behaviour and access extended syntax features. [Convert existing ReStructuredText files to Markdown][rst-to-myst] : Use the [rst-to-myst] CLI or [the MySTyc interactive web interface](https://astrojuanlu.github.io/mystyc/). -[MyST-NB](https://myst-nb.readthedocs.io) -: A Sphinx and Docutils extension for compiling Jupyter Notebooks into high quality documentation formats, built on top of the MyST-Parser. - -[Jupyter Book](https://jupyterbook.org) -: An open source project for building beautiful, publication-quality books and documents from computational material, built on top of the MyST-Parser and MyST-NB. - -[The Jupyter Book gallery](https://executablebooks.org/en/latest/gallery) -: Examples of documents built with MyST. - -[Javascript MyST parser][mystjs] -: The [mystjs] Javascript parser, allows you to parse MyST in websites. - [markdown-it-py] : A CommonMark-compliant and extensible Markdown parser, used by MyST-Parser to parse source text to tokens. @@ -182,4 +170,3 @@ apidocs/index.rst [markdown-it-py]: https://markdown-it-py.readthedocs.io/ [markdown-it]: https://markdown-it.github.io/ [rst-to-myst]: https://rst-to-myst.readthedocs.io -[mystjs]: https://github.com/executablebooks/mystjs diff --git a/myst_parser/_compat.py b/myst_parser/_compat.py index ad4653c4..d5f0d0ea 100644 --- a/myst_parser/_compat.py +++ b/myst_parser/_compat.py @@ -1,6 +1,6 @@ """Helpers for cross compatibility across dependency versions.""" -from typing import Callable, Iterable +from collections.abc import Callable, Iterable from docutils.nodes import Element diff --git a/myst_parser/_docs.py b/myst_parser/_docs.py index 1f773934..41212e98 100644 --- a/myst_parser/_docs.py +++ b/myst_parser/_docs.py @@ -4,7 +4,8 @@ import contextlib import io -from typing import Sequence, Union, get_args, get_origin +from collections.abc import Sequence +from typing import Union, get_args, get_origin from docutils import nodes from docutils.core import Publisher @@ -28,7 +29,7 @@ class StripUnsupportedLatex(SphinxPostTransform): default_priority = 900 - def run(self): + def run(self, **kwargs): if self.app.builder.format != "latex": return from docutils import nodes @@ -46,7 +47,7 @@ class NumberSections(SphinxPostTransform): default_priority = 710 # same as docutils.SectNum formats = ("html",) - def run(self): + def run(self, **kwargs): min_heading_level = 2 max_heading_level = 3 stack: list[tuple[list[int], nodes.Element]] = [([], self.document)] diff --git a/myst_parser/config/dc_validators.py b/myst_parser/config/dc_validators.py index db98cc86..b3d52836 100644 --- a/myst_parser/config/dc_validators.py +++ b/myst_parser/config/dc_validators.py @@ -3,7 +3,8 @@ from __future__ import annotations import dataclasses as dc -from typing import Any, Protocol, Sequence +from collections.abc import Sequence +from typing import Any, Protocol def validate_field(inst: Any, field: dc.Field, value: Any) -> None: diff --git a/myst_parser/config/main.py b/myst_parser/config/main.py index f5174f2b..b9b0c478 100644 --- a/myst_parser/config/main.py +++ b/myst_parser/config/main.py @@ -1,20 +1,11 @@ """The configuration for the myst parser.""" import dataclasses as dc +from collections.abc import Callable, Iterable, Iterator, Sequence from importlib import import_module from typing import ( Any, - Callable, - Dict, - Iterable, - Iterator, - List, - Optional, - Sequence, - Set, - Tuple, TypedDict, - Union, ) from myst_parser.warnings_ import MystWarnings @@ -65,12 +56,12 @@ class UrlSchemeType(TypedDict, total=False): url: str title: str - classes: List[str] + classes: list[str] def check_url_schemes(inst: "MdParserConfig", field: dc.Field, value: Any) -> None: """Check that the external schemes are of the right format.""" - if isinstance(value, (list, tuple)): + if isinstance(value, list | tuple): if not all(isinstance(v, str) for v in value): raise TypeError(f"'{field.name}' is not a list of strings: {value!r}") value = {v: None for v in value} @@ -78,7 +69,7 @@ def check_url_schemes(inst: "MdParserConfig", field: dc.Field, value: Any) -> No if not isinstance(value, dict): raise TypeError(f"'{field.name}' is not a dictionary: {value!r}") - new_dict: Dict[str, Optional[UrlSchemeType]] = {} + new_dict: dict[str, UrlSchemeType | None] = {} for key, val in value.items(): if not isinstance(key, str): raise TypeError(f"'{field.name}' key is not a string: {key!r}") @@ -116,7 +107,7 @@ def check_url_schemes(inst: "MdParserConfig", field: dc.Field, value: Any) -> No def check_sub_delimiters(_: "MdParserConfig", field: dc.Field, value: Any) -> None: """Check that the sub_delimiters are a tuple of length 2 of strings of length 1""" - if (not isinstance(value, (tuple, list))) or len(value) != 2: + if (not isinstance(value, tuple | list)) or len(value) != 2: raise TypeError(f"'{field.name}' is not a tuple of length 2: {value}") for delim in value: if (not isinstance(delim, str)) or len(delim) != 1: @@ -132,7 +123,7 @@ def check_inventories(_: "MdParserConfig", field: dc.Field, value: Any) -> None: for key, val in value.items(): if not isinstance(key, str): raise TypeError(f"'{field.name}' key is not a string: {key!r}") - if not isinstance(val, (tuple, list)) or len(val) != 2: + if not isinstance(val, tuple | list) or len(val) != 2: raise TypeError( f"'{field.name}[{key}]' value is not a 2-item list: {val!r}" ) @@ -188,7 +179,7 @@ def __repr__(self) -> str: """Return a string representation of the config.""" # this replicates the auto-generated __repr__, # but also allows for a repr function to be defined on the field - attributes: List[str] = [] + attributes: list[str] = [] for name, val, f in self.as_triple(): if not f.repr: continue @@ -213,7 +204,7 @@ def __repr__(self) -> str: }, ) - enable_extensions: Set[str] = dc.field( + enable_extensions: set[str] = dc.field( default_factory=set, metadata={"validator": check_extensions, "help": "Enable syntax extensions"}, ) @@ -242,7 +233,7 @@ def __repr__(self) -> str: }, ) - url_schemes: Dict[str, Optional[UrlSchemeType]] = dc.field( + url_schemes: dict[str, UrlSchemeType | None] = dc.field( default_factory=lambda: { "http": None, "https": None, @@ -258,7 +249,7 @@ def __repr__(self) -> str: }, ) - ref_domains: Optional[Iterable[str]] = dc.field( + ref_domains: Iterable[str] | None = dc.field( default=None, metadata={ "validator": optional( @@ -269,7 +260,7 @@ def __repr__(self) -> str: }, ) - fence_as_directive: Set[str] = dc.field( + fence_as_directive: set[str] = dc.field( default_factory=set, metadata={ "validator": check_fence_as_directive, @@ -303,7 +294,7 @@ def __repr__(self) -> str: }, ) - heading_slug_func: Optional[Callable[[str], str]] = dc.field( + heading_slug_func: Callable[[str], str] | None = dc.field( default=None, metadata={ "validator": check_heading_slug_func, @@ -316,7 +307,7 @@ def __repr__(self) -> str: }, ) - html_meta: Dict[str, str] = dc.field( + html_meta: dict[str, str] = dc.field( default_factory=dict, metadata={ "validator": deep_mapping( @@ -346,7 +337,7 @@ def __repr__(self) -> str: # Extension specific - substitutions: Dict[str, Any] = dc.field( + substitutions: dict[str, Any] = dc.field( default_factory=dict, metadata={ "validator": deep_mapping(instance_of(str), any_, instance_of(dict)), @@ -357,7 +348,7 @@ def __repr__(self) -> str: }, ) - sub_delimiters: Tuple[str, str] = dc.field( + sub_delimiters: tuple[str, str] = dc.field( default=("{", "}"), repr=False, metadata={ @@ -462,7 +453,7 @@ def __repr__(self) -> str: }, ) - inventories: Dict[str, Tuple[str, Optional[str]]] = dc.field( + inventories: dict[str, tuple[str, str | None]] = dc.field( default_factory=dict, repr=False, metadata={ @@ -484,7 +475,7 @@ def copy(self, **kwargs: Any) -> "MdParserConfig": return dc.replace(self, **kwargs) @classmethod - def get_fields(cls) -> Tuple[dc.Field, ...]: + def get_fields(cls) -> tuple[dc.Field, ...]: """Return all attribute fields in this class.""" return dc.fields(cls) @@ -492,7 +483,7 @@ def as_dict(self, dict_factory=dict) -> dict: """Return a dictionary of field name -> value.""" return dc.asdict(self, dict_factory=dict_factory) - def as_triple(self) -> Iterable[Tuple[str, Any, dc.Field]]: + def as_triple(self) -> Iterable[tuple[str, Any, dc.Field]]: """Yield triples of (name, value, field).""" fields = {f.name: f for f in dc.fields(self.__class__)} for name, value in dc.asdict(self).items(): @@ -501,7 +492,7 @@ def as_triple(self) -> Iterable[Tuple[str, Any, dc.Field]]: def merge_file_level( config: MdParserConfig, - topmatter: Dict[str, Any], + topmatter: dict[str, Any], warning: Callable[[MystWarnings, str], None], ) -> MdParserConfig: """Merge the file-level topmatter with the global config. @@ -512,7 +503,7 @@ def merge_file_level( :returns: A new config object """ # get updates - updates: Dict[str, Any] = {} + updates: dict[str, Any] = {} myst = topmatter.get("myst", {}) if not isinstance(myst, dict): warning(MystWarnings.MD_TOPMATTER, f"'myst' key not a dict: {type(myst)}") @@ -564,7 +555,7 @@ class TopmatterReadError(Exception): """Topmatter parsing error.""" -def read_topmatter(text: Union[str, Iterator[str]]) -> Optional[Dict[str, Any]]: +def read_topmatter(text: str | Iterator[str]) -> dict[str, Any] | None: """Read the (optional) YAML topmatter from a source string. This is identified by the first line starting with `---`, diff --git a/myst_parser/inventory.py b/myst_parser/inventory.py index 88e3ac4c..0782752c 100644 --- a/myst_parser/inventory.py +++ b/myst_parser/inventory.py @@ -13,8 +13,9 @@ import json import re import zlib +from collections.abc import Iterator from dataclasses import asdict, dataclass -from typing import IO, TYPE_CHECKING, Iterator, TypedDict +from typing import IO, TYPE_CHECKING, TypedDict from urllib.request import urlopen import yaml diff --git a/myst_parser/mdit_to_docutils/base.py b/myst_parser/mdit_to_docutils/base.py index 520a3c80..1dded859 100644 --- a/myst_parser/mdit_to_docutils/base.py +++ b/myst_parser/mdit_to_docutils/base.py @@ -8,25 +8,21 @@ import posixpath import re from collections import OrderedDict +from collections.abc import Callable, Iterable, Iterator, MutableMapping, Sequence from contextlib import contextmanager, suppress from datetime import date, datetime from types import ModuleType from typing import ( TYPE_CHECKING, Any, - Callable, - Iterable, - Iterator, - MutableMapping, - Sequence, cast, ) from urllib.parse import urlparse -import docutils import jinja2 import yaml from docutils import nodes +from docutils.frontend import get_default_settings from docutils.languages import get_language from docutils.parsers.rst import Directive, DirectiveError, directives, roles from docutils.parsers.rst import Parser as RSTParser @@ -64,14 +60,7 @@ def make_document(source_path="notset", parser_cls=RSTParser) -> nodes.document: """Create a new docutils document, with the parser classes' default settings.""" - if docutils.__version_info__[:2] >= (0, 19): - from docutils.frontend import get_default_settings - - settings = get_default_settings(parser_cls) - else: - from docutils.frontend import OptionParser - - settings = OptionParser(components=(parser_cls,)).get_default_values() + settings = get_default_settings(parser_cls) return new_document(source_path, settings=settings) @@ -671,7 +660,7 @@ def create_highlighted_code_block( MystWarnings.INVALID_ATTRIBUTE, line=line, ) - if isinstance(emphasize_lines, (list, tuple)): + if isinstance(emphasize_lines, list | tuple): # TODO emphasize_lines in docutils? if "highlight_args" not in node: node["highlight_args"] = {} @@ -863,7 +852,7 @@ def render_heading(self, token: SyntaxTreeNode) -> None: ) if not ( parent_of_temp_root - or isinstance(self.current_node, (nodes.document, nodes.section)) + or isinstance(self.current_node, nodes.document | nodes.section) ): # if this is not the case, we create a rubric node instead rubric = nodes.rubric(token.content, "", level=level) @@ -1334,7 +1323,7 @@ def dict_to_fm_field_list( bibliofields = get_language(language_code).bibliographic_fields for key, value in data.items(): - if not isinstance(value, (str, int, float, date, datetime)): + if not isinstance(value, str | int | float | date | datetime): value = json.dumps(value) value = str(value) body = nodes.paragraph() @@ -1408,9 +1397,10 @@ def render_table_row(self, token: SyntaxTreeNode) -> None: "text-align:center", ): entry["classes"].append(f"text-{cast(str, style).split(':')[1]}") - with self.current_node_context( - entry, append=True - ), self.current_node_context(para, append=True): + with ( + self.current_node_context(entry, append=True), + self.current_node_context(para, append=True), + ): self.render_children(child) def render_s(self, token: SyntaxTreeNode) -> None: diff --git a/myst_parser/mdit_to_docutils/transforms.py b/myst_parser/mdit_to_docutils/transforms.py index 54e8f396..cd2b9d70 100644 --- a/myst_parser/mdit_to_docutils/transforms.py +++ b/myst_parser/mdit_to_docutils/transforms.py @@ -57,22 +57,22 @@ def apply(self, **kwargs: t.Any) -> None: if implicit_title is None: # handle sections and and other captioned elements for subnode in node: - if isinstance(subnode, (nodes.caption, nodes.title)): + if isinstance(subnode, nodes.caption | nodes.title): implicit_title = clean_astext(subnode) break if implicit_title is None: # handle definition lists and field lists if ( - isinstance(node, (nodes.definition_list, nodes.field_list)) + isinstance(node, nodes.definition_list | nodes.field_list) and node.children ): node = node[0] if ( - isinstance(node, (nodes.field, nodes.definition_list_item)) + isinstance(node, nodes.field | nodes.definition_list_item) and node.children ): node = node[0] - if isinstance(node, (nodes.term, nodes.field_name)): + if isinstance(node, nodes.term | nodes.field_name): implicit_title = clean_astext(node) explicit[name] = (labelid, implicit_title) diff --git a/myst_parser/parsers/directives.py b/myst_parser/parsers/directives.py index 22ff5a64..45bf593e 100644 --- a/myst_parser/parsers/directives.py +++ b/myst_parser/parsers/directives.py @@ -37,9 +37,10 @@ from __future__ import annotations import re +from collections.abc import Callable from dataclasses import dataclass from textwrap import dedent -from typing import Any, Callable +from typing import Any import yaml from docutils.parsers.rst import Directive diff --git a/myst_parser/parsers/docutils_.py b/myst_parser/parsers/docutils_.py index 1b7a33bb..c16550b2 100644 --- a/myst_parser/parsers/docutils_.py +++ b/myst_parser/parsers/docutils_.py @@ -1,18 +1,10 @@ """MyST Markdown parser for docutils.""" +from collections.abc import Callable, Iterable, Sequence from dataclasses import Field from typing import ( Any, - Callable, - Dict, - Iterable, - List, Literal, - Optional, - Sequence, - Set, - Tuple, - Union, get_args, get_origin, ) @@ -45,7 +37,7 @@ def _validate_int( def _validate_comma_separated_set( setting, value, option_parser, config_parser=None, config_section=None -) -> Set[str]: +) -> set[str]: """Validate an integer setting.""" value = frontend.validate_comma_separated_list( setting, value, option_parser, config_parser, config_section @@ -53,7 +45,7 @@ def _validate_comma_separated_set( return set(value) -def _create_validate_tuple(length: int) -> Callable[..., Tuple[str, ...]]: +def _create_validate_tuple(length: int) -> Callable[..., tuple[str, ...]]: """Create a validator for a tuple of length `length`.""" def _validate( @@ -125,7 +117,7 @@ def _validate_url_schemes( return output -def _attr_to_optparse_option(at: Field, default: Any) -> Tuple[Dict[str, Any], str]: +def _attr_to_optparse_option(at: Field, default: Any) -> tuple[dict[str, Any], str]: """Convert a field into a Docutils optparse options dict. :returns: (option_dict, default) @@ -160,22 +152,22 @@ def _attr_to_optparse_option(at: Field, default: Any) -> Tuple[Dict[str, Any], s "metavar": "", "validator": frontend.validate_comma_separated_list, }, ",".join(default) - if at.type == Set[str]: + if at.type == set[str]: return { "metavar": "", "validator": _validate_comma_separated_set, }, ",".join(default) - if at.type == Tuple[str, str]: + if at.type == tuple[str, str]: return { "metavar": "", "validator": _create_validate_tuple(2), }, ",".join(default) - if at.type == Union[int, type(None)]: + if at.type == int | type(None): return { "metavar": "", "validator": _validate_int, }, str(default) - if at.type == Union[Iterable[str], type(None)]: + if at.type == Iterable[str] | type(None): return { "metavar": "", "validator": frontend.validate_comma_separated_list, @@ -192,7 +184,7 @@ def _attr_to_optparse_option(at: Field, default: Any) -> Tuple[Dict[str, Any], s def attr_to_optparse_option( attribute: Field, default: Any, prefix: str = "myst_" -) -> Tuple[str, List[str], Dict[str, Any]]: +) -> tuple[str, list[str], dict[str, Any]]: """Convert an ``MdParserConfig`` attribute into a Docutils setting tuple. :returns: A tuple of ``(help string, option flags, optparse kwargs)``. @@ -238,7 +230,7 @@ def create_myst_config( class Parser(RstParser): """Docutils parser for Markedly Structured Text (MyST).""" - supported: Tuple[str, ...] = ("md", "markdown", "myst") + supported: tuple[str, ...] = ("md", "markdown", "myst") """Aliases this parser supports.""" settings_spec = ( @@ -346,7 +338,7 @@ def __init__(self): self.translator_class = SimpleTranslator -def _run_cli(writer_name: str, writer_description: str, argv: Optional[List[str]]): +def _run_cli(writer_name: str, writer_description: str, argv: list[str] | None): """Run the command line interface for a particular writer.""" publish_cmdline( parser=Parser(), @@ -358,17 +350,17 @@ def _run_cli(writer_name: str, writer_description: str, argv: Optional[List[str] ) -def cli_html(argv: Optional[List[str]] = None) -> None: +def cli_html(argv: list[str] | None = None) -> None: """Cmdline entrypoint for converting MyST to HTML.""" _run_cli("html", "(X)HTML documents", argv) -def cli_html5(argv: Optional[List[str]] = None): +def cli_html5(argv: list[str] | None = None): """Cmdline entrypoint for converting MyST to HTML5.""" _run_cli("html5", "HTML5 documents", argv) -def cli_html5_demo(argv: Optional[List[str]] = None): +def cli_html5_demo(argv: list[str] | None = None): """Cmdline entrypoint for converting MyST to simple HTML5 demonstrations. This is a special case of the HTML5 writer, @@ -406,17 +398,17 @@ def to_html5_demo(inputstring: str, **kwargs) -> str: ) -def cli_latex(argv: Optional[List[str]] = None): +def cli_latex(argv: list[str] | None = None): """Cmdline entrypoint for converting MyST to LaTeX.""" _run_cli("latex", "LaTeX documents", argv) -def cli_xml(argv: Optional[List[str]] = None): +def cli_xml(argv: list[str] | None = None): """Cmdline entrypoint for converting MyST to XML.""" _run_cli("xml", "Docutils-native XML", argv) -def cli_pseudoxml(argv: Optional[List[str]] = None): +def cli_pseudoxml(argv: list[str] | None = None): """Cmdline entrypoint for converting MyST to pseudo-XML.""" _run_cli("pseudoxml", "pseudo-XML", argv) diff --git a/myst_parser/parsers/mdit.py b/myst_parser/parsers/mdit.py index b9dda5b1..1c29ef10 100644 --- a/myst_parser/parsers/mdit.py +++ b/myst_parser/parsers/mdit.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Callable +from collections.abc import Callable from markdown_it import MarkdownIt from markdown_it.renderer import RendererProtocol diff --git a/myst_parser/parsers/options.py b/myst_parser/parsers/options.py index bfabf6d2..ce0a9c2b 100644 --- a/myst_parser/parsers/options.py +++ b/myst_parser/parsers/options.py @@ -17,8 +17,9 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass, replace -from typing import ClassVar, Final, Iterable, Literal, cast +from typing import ClassVar, Final, Literal, cast @dataclass diff --git a/myst_parser/parsers/parse_html.py b/myst_parser/parsers/parse_html.py index 73f403cc..d3b0c128 100644 --- a/myst_parser/parsers/parse_html.py +++ b/myst_parser/parsers/parse_html.py @@ -23,8 +23,9 @@ import inspect import itertools from collections import abc, deque +from collections.abc import Callable, Iterable, Iterator from html.parser import HTMLParser -from typing import Any, Callable, Iterable, Iterator +from typing import Any class Attribute(dict): diff --git a/myst_parser/sphinx_ext/main.py b/myst_parser/sphinx_ext/main.py index 259ab0a9..a72527c4 100644 --- a/myst_parser/sphinx_ext/main.py +++ b/myst_parser/sphinx_ext/main.py @@ -69,8 +69,6 @@ def setup_sphinx(app: Sphinx, load_parser: bool = False) -> None: def create_myst_config(app): """Create the myst config object and add it to the sphinx environment.""" from sphinx.util import logging - - # Ignore type checkers because the attribute is dynamically assigned from sphinx.util.console import bold from myst_parser import __version__ diff --git a/myst_parser/sphinx_ext/myst_refs.py b/myst_parser/sphinx_ext/myst_refs.py index 913a4f8d..5d69666f 100644 --- a/myst_parser/sphinx_ext/myst_refs.py +++ b/myst_parser/sphinx_ext/myst_refs.py @@ -64,12 +64,7 @@ def log_warning( ): return - LOGGER.warning( - msg + f" [myst.{subtype.value}]", - type="myst", - subtype=subtype.value, - **kwargs, - ) + LOGGER.warning(msg, type="myst", subtype=subtype.value, **kwargs) def run(self, **kwargs: Any) -> None: self.document: document diff --git a/myst_parser/warnings_.py b/myst_parser/warnings_.py index 9dcde95c..14183544 100644 --- a/myst_parser/warnings_.py +++ b/myst_parser/warnings_.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Sequence from enum import Enum -from typing import Sequence from docutils import nodes @@ -108,21 +108,48 @@ def create_warning( If the warning type is listed in the ``suppress_warnings`` configuration, then ``None`` will be returned and no warning logged. """ + # In general we want to both create a warning node within the document AST, + # and also log the warning to output it in the CLI etc. + # docutils and sphinx have different ways of doing this, so we need to handle both. + # Note also that in general we want to show the type/subtype in the warning message, + # but this was added as an option to sphinx in v7.3, and made the default in v8.0. + wtype = "myst" - # figure out whether to suppress the warning, if sphinx is available, - # it will have been set up by the Sphinx environment, - # otherwise we will use the configuration set by docutils - suppress_warnings: Sequence[str] = [] - try: - suppress_warnings = document.settings.env.app.config.suppress_warnings - except AttributeError: - suppress_warnings = document.settings.myst_suppress_warnings or [] - if _is_suppressed_warning(wtype, subtype.value, suppress_warnings): - return None + message_with_type = f"{message} [{wtype}.{subtype.value}]" + + if hasattr(document.settings, "env"): + # Sphinx + from sphinx.util.logging import getLogger + + logger = getLogger(__name__) + logger.warning( + message, + type=wtype, + subtype=subtype.value, + location=(document["source"], line), + ) + if _is_suppressed_warning( + wtype, subtype.value, document.settings.env.config.suppress_warnings + ): + return None + msg_node = _create_warning_node(message_with_type, document["source"], line) + else: + # docutils + if _is_suppressed_warning( + wtype, subtype.value, document.settings.myst_suppress_warnings or [] + ): + return None + msg_node = document.reporter.warning( + message_with_type, **({"line": line} if line is not None else {}) + ) - kwargs = {"line": line} if line is not None else {} - message = f"{message} [{wtype}.{subtype.value}]" - msg_node = document.reporter.warning(message, **kwargs) if append_to is not None: append_to.append(msg_node) return msg_node + + +def _create_warning_node( + msg: str, source: str, line: int | None +) -> nodes.system_message: + kwargs = {"line": line} if line is not None else {} + return nodes.system_message(msg, level=2, type="WARNING", source=source, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index 65c66548..8933f90b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -33,14 +31,14 @@ keywords = [ "docutils", "sphinx", ] -requires-python = ">=3.8" +requires-python = ">=3.10" dependencies = [ - "docutils>=0.18,<0.22", + "docutils>=0.19,<0.22", "jinja2", # required for substitutions, but let sphinx choose version "markdown-it-py~=3.0", "mdit-py-plugins~=0.4", "pyyaml", - "sphinx>=6,<8", + "sphinx>=7,<9", ] [project.urls] diff --git a/tests/test_renderers/test_fixtures_docutils.py b/tests/test_renderers/test_fixtures_docutils.py index 0f21df6a..b1e1e92c 100644 --- a/tests/test_renderers/test_fixtures_docutils.py +++ b/tests/test_renderers/test_fixtures_docutils.py @@ -11,7 +11,6 @@ from typing import Any import pytest -from docutils import __version_info__ from docutils.core import Publisher, publish_doctree from myst_parser.parsers.docutils_ import Parser @@ -44,8 +43,6 @@ def _apply_transforms(self): @pytest.mark.param_file(FIXTURE_PATH / "docutil_link_resolution.md") def test_link_resolution(file_params): """Test that Markdown links resolve to the correct target, or give the correct warning.""" - if "explicit>implicit" in file_params.title and __version_info__ < (0, 18): - pytest.skip("ids changed in docutils 0.18+") settings = settings_from_cmdline(file_params.description) report_stream = StringIO() settings["warning_stream"] = report_stream @@ -76,18 +73,7 @@ def _apply_transforms(self): parser=Parser(), ) - ptree = doctree.pformat() - # docutils >=0.19 changes: - ptree = ptree.replace( - 'refuri="http://tools.ietf.org/html/rfc1.html"', - 'refuri="https://tools.ietf.org/html/rfc1.html"', - ) - ptree = ptree.replace( - 'refuri="http://www.python.org/dev/peps/pep-0000"', - 'refuri="https://peps.python.org/pep-0000"', - ) - - file_params.assert_expected(ptree, rstrip_lines=True) + file_params.assert_expected(doctree.pformat(), rstrip_lines=True) @pytest.mark.param_file(FIXTURE_PATH / "docutil_directives.md") diff --git a/tests/test_renderers/test_fixtures_sphinx.py b/tests/test_renderers/test_fixtures_sphinx.py index 65aba83a..af4e5890 100644 --- a/tests/test_renderers/test_fixtures_sphinx.py +++ b/tests/test_renderers/test_fixtures_sphinx.py @@ -20,7 +20,9 @@ @pytest.mark.param_file(FIXTURE_PATH / "sphinx_syntax_elements.md") def test_syntax_elements(file_params, sphinx_doctree_no_tr: CreateDoctree): - sphinx_doctree_no_tr.set_conf({"extensions": ["myst_parser"]}) + sphinx_doctree_no_tr.set_conf( + {"extensions": ["myst_parser"], "show_warning_types": True} + ) result = sphinx_doctree_no_tr(file_params.content, "index.md") pformat = result.pformat("index") # changed in docutils 0.20.1 diff --git a/tests/test_renderers/test_myst_config.py b/tests/test_renderers/test_myst_config.py index c7acfb9d..8ee5f8db 100644 --- a/tests/test_renderers/test_myst_config.py +++ b/tests/test_renderers/test_myst_config.py @@ -5,7 +5,6 @@ from pathlib import Path import pytest -from docutils import __version_info__ from docutils.core import Publisher, publish_string from pytest_param_files import ParamTestData @@ -18,13 +17,9 @@ @pytest.mark.param_file(FIXTURE_PATH / "myst-config.txt") def test_cmdline(file_params: ParamTestData): """The description is parsed as a docutils commandline""" - if "url_schemes_list" in file_params.title and __version_info__ < (0, 18): - pytest.skip("problematic node ids changed in docutils 0.18") - if "heading_slug_func" in file_params.title and __version_info__ < (0, 18): - pytest.skip("dupnames ids changed in docutils 0.18") pub = Publisher(parser=Parser()) try: - pub.process_command_line(shlex.split(file_params.description)) + pub.process_command_line(shlex.split(file_params.description or "")) except Exception as err: raise AssertionError( f"Failed to parse commandline: {file_params.description}\n{err}" diff --git a/tests/test_renderers/test_myst_refs.py b/tests/test_renderers/test_myst_refs.py index 6412eadc..ac69f771 100644 --- a/tests/test_renderers/test_myst_refs.py +++ b/tests/test_renderers/test_myst_refs.py @@ -1,4 +1,5 @@ import pytest +from sphinx.util.console import strip_colors from sphinx_pytest.plugin import CreateDoctree @@ -23,7 +24,7 @@ def test_parse( sphinx_doctree: CreateDoctree, file_regression, ): - sphinx_doctree.set_conf({"extensions": ["myst_parser"]}) + sphinx_doctree.set_conf({"extensions": ["myst_parser"], "show_warning_types": True}) result = sphinx_doctree(text, "index.md") assert not result.warnings @@ -38,7 +39,5 @@ def test_parse( doctree.attributes.pop("translation_progress", None) outcome = doctree.pformat() if result.warnings.strip(): - outcome += "\n\n" + result.warnings.strip().replace("", "").replace( - "", "" - ) + outcome += "\n\n" + strip_colors(result.warnings.strip()) file_regression.check(outcome, basename=test_name, extension=".xml") diff --git a/tests/test_sphinx/sourcedirs/mathjax/conf.py b/tests/test_sphinx/sourcedirs/mathjax/conf.py index b2a70c8d..bec75419 100644 --- a/tests/test_sphinx/sourcedirs/mathjax/conf.py +++ b/tests/test_sphinx/sourcedirs/mathjax/conf.py @@ -1,12 +1,7 @@ -import sphinx - extensions = ["myst_parser"] exclude_patterns = ["_build"] -if sphinx.version_info[0] <= 3: - mathjax_config = {"tex2jax": {"processClass": "other"}} -else: - mathjax3_config = {"options": {"processHtmlClass": "other"}} +mathjax3_config = {"options": {"processHtmlClass": "other"}} # this should remove the warning # suppress_warnings = ["myst.mathjax"] diff --git a/tests/test_sphinx/test_sphinx_builds.py b/tests/test_sphinx/test_sphinx_builds.py index 1a7b5986..ca11d421 100644 --- a/tests/test_sphinx/test_sphinx_builds.py +++ b/tests/test_sphinx/test_sphinx_builds.py @@ -11,7 +11,6 @@ import pytest import sphinx -from docutils import VersionInfo, __version_info__ SOURCE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "sourcedirs")) @@ -85,7 +84,10 @@ def test_basic( buildername="html", srcdir=os.path.join(SOURCE_DIR, "references"), freshenv=True, - confoverrides={"myst_enable_extensions": ["dollarmath"]}, + confoverrides={ + "myst_enable_extensions": ["dollarmath"], + "show_warning_types": True, + }, ) def test_references( app, @@ -319,10 +321,6 @@ def test_include_from_rst( ) -@pytest.mark.skipif( - __version_info__ < VersionInfo(0, 19, 0, "final", 0, True), - reason="Footnote HTML changed in docutils 0.19", -) @pytest.mark.sphinx( buildername="html", srcdir=os.path.join(SOURCE_DIR, "footnotes"), freshenv=True ) diff --git a/tox.ini b/tox.ini index 25a7aaea..77317ea0 100644 --- a/tox.ini +++ b/tox.ini @@ -11,15 +11,15 @@ # then then deleting compiled files has been found to fix it: `find . -name \*.pyc -delete` [tox] -envlist = py39-sphinx7 +envlist = py310-sphinx8 [testenv] usedevelop = true -[testenv:py{38,39,310,311,312}-sphinx{6,7}] +[testenv:py{310,311,312}-sphinx{7,8}] deps = - sphinx6: sphinx>=6,<7 sphinx7: sphinx>=7,<8 + sphinx8: sphinx>=8,<9 extras = linkify testing