diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 5af0a95..0000000 --- a/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -max-line-length = 95 -ignore = E116,E241,E251 -exclude = .git,.tox,.venv diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..47a31bc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 8387d74..c6d3a57 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -17,9 +17,9 @@ jobs: permissions: id-token: write # for PyPI trusted publishing steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3 cache: pip @@ -35,7 +35,7 @@ jobs: - name: Mint PyPI API token id: mint-token - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: # language=JavaScript script: | @@ -75,16 +75,16 @@ jobs: permissions: contents: write # for softprops/action-gh-release to create GitHub release steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Get release version id: get_version - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: core.setOutput('version', context.ref.replace("refs/tags/", "")) - name: Create GitHub release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 if: startsWith(github.ref, 'refs/tags/') with: name: "sphinxcontrib-htmlhelp ${{ steps.get_version.outputs.version }}" - body: "Changelog: https://www.sphinx-doc.org/en/master/changes.html" + body: "Changelog: https://github.com/sphinx-doc/sphinxcontrib-htmlhelp/blob/master/CHANGES.rst" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8cd7db1..e337f0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,26 +25,26 @@ jobs: - "3.9" - "3.10" - "3.11" - - "3.12-dev" + - "3.12" - "3.13-dev" fail-fast: false steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 if: "!endsWith(matrix.python, '-dev')" with: python-version: ${{ matrix.python }} - name: Set up Python ${{ matrix.python }} (deadsnakes) - uses: deadsnakes/action@v2.1.1 + uses: deadsnakes/action@v3.1.0 if: "endsWith(matrix.python, '-dev')" with: python-version: ${{ matrix.python }} - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install .[test,standalone] + python -m pip install .[standalone,test] - name: Test with pytest run: python -m pytest -vv --durations 25 @@ -53,9 +53,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3" - name: Install dependencies @@ -71,12 +71,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - env: [flake8, mypy] + env: + - ruff + - mypy steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3" diff --git a/.github/workflows/transifex.yml b/.github/workflows/transifex.yml index 1f3bab3..71633f2 100644 --- a/.github/workflows/transifex.yml +++ b/.github/workflows/transifex.yml @@ -15,9 +15,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3 - name: Install transifex client @@ -44,9 +44,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3 - name: Install transifex client @@ -67,7 +67,7 @@ jobs: - name: Compile message catalogs run: python utils/babel_runner.py compile - name: Create Pull Request - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v6 with: commit-message: "[internationalisation] Update translations" branch: bot/pull-translations diff --git a/.gitignore b/.gitignore index fe75ce5..10cef94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,17 @@ *.pyc -*.egg -*.so -*.swp .DS_Store +idea/ +.vscode/ + .mypy_cache/ +.pytest_cache/ +.ruff_cache/ .tags .tox/ +.venv/ +venv/ + build/ dist/ -sphinxcontrib_htmlhelp.egg-info/ diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..4b7dd2a --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,53 @@ +target-version = "py39" # Pin Ruff to Python 3.9 +output-format = "full" +line-length = 95 + +[lint] +preview = true +select = [ +# "ANN", # flake8-annotations + "C4", # flake8-comprehensions + "COM", # flake8-commas + "B", # flake8-bugbear + "DTZ", # flake8-datetimez + "E", # pycodestyle + "EM", # flake8-errmsg + "EXE", # flake8-executable + "F", # pyflakes + "FA", # flake8-future-annotations + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "ICN", # flake8-import-conventions + "INT", # flake8-gettext + "LOG", # flake8-logging + "PERF", # perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PT", # flake8-pytest-style + "SIM", # flake8-simplify + "SLOT", # flake8-slots + "TCH", # flake8-type-checking + "UP", # pyupgrade + "W", # pycodestyle + "YTT", # flake8-2020 +] +ignore = [ + "E116", + "E241", + "E251", +] + +[lint.per-file-ignores] +"tests/*" = [ + "ANN", # tests don't need annotations +] + +[lint.isort] +forced-separate = [ + "tests", +] +required-imports = [ + "from __future__ import annotations", +] diff --git a/CHANGES b/CHANGES.rst similarity index 83% rename from CHANGES rename to CHANGES.rst index f2e1585..14bd960 100644 --- a/CHANGES +++ b/CHANGES.rst @@ -1,3 +1,16 @@ +Release 2.1.0 (2024-07-28) +========================== + +* Adopt Ruff +* Tighten MyPy settings +* Update GitHub actions versions +* Escape HTML entities + +Release 2.0.6 (2024-07-20) +========================== + +* Fix tests for Sphinx 7.4 and later. + Release 2.0.5 (2024-01-13) ========================== diff --git a/LICENSE b/LICENCE.rst similarity index 100% rename from LICENSE rename to LICENCE.rst diff --git a/Makefile b/Makefile index 26f411a..438ee54 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ clean-mypyfiles: .PHONY: style-check style-check: - @flake8 + @ruff check .PHONY: type-check type-check: diff --git a/pyproject.toml b/pyproject.toml index fb933af..86da7e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,11 +7,11 @@ build-backend = "flit_core.buildapi" name = "sphinxcontrib-htmlhelp" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" readme = "README.rst" -urls.Changelog = "https://www.sphinx-doc.org/en/master/changes.html" -urls.Code = "https://github.com/sphinx-doc/sphinxcontrib-htmlhelp" +urls.Changelog = "https://github.com/sphinx-doc/sphinxcontrib-htmlhelp/blob/master/CHANGES.rst" +urls.Code = "https://github.com/sphinx-doc/sphinxcontrib-htmlhelp/" urls.Download = "https://pypi.org/project/sphinxcontrib-htmlhelp/" urls.Homepage = "https://www.sphinx-doc.org/" -urls."Issue tracker" = "https://github.com/sphinx-doc/sphinx/issues" +urls."Issue tracker" = "https://github.com/sphinx-doc/sphinx/issues/" license.text = "BSD-2-Clause" requires-python = ">=3.9" @@ -48,9 +48,9 @@ test = [ "html5lib", ] lint = [ - "flake8", + "ruff==0.5.5", "mypy", - "docutils-stubs", + "types-docutils", ] standalone = [ "Sphinx>=5", @@ -65,12 +65,42 @@ name = "sphinxcontrib.htmlhelp" [tool.flit.sdist] include = [ - "CHANGES", - "LICENSE", + "CHANGES.rst", + "LICENCE.rst", # Tests "tests/", "tox.ini", ] [tool.mypy] -ignore_missing_imports = true +python_version = "3.9" +packages = [ + "sphinxcontrib", + "tests", +] +exclude = [ + "tests/roots", +] +check_untyped_defs = true +disallow_any_generics = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +#explicit_package_bases = true +extra_checks = true +no_implicit_reexport = true +show_column_numbers = true +show_error_context = true +strict_optional = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true +enable_error_code = [ + "type-arg", + "redundant-self", + "truthy-iterable", + "ignore-without-code", + "unused-awaitable", +] diff --git a/sphinxcontrib/htmlhelp/__init__.py b/sphinxcontrib/htmlhelp/__init__.py index dd27da2..3127554 100644 --- a/sphinxcontrib/htmlhelp/__init__.py +++ b/sphinxcontrib/htmlhelp/__init__.py @@ -4,17 +4,16 @@ import html import os +import re +from html.entities import codepoint2name from os import path -from typing import Any - -from docutils import nodes -from docutils.nodes import Element, Node, document +from pathlib import Path +from typing import TYPE_CHECKING, Any import sphinx +from docutils import nodes from sphinx import addnodes -from sphinx.application import Sphinx from sphinx.builders.html import StandaloneHTMLBuilder -from sphinx.config import Config from sphinx.environment.adapters.indexentries import IndexEntries from sphinx.locale import get_translation from sphinx.util import logging @@ -23,13 +22,18 @@ from sphinx.util.osutil import make_filename_from_project, relpath from sphinx.util.template import SphinxRenderer +if TYPE_CHECKING: + from docutils.nodes import Element, Node + from sphinx.application import Sphinx + from sphinx.config import Config + if sphinx.version_info[:2] >= (6, 1): from sphinx.util.display import progress_message else: - from sphinx.util import progress_message # type: ignore[attr-defined,no-redef] + from sphinx.util import progress_message # type: ignore[no-redef] -__version__ = '2.0.5' -__version_info__ = (2, 0, 5) +__version__ = '2.1.0' +__version_info__ = (2, 1, 0) logger = logging.getLogger(__name__) __ = get_translation(__name__, 'console') @@ -89,7 +93,7 @@ def chm_htmlescape(s: str, quote: bool = True) -> str: class ToCTreeVisitor(nodes.NodeVisitor): - def __init__(self, document: document) -> None: + def __init__(self, document: nodes.document) -> None: super().__init__(document) self.body: list[str] = [] self.depth = 0 @@ -126,8 +130,8 @@ def depart_list_item(self, node: Element) -> None: def visit_reference(self, node: Element) -> None: title = chm_htmlescape(node.astext(), True) - self.append(' ' % title) - self.append(' ' % node['refuri']) + self.append(f' ') + self.append(f' ') self.append('') raise nodes.SkipNode @@ -170,24 +174,42 @@ def prepare_writing(self, docnames: set[str]) -> None: super().prepare_writing(docnames) self.globalcontext['html5_doctype'] = False - def update_page_context(self, pagename: str, templatename: str, ctx: dict, event_arg: str) -> None: # NOQA + def update_page_context( + self, + pagename: str, + templatename: str, + ctx: dict[str, Any], + event_arg: str, + ) -> None: ctx['encoding'] = self.encoding + # escape the `body` part to 7-bit ASCII + body = ctx.get("body") + if body is not None: + ctx["body"] = re.sub(r"[^\x00-\x7F]", self._escape, body) + + @staticmethod + def _escape(match: re.Match[str]) -> str: + codepoint = ord(match.group(0)) + if codepoint in codepoint2name: + return f"&{codepoint2name[codepoint]};" + return f"&#{codepoint};" + def handle_finish(self) -> None: self.copy_stopword_list() self.build_project_file() self.build_toc_file() self.build_hhx(self.outdir, self.config.htmlhelp_basename) - def write_doc(self, docname: str, doctree: document) -> None: - for node in doctree.traverse(nodes.reference): + def write_doc(self, docname: str, doctree: nodes.document) -> None: + for node in doctree.findall(nodes.reference): # add ``target=_blank`` attributes to external links if node.get('internal') is None and 'refuri' in node: node['target'] = '_blank' super().write_doc(docname, doctree) - def render(self, name: str, context: dict) -> str: + def render(self, name: str, context: dict[str, Any]) -> str: template = SphinxRenderer(template_dir) return template.render(name, context) @@ -220,51 +242,54 @@ def build_project_file(self) -> None: fn = relpath(path.join(root, fn), self.outdir) project_files.append(fn.replace(os.sep, '\\')) - filename = path.join(self.outdir, self.config.htmlhelp_basename + '.hhp') - with open(filename, 'w', encoding=self.encoding, errors='xmlcharrefreplace') as f: - context = { - 'outname': self.config.htmlhelp_basename, - 'title': self.config.html_title, - 'version': self.config.version, - 'project': self.config.project, - 'lcid': self.lcid, - 'master_doc': self.config.master_doc + self.out_suffix, - 'files': project_files, - } - body = self.render('project.hhp', context) - f.write(body) + context = { + 'outname': self.config.htmlhelp_basename, + 'title': self.config.html_title, + 'version': self.config.version, + 'project': self.config.project, + 'lcid': self.lcid, + 'master_doc': self.config.master_doc + self.out_suffix, + 'files': project_files, + } + body = self.render('project.hhp', context) + filename = Path(self.outdir, f'{self.config.htmlhelp_basename}.hhp') + filename.write_text(body, encoding=self.encoding, errors='xmlcharrefreplace') @progress_message(__('writing TOC file')) def build_toc_file(self) -> None: """Create a ToC file (.hhp) on outdir.""" - filename = path.join(self.outdir, self.config.htmlhelp_basename + '.hhc') - with open(filename, 'w', encoding=self.encoding, errors='xmlcharrefreplace') as f: - toctree = self.env.get_and_resolve_doctree(self.config.master_doc, self, - prune_toctrees=False) - visitor = ToCTreeVisitor(toctree) - matcher = NodeMatcher(addnodes.compact_paragraph, toctree=True) - for node in toctree.traverse(matcher): # type: addnodes.compact_paragraph - node.walkabout(visitor) - - context = { - 'body': visitor.astext(), - 'suffix': self.out_suffix, - 'short_title': self.config.html_short_title, - 'master_doc': self.config.master_doc, - 'domain_indices': self.domain_indices, - } - f.write(self.render('project.hhc', context)) + toctree = self.env.get_and_resolve_doctree(self.config.master_doc, self, + prune_toctrees=False) + visitor = ToCTreeVisitor(toctree) + matcher = NodeMatcher(addnodes.compact_paragraph, toctree=True) + for node in toctree.findall(matcher): + node.walkabout(visitor) + + context = { + 'body': visitor.astext(), + 'suffix': self.out_suffix, + 'short_title': self.config.html_short_title, + 'master_doc': self.config.master_doc, + 'domain_indices': self.domain_indices, + } + body = self.render('project.hhc', context) + filename = Path(self.outdir, f'{self.config.htmlhelp_basename}.hhc') + filename.write_text(body, encoding=self.encoding, errors='xmlcharrefreplace') def build_hhx(self, outdir: str | os.PathLike[str], outname: str) -> None: logger.info(__('writing index file...')) index = IndexEntries(self.env).create_index(self) - filename = path.join(outdir, outname + '.hhk') + filename = Path(outdir, outname + '.hhk') with open(filename, 'w', encoding=self.encoding, errors='xmlcharrefreplace') as f: f.write('