diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..5523d9d --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,38 @@ +name: Lint source code + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + FORCE_COLOR: "1" + UV_SYSTEM_PYTHON: "1" # make uv do global installs + +jobs: + ruff: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install Ruff + uses: astral-sh/ruff-action@v3 + with: + args: --version + version: 0.9.2 + + - name: Lint with Ruff + run: ruff check --output-format=github + + - name: Format with Ruff + run: ruff format --diff diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 76dac05..2fb3430 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -1,33 +1,12 @@ name: Test and Deploy on: pull_request: - branches: - - main push: env: - FORCE_COLOR: 1 + FORCE_COLOR: "1" jobs: - check: - runs-on: ubuntu-latest - - # We want to run on external PRs, but not on our own internal PRs as they'll be run - # by the push to the branch. - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.x' - cache: pip - cache-dependency-path: .github/workflows/workflow.yml - - name: Black - run: | - pip install black - black --check --exclude /docs --diff . - build-wheel: runs-on: ubuntu-latest steps: @@ -37,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.8" + python-version: "3.9" cache: pip cache-dependency-path: | .github/workflows/workflow.yml @@ -47,7 +26,7 @@ jobs: set -xe python -VV python -m site - python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade pip python -m pip install -r dev-requirements.txt - name: Install package run: | @@ -62,22 +41,39 @@ jobs: name: my-dist path: dist/* + test: needs: build-wheel runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ['pypy3.9', '3.8', '3.9', '3.10', '3.11', '3.12'] - sphinx-version: ['>=5,<6', '>=6a0,<7', '>=7,<8', '>=8,<9'] - os: [windows-latest, macos-latest, ubuntu-latest] + python-version: + - "pypy3.9" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + sphinx-version: + - ">=6,<7" + - ">=7,<8" + - ">=8,<9" + os: + - windows-latest + - macos-latest + - ubuntu-latest exclude: - - python-version: '3.8' - sphinx-version: '>=8,<9' - - python-version: '3.9' - sphinx-version: '>=8,<9' - - python-version: 'pypy3.9' - sphinx-version: '>=8,<9' + - python-version: "3.9" + sphinx-version: ">=8,<9" + - python-version: "pypy3.9" + sphinx-version: ">=6,<7" + os: "macos-latest" + - python-version: "pypy3.9" + sphinx-version: ">=7,<8" + os: "macos-latest" + - python-version: "pypy3.9" + sphinx-version: ">=8,<9" steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -92,7 +88,7 @@ jobs: run: | python -VV python -m site - python -m pip install --upgrade pip setuptools wheel + python -m pip install --upgrade pip python -m pip install -r dev-requirements.txt python -m pip install "sphinx${{ matrix.sphinx-version }}" - name: Download sdist and wheel artifacts @@ -108,7 +104,7 @@ jobs: python -m pytest -vv - name: Install matplotlib run: | - python -m pip install matplotlib + python -m pip install --only-binary :all: matplotlib - name: Run tests with matplotlib for ${{ matrix.python-version }} run: | python -m pytest -vv @@ -122,7 +118,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: "3" cache: pip cache-dependency-path: docs/requirements.txt - name: Install dependencies diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..67fdfd9 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,95 @@ +target-version = "py39" # Pin Ruff to Python 3.9 +line-length = 88 +output-format = "full" + +[format] +quote-style = "double" +docstring-code-format = true + +[lint] +select = [ + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "D", # pydocstyle + "D212", # Multi-line docstring summary should start at the first line + "D417", # Missing argument description in the docstring for `{definition}`: `{name}` + "DTZ", # flake8-datetimez + "E", # pycodestyle + "EM", # flake8-errmsg + "EXE", # flake8-executable + "F", # pyflakes + "FA", # flake8-future-annotations + "FIX", # flake8-fixme + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "ICN", # flake8-import-conventions + "INT", # flake8-gettext + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "N", # pep8-naming + "PERF", # perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PLC", # pylint + "PLE", # pylint + "PLW", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "Q", # flake8-quotes + "RET", # flake8-return + "RSE", # flake8-raise + "RUF", # Ruff-specific rules + "S", # flake8-bandit + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T10", # flake8-debugger + "TC", # flake8-type-checking + "TD", # flake8-todos + "TID", # flake8-tidy-imports + "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle + "W", # pycodestyle + "YTT", # flake8-2020 +] +ignore = [ + # pydocstyle + "D100", # Missing docstring in public module + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D107", # Missing docstring in `__init__` + "D400", # First line should end with a period + # pycodestyle + "E501", # Ignore line length errors (we use auto-formatting) +] + +[lint.per-file-ignores] +"tests/*" = [ + "ANN", # tests don't need annotations + "S101", # allow use of assert + "SLF001", # allow private member access +] + +[lint.flake8-type-checking] +exempt-modules = [] +strict = true + +[lint.isort] +forced-separate = [ + "tests", +] +required-imports = [ + "from __future__ import annotations", +] + +[lint.pydocstyle] +convention = "pep257" +ignore-decorators = ["typing.overload"] +ignore-var-parameters = true diff --git a/LICENSE.md b/LICENSE.md index ef1a952..9d6b64a 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -3,14 +3,15 @@ All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of the FIRST nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of the FIRST nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY FIRST AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED @@ -21,4 +22,4 @@ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index c01653c..be2b98a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # sphinxext-opengraph [![Build](https://github.com/wpilibsuite/sphinxext-opengraph/workflows/Test%20and%20Deploy/badge.svg)](https://github.com/wpilibsuite/sphinxext-opengraph/actions) -[![Code style: Black](https://img.shields.io/badge/code%20style-Black-000000.svg)](https://github.com/psf/black) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/format.json)](https://github.com/astral-sh/ruff) Sphinx extension to generate [Open Graph metadata](https://ogp.me/) for each page of your documentation. @@ -11,6 +11,12 @@ Sphinx extension to generate [Open Graph metadata](https://ogp.me/) for each pag python -m pip install sphinxext-opengraph ``` +The `matplotlib` package is required to generate social cards: + +```sh +python -m pip install sphinxext-opengraph[social_cards] +``` + ## Usage Just add `sphinxext.opengraph` to your extensions list in your `conf.py` @@ -19,6 +25,8 @@ extensions = [ "sphinxext.opengraph", ] ``` + + ## Options These values are placed in the `conf.py` of your Sphinx project. diff --git a/dev-requirements.txt b/dev-requirements.txt index ab5efb8..dd6b67c 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,4 @@ sphinx -wheel==0.43.0 +build>=1 pytest==7.4.4 beautifulsoup4==4.12.3 -setuptools==70.1.0 diff --git a/docs/requirements.txt b/docs/requirements.txt index 83e6de9..3396011 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ -myst-parser==0.18.1 -furo==2022.9.29 -sphinx==5.2.3 +myst-parser>=4 +furo>=2024 +sphinx~=8.1.0 sphinx-design ./ -matplotlib \ No newline at end of file +matplotlib diff --git a/docs/script/generate_social_card_previews.py b/docs/script/generate_social_card_previews.py index 9d141c0..7d1cfaa 100644 --- a/docs/script/generate_social_card_previews.py +++ b/docs/script/generate_social_card_previews.py @@ -1,34 +1,38 @@ -""" -A helper script to test out what social previews look like. +"""A helper script to test out what social previews look like. + I should remove this when I'm happy with the result. """ # %load_ext autoreload # %autoreload 2 +from __future__ import annotations + +import random from pathlib import Path from textwrap import dedent + from sphinxext.opengraph.socialcards import ( - render_social_card, - MAX_CHAR_PAGE_TITLE, MAX_CHAR_DESCRIPTION, + MAX_CHAR_PAGE_TITLE, + create_social_card_objects, + render_social_card, ) -import random -here = Path(__file__).parent +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent # Dummy lorem text lorem = """ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum -""".split() # noqa +""".split() -kwargs_fig = dict( - image=here / "../source/_static/og-logo.png", - image_mini=here / "../../sphinxext/opengraph/_static/sphinx-logo-shadow.png", -) +kwargs_fig = { + "image": PROJECT_ROOT / "docs/source/_static/og-logo.png", + "image_mini": PROJECT_ROOT / "sphinxext/opengraph/_static/sphinx-logo-shadow.png", +} print("Generating previews of social media cards...") -plt_objects = None +plt_objects = create_social_card_objects(**kwargs_fig) embed_text = [] for perm in range(20): # Create dummy text description and pagetitle for this iteration @@ -40,7 +44,7 @@ desc = " ".join(lorem[:100]) desc = desc[: MAX_CHAR_DESCRIPTION - 3] + "..." - path_tmp = Path(here / "../tmp") + path_tmp = Path(PROJECT_ROOT / "docs/tmp") path_tmp.mkdir(exist_ok=True) path_out = Path(path_tmp / f"num_{perm}.png") @@ -51,10 +55,9 @@ description=desc, siteurl="sphinxext-opengraph.readthedocs.io", plt_objects=plt_objects, - kwargs_fig=kwargs_fig, ) - path_examples_page_folder = here / ".." + path_examples_page_folder = PROJECT_ROOT / "docs" embed_text.append( dedent( f""" @@ -76,6 +79,6 @@ """ # Write markdown text that we can use to embed these images in the docs -(here / "../tmp/embed.txt").write_text(embed_text) +(PROJECT_ROOT / "docs/tmp/embed.txt").write_text(embed_text) print("Done generating previews of social media cards...") diff --git a/docs/source/conf.py b/docs/source/conf.py index eb612c8..f92ab04 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -4,18 +4,15 @@ # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html -# -- Path setup -------------------------------------------------------------- +from __future__ import annotations -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# -import os import sys +from pathlib import Path from subprocess import run -sys.path.insert(0, os.path.abspath("../..")) +# -- Path setup -------------------------------------------------------------- +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) # -- Project information ----------------------------------------------------- @@ -69,5 +66,7 @@ } # Generate sample social media preview images -path_script = os.path.abspath("../script/generate_social_card_previews.py") -run(f"python {path_script}", shell=True) +path_script = Path( + __file__, "..", "..", "script", "generate_social_card_previews.py" +).resolve() +run(("python", path_script), check=False) # NoQA: S603 diff --git a/noxfile.py b/noxfile.py index f06b6d1..c44f863 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,5 +1,5 @@ -""" -Configuration to automatically run jobs and tests via `nox`. +"""Configuration to automatically run jobs and tests via `nox`. + For example, to build the documentation with a live server: nox -s docs -- live @@ -11,14 +11,17 @@ ref: https://nox.thea.codes/ """ -import nox +from __future__ import annotations + from shlex import split +import nox + nox.options.reuse_existing_virtualenvs = True @nox.session -def docs(session): +def docs(session: nox.Session) -> None: """Build the documentation. Use `-- live` to build with a live server.""" session.install("-r", "docs/requirements.txt") session.install("-e", ".") @@ -28,13 +31,13 @@ def docs(session): session.run(*split("sphinx-autobuild -b html docs/source docs/build/html")) else: session.run( - *split("sphinx-build -nW --keep-going -b html docs/source docs/build/html") + *split("sphinx-build -nW --keep-going -b html docs/source docs/build/html"), ) @nox.session -def test(session): +def test(session: nox.Session) -> None: """Run the test suite.""" session.install("-e", ".") session.install("-r", "dev-requirements.txt") - session.run(*(["pytest"] + session.posargs)) + session.run("pytest", *session.posargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..96f1440 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[build-system] +requires = ["flit_core>=3.7"] +build-backend = "flit_core.buildapi" + +# project metadata +[project] +name = "sphinxext-opengraph" +description = "Sphinx Extension to enable OGP support" +readme = "README.md" +urls.Code = "https://github.com/wpilibsuite/sphinxext-opengraph/" +urls.Documentation = "https://sphinxext-opengraph.readthedocs.io/" +urls.Download = "https://pypi.org/project/sphinxext-opengraph/" +urls.Homepage = "https://github.com/wpilibsuite/sphinxext-opengraph/" +urls."Issue tracker" = "https://github.com/wpilibsuite/sphinxext-opengraph/issues" +license.text = "BSD-3-Clause" +requires-python = ">=3.9" + +# Classifiers list: https://pypi.org/classifiers/ +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Plugins", + "Environment :: Web Environment", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python", + "Topic :: Documentation :: Sphinx", + "Topic :: Documentation", + "Topic :: Software Development :: Documentation", + "Topic :: Text Processing", + "Topic :: Utilities", +] +dependencies = [ + "Sphinx>=6.0", +] +dynamic = ["version"] + +[project.optional-dependencies] +social_cards = [ + "matplotlib>=3", +] + +[[project.authors]] +name = "Itay Ziv" +email = "itay220204@gmail.com" + +[tool.flit.module] +name = "sphinxext.opengraph" + +[tool.flit.sdist] +include = [ + "LICENSE.md", + # Documentation + "docs/", + # Resources + "sphinxext/opengraph/_static/", + # Tests + "tests/", + "noxfile.py", +] +exclude = [ + "doc/_build", +] diff --git a/setup.py b/setup.py deleted file mode 100644 index be81e0d..0000000 --- a/setup.py +++ /dev/null @@ -1,43 +0,0 @@ -import setuptools - -with open("README.md", encoding="utf-8") as readme: - long_description = readme.read() - -setuptools.setup( - name="sphinxext-opengraph", - use_scm_version=True, - setup_requires=["setuptools_scm"], - author="Itay Ziv", - author_email="itay220204@gmail.com", - description="Sphinx Extension to enable OGP support", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/wpilibsuite/sphinxext-opengraph", - license="LICENSE.md", - install_requires=["sphinx>=5.0"], - packages=["sphinxext/opengraph"], - include_package_data=True, - package_data={"sphinxext.opengraph": ["sphinxext/opengraph/_static/*"]}, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Plugins", - "Environment :: Web Environment", - "Framework :: Sphinx :: Extension", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Natural Language :: English", - "Operating System :: OS Independent", - "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", - "Programming Language :: Python", - "Topic :: Documentation :: Sphinx", - "Topic :: Documentation", - "Topic :: Software Development :: Documentation", - "Topic :: Text Processing", - "Topic :: Utilities", - ], - python_requires=">=3.8", -) diff --git a/sphinxext/opengraph/__init__.py b/sphinxext/opengraph/__init__.py index 65f29c5..5e84467 100644 --- a/sphinxext/opengraph/__init__.py +++ b/sphinxext/opengraph/__init__.py @@ -1,25 +1,37 @@ -from typing import Any, Dict -from urllib.parse import urljoin, urlparse, urlunparse +from __future__ import annotations + +import os from pathlib import Path +from typing import TYPE_CHECKING +from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit + +from docutils import nodes + +from sphinxext.opengraph.descriptionparser import get_description +from sphinxext.opengraph.metaparser import get_meta_description +from sphinxext.opengraph.titleparser import get_title -import docutils.nodes as nodes -from sphinx.application import Sphinx +if TYPE_CHECKING: + from typing import Any -from .descriptionparser import get_description -from .metaparser import get_meta_description -from .titleparser import get_title + from sphinx.application import Sphinx + from sphinx.builders import Builder + from sphinx.config import Config + from sphinx.environment import BuildEnvironment + from sphinx.util.typing import ExtensionMetadata try: - import matplotlib + from sphinxext.opengraph.socialcards import ( + DEFAULT_SOCIAL_CONFIG, + create_social_card, + ) except ImportError: print("matplotlib is not installed, social cards will not be generated") create_social_card = None DEFAULT_SOCIAL_CONFIG = {} -else: - from .socialcards import create_social_card, DEFAULT_SOCIAL_CONFIG - -import os +__version__ = "0.9.1" +version_info = (0, 9, 1) DEFAULT_DESCRIPTION_LENGTH = 200 DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS = 160 @@ -47,10 +59,14 @@ def make_tag(property: str, content: str, type_: str = "property") -> str: def get_tags( - app: Sphinx, - context: Dict[str, Any], + context: dict[str, Any], doctree: nodes.document, - config: Dict[str, Any], + *, + srcdir: str | Path, + outdir: str | Path, + config: Config, + builder: Builder, + env: BuildEnvironment, ) -> str: # Get field lists for per-page overrides fields = context["meta"] @@ -66,58 +82,39 @@ def get_tags( # Set length of description try: desc_len = int( - fields.get("ogp_description_length", config["ogp_description_length"]) + fields.get("ogp_description_length", config.ogp_description_length) ) except ValueError: desc_len = DEFAULT_DESCRIPTION_LENGTH # Get the title and parse any html in it - title = get_title(context["title"], skip_html_tags=False) - title_excluding_html = get_title(context["title"], skip_html_tags=True) + title, title_excluding_html = get_title(context["title"]) # Parse/walk doctree for metadata (tag/description) - description = get_description(doctree, desc_len, [title, title_excluding_html]) + description = get_description(doctree, desc_len, {title, title_excluding_html}) # title tag tags["og:title"] = title # type tag - tags["og:type"] = config["ogp_type"] - - if os.getenv("READTHEDOCS") and not config["ogp_site_url"]: - # readthedocs uses html_baseurl for sphinx > 1.8 - parse_result = urlparse(config["html_baseurl"]) - - if config["html_baseurl"] is None: - raise OSError("ReadTheDocs did not provide a valid canonical URL!") - - # Grab root url from canonical url - config["ogp_site_url"] = urlunparse( - ( - parse_result.scheme, - parse_result.netloc, - parse_result.path, - "", - "", - "", - ) - ) + tags["og:type"] = config.ogp_type + + if not config.ogp_site_url and os.getenv("READTHEDOCS"): + config.ogp_site_url = read_the_docs_site_url(config.html_baseurl) # url tag # Get the URL of the specific page - page_url = urljoin( - config["ogp_site_url"], app.builder.get_target_uri(context["pagename"]) - ) + page_url = urljoin(config.ogp_site_url, builder.get_target_uri(context["pagename"])) tags["og:url"] = page_url # site name tag, False disables, default to project if ogp_site_name not # set. - if config["ogp_site_name"] is False: + if config.ogp_site_name is False: site_name = None - elif config["ogp_site_name"] is None: - site_name = config["project"] + elif config.ogp_site_name is None: + site_name = config.project else: - site_name = config["ogp_site_name"] + site_name = config.ogp_site_name if site_name: tags["og:site_name"] = site_name @@ -125,7 +122,7 @@ def get_tags( if description: tags["og:description"] = description - if config["ogp_enable_meta_description"] and not get_meta_description( + if config.ogp_enable_meta_description and not get_meta_description( context["metatags"] ): meta_tags["description"] = description @@ -138,49 +135,31 @@ def get_tags( ogp_image_alt = fields.get("og:image:alt") fields.pop("og:image", None) else: - image_url = config["ogp_image"] - ogp_use_first_image = config["ogp_use_first_image"] - ogp_image_alt = fields.get("og:image:alt", config["ogp_image_alt"]) + image_url = config.ogp_image + ogp_use_first_image = config.ogp_use_first_image + ogp_image_alt = fields.get("og:image:alt", config.ogp_image_alt) # Decide whether to add social media card images for each page. # Only do this as a fallback if the user hasn't given any configuration # to add other images. config_social = DEFAULT_SOCIAL_CONFIG.copy() - social_card_user_options = app.config.ogp_social_cards or {} + social_card_user_options = config.ogp_social_cards or {} config_social.update(social_card_user_options) if ( not (image_url or ogp_use_first_image) and config_social.get("enable") is not False and create_social_card is not None ): - # Description - description_max_length = config_social.get( - "description_max_length", DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3 - ) - if len(description) > description_max_length: - description = description[:description_max_length].strip() + "..." - - # Page title - pagetitle = title - if len(pagetitle) > DEFAULT_PAGE_LENGTH_SOCIAL_CARDS: - pagetitle = pagetitle[:DEFAULT_PAGE_LENGTH_SOCIAL_CARDS] + "..." - - # Site URL - site_url = config_social.get("site_url", True) - if site_url is True: - url_text = app.config.ogp_site_url.split("://")[-1] - elif isinstance(site_url, str): - url_text = site_url - - # Plot an image with the given metadata to the output path - image_path = create_social_card( - app, - config_social, - site_name, - pagetitle, - description, - url_text, - context["pagename"], + image_url = social_card_for_page( + config_social=config_social, + site_name=site_name, + title=title, + description=description, + pagename=context["pagename"], + srcdir=srcdir, + outdir=outdir, + config=config, + env=env, ) ogp_use_first_image = False @@ -190,12 +169,6 @@ def get_tags( else: ogp_image_alt = description - # Link the image in our page metadata - # We use os.path.sep to standardize behavior acros *nix and Windows - url = app.config.ogp_site_url.strip("/") - image_path = str(image_path).replace(os.path.sep, "/").strip("/") - image_url = f"{url}/{image_path}" - # If the social card objects have been added we add special metadata for them # These are the dimensions *in pixels* of the card # They were chosen by looking at the image pixel dimensions on disk @@ -224,12 +197,12 @@ def get_tags( image_url_parsed = urlparse(image_url) if not image_url_parsed.scheme: # Relative image path detected, relative to the source. Make absolute. - if first_image: + if first_image: # NoQA: SIM108 root = page_url else: # ogp_image is set # ogp_image is defined as being relative to the site root. # This workaround is to keep that functionality from breaking. - root = config["ogp_site_url"] + root = config.ogp_site_url image_url = urljoin(root, image_url_parsed.path) tags["og:image"] = image_url @@ -249,24 +222,101 @@ def get_tags( "\n".join( [make_tag(p, c) for p, c in tags.items()] + [make_tag(p, c, "name") for p, c in meta_tags.items()] - + config["ogp_custom_meta_tags"] + + config.ogp_custom_meta_tags ) + "\n" ) +def read_the_docs_site_url(html_baseurl: str | None) -> str: + # readthedocs addons sets the READTHEDOCS_CANONICAL_URL variable, + # or defines the ``html_baseurl`` variable in conf.py + if rtd_canonical_url := os.getenv("READTHEDOCS_CANONICAL_URL"): + parse_result = urlsplit(rtd_canonical_url) + elif html_baseurl is not None: + parse_result = urlsplit(html_baseurl) + else: + msg = "ReadTheDocs did not provide a valid canonical URL!" + raise RuntimeError(msg) + + # Grab root url from canonical url + return urlunsplit( + (parse_result.scheme, parse_result.netloc, parse_result.path, "", "") + ) + + +def social_card_for_page( + config_social: dict[str, bool | str], + site_name: str, + title: str, + description: str, + pagename: str, + *, + srcdir: str | Path, + outdir: str | Path, + config: Config, + env: BuildEnvironment, +) -> str: + # Description + description_max_length = config_social.get( + "description_max_length", DEFAULT_DESCRIPTION_LENGTH_SOCIAL_CARDS - 3 + ) + if len(description) > description_max_length: + description = description[:description_max_length].strip() + "..." + + # Page title + pagetitle = title + if len(pagetitle) > DEFAULT_PAGE_LENGTH_SOCIAL_CARDS: + pagetitle = pagetitle[:DEFAULT_PAGE_LENGTH_SOCIAL_CARDS] + "..." + + # Site URL + site_url = config_social.get("site_url", True) + if site_url is True: + url_text = config.ogp_site_url.split("://")[-1] + elif isinstance(site_url, str): + url_text = site_url + + # Plot an image with the given metadata to the output path + image_path = create_social_card( + config_social, + site_name, + pagetitle, + description, + url_text, + pagename, + srcdir=srcdir, + outdir=outdir, + config=config, + env=env, + ) + + # Link the image in our page metadata + # We use os.path.sep to standardize behavior acros *nix and Windows + url = config.ogp_site_url.strip("/") + image_path = str(image_path).replace(os.path.sep, "/").strip("/") + return f"{url}/{image_path}" + + def html_page_context( app: Sphinx, pagename: str, templatename: str, - context: Dict[str, Any], + context: dict[str, Any], doctree: nodes.document, ) -> None: if doctree: - context["metatags"] += get_tags(app, context, doctree, app.config) + context["metatags"] += get_tags( + context, + doctree, + srcdir=app.srcdir, + outdir=app.outdir, + config=app.config, + builder=app.builder, + env=app.env, + ) -def setup(app: Sphinx) -> Dict[str, Any]: +def setup(app: Sphinx) -> ExtensionMetadata: # ogp_site_url="" allows relative by default, even though it's not # officially supported by OGP. app.add_config_value("ogp_site_url", "", "html") @@ -284,6 +334,8 @@ def setup(app: Sphinx) -> Dict[str, Any]: app.connect("html-page-context", html_page_context) return { + "version": __version__, + "env_version": 1, "parallel_read_safe": True, "parallel_write_safe": True, } diff --git a/sphinxext/opengraph/descriptionparser.py b/sphinxext/opengraph/descriptionparser.py index f8eea29..429f7cb 100644 --- a/sphinxext/opengraph/descriptionparser.py +++ b/sphinxext/opengraph/descriptionparser.py @@ -1,35 +1,24 @@ +from __future__ import annotations + import string -from typing import Iterable +from typing import TYPE_CHECKING + +from docutils import nodes -import docutils.nodes as nodes +if TYPE_CHECKING: + from collections.abc import Set class DescriptionParser(nodes.NodeVisitor): - """ - Finds the title and creates a description from a doctree - """ + """Finds the title and creates a description from a doctree.""" def __init__( self, + document: nodes.document, + *, desc_len: int, - known_titles: Iterable[str] = None, - document: nodes.document = None, - ): - # Hack to prevent requirement for the doctree to be passed in. - # It's only used by doctree.walk(...) to print debug messages. - if document is None: - - class document_cls: - class reporter: - @staticmethod - def debug(*args, **kwaargs): - pass - - document = document_cls() - - if known_titles == None: - known_titles = [] - + known_titles: Set[str] = frozenset(), + ) -> None: super().__init__(document) self.description = "" self.desc_len = desc_len @@ -45,12 +34,8 @@ def dispatch_visit(self, node: nodes.Element) -> None: if self.stop: raise nodes.StopTraversal - # Skip comments - if isinstance(node, nodes.Invisible): - raise nodes.SkipNode - - # Skip all admonitions - if isinstance(node, nodes.Admonition): + # Skip comments & all admonitions + if isinstance(node, (nodes.Admonition, nodes.Invisible)): raise nodes.SkipNode # Mark start of nested lists @@ -115,9 +100,10 @@ def dispatch_departure(self, node: nodes.Element) -> None: def get_description( doctree: nodes.document, description_length: int, - known_titles: Iterable[str] = None, - document: nodes.document = None, -): - mcv = DescriptionParser(description_length, known_titles, document) + known_titles: Set[str] = frozenset(), +) -> str: + mcv = DescriptionParser( + doctree, desc_len=description_length, known_titles=known_titles + ) doctree.walkabout(mcv) return mcv.description diff --git a/sphinxext/opengraph/metaparser.py b/sphinxext/opengraph/metaparser.py index 77d06a4..85eed01 100644 --- a/sphinxext/opengraph/metaparser.py +++ b/sphinxext/opengraph/metaparser.py @@ -1,16 +1,16 @@ +from __future__ import annotations + from html.parser import HTMLParser class HTMLTextParser(HTMLParser): - """ - Parse HTML into text - """ + """Parse HTML into text.""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.meta_description = None - def handle_starttag(self, tag, attrs) -> None: + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: # For example: # attrs = [("content", "My manual description"), ("name", "description")] if ("name", "description") in attrs: diff --git a/sphinxext/opengraph/socialcards.py b/sphinxext/opengraph/socialcards.py index abf5ba0..65e88a3 100644 --- a/sphinxext/opengraph/socialcards.py +++ b/sphinxext/opengraph/socialcards.py @@ -1,13 +1,28 @@ """Build a PNG card for each page meant for social media.""" +from __future__ import annotations + import hashlib from pathlib import Path -import matplotlib -from matplotlib import pyplot as plt +from typing import TYPE_CHECKING + +import matplotlib as mpl +import matplotlib.font_manager import matplotlib.image as mpimg +from matplotlib import pyplot as plt from sphinx.util import logging -matplotlib.use("agg") +if TYPE_CHECKING: + from typing import TypeAlias + + from matplotlib.figure import Figure + from matplotlib.text import Text + from sphinx.config import Config + from sphinx.environment import BuildEnvironment + + PltObjects: TypeAlias = tuple[Figure, Text, Text, Text, Text] + +mpl.use("agg") LOGGER = logging.getLogger(__name__) HERE = Path(__file__).parent @@ -35,28 +50,38 @@ # They must be defined here otherwise Sphinx errors when trying to pickle them. # They are dependent on the `multiple` variable defined when the figure is created. # Because they are depending on the figure size and renderer used to generate them. -def _set_page_title_line_width(): +def _set_page_title_line_width() -> int: return 825 -def _set_description_line_width(): +def _set_description_line_width() -> int: return 1000 def create_social_card( - app, config_social, site_name, page_title, description, url_text, page_path -): + config_social: dict[str, bool | str], + site_name: str, + page_title: str, + description: str, + url_text: str, + page_path: str, + *, + srcdir: str | Path, + outdir: str | Path, + config: Config, + env: BuildEnvironment, +) -> Path: """Create a social preview card according to page metadata. This uses page metadata and calls a render function to generate the image. It also passes configuration through to the rendering function. If Matplotlib objects are present in the `app` environment, it reuses them. """ - # Add a hash to the image path based on metadata to bust caches - # ref: https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/troubleshooting-cards#refreshing_images # noqa + # ref: https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/troubleshooting-cards#refreshing_images hash = hashlib.sha1( - (site_name + page_title + description + str(config_social)).encode() + (site_name + page_title + description + str(config_social)).encode(), + usedforsecurity=False, ).hexdigest()[:8] # Define the file path we'll use for this image @@ -64,7 +89,7 @@ def create_social_card( filename_image = f"summary_{page_path.replace('/', '_')}_{hash}.png" # Absolute path used to save the image - path_images_absolute = Path(app.builder.outdir) / path_images_relative + path_images_absolute = Path(outdir) / path_images_relative path_images_absolute.mkdir(exist_ok=True, parents=True) path_image = path_images_absolute / filename_image @@ -72,22 +97,20 @@ def create_social_card( # This is because we hash the values of the text + images in the social card. # If the hash doesn't change, it means the output should be the same. if path_image.exists(): - return + return path_images_relative / filename_image # These kwargs are used to generate the base figure image - kwargs_fig = {} + kwargs_fig: dict[str, str | Path | None] = {} # Large image to the top right - if config_social.get("image"): - kwargs_fig["image"] = Path(app.builder.srcdir) / config_social.get("image") - elif app.config.html_logo: - kwargs_fig["image"] = Path(app.builder.srcdir) / app.config.html_logo + if cs_image := config_social.get("image"): + kwargs_fig["image"] = Path(srcdir) / cs_image + elif config.html_logo: + kwargs_fig["image"] = Path(srcdir) / config.html_logo # Mini image to the bottom right - if config_social.get("image_mini"): - kwargs_fig["image_mini"] = Path(app.builder.srcdir) / config_social.get( - "image_mini" - ) + if cs_image_mini := config_social.get("image_mini"): + kwargs_fig["image_mini"] = Path(srcdir) / cs_image_mini else: kwargs_fig["image_mini"] = ( Path(__file__).parent / "_static/sphinx-logo-shadow.png" @@ -101,12 +124,12 @@ def create_social_card( # If image is an SVG replace it with None if impath.suffix.lower() == ".svg": - LOGGER.warning(f"[Social card] %s cannot be an SVG image, skipping...", img) + LOGGER.warning("[Social card] %s cannot be an SVG image, skipping...", img) kwargs_fig[img] = None # If image doesn't exist, throw a warning and replace with none if not impath.exists(): - LOGGER.warning(f"[Social card]: %s file doesn't exist, skipping...", img) + LOGGER.warning("[Social card]: %s file doesn't exist, skipping...", img) kwargs_fig[img] = None # These are passed directly from the user configuration to our plotting function @@ -116,10 +139,12 @@ def create_social_card( kwargs_fig[config] = config_social.get(config) # Generate the image and store the matplotlib objects so that we can re-use them - if hasattr(app.env, "ogp_social_card_plt_objects"): - plt_objects = app.env.ogp_social_card_plt_objects - else: - plt_objects = None + try: + plt_objects = env.ogp_social_card_plt_objects + except AttributeError: + # If objects is None it means this is the first time plotting. + # Create the figure objects and return them so that we re-use them later. + plt_objects = create_social_card_objects(**kwargs_fig) plt_objects = render_social_card( path_image, site_name, @@ -127,37 +152,23 @@ def create_social_card( description, url_text, plt_objects, - kwargs_fig, ) - app.env.ogp_social_card_plt_objects = plt_objects + env.ogp_social_card_plt_objects = plt_objects # Path relative to build folder will be what we use for linking the URL - path_relative_to_build = path_images_relative / filename_image - return path_relative_to_build + return path_images_relative / filename_image def render_social_card( - path, - site_title=None, - page_title=None, - description=None, - siteurl=None, - plt_objects=None, - kwargs_fig=None, -): + path: Path, + site_title: str, + page_title: str, + description: str, + siteurl: str, + plt_objects: PltObjects, +) -> PltObjects: """Render a social preview card with Matplotlib and write to disk.""" - # If objects is None it means this is the first time plotting. - # Create the figure objects and return them so that we re-use them later. - if plt_objects is None: - ( - fig, - txt_site_title, - txt_page_title, - txt_description, - txt_url, - ) = create_social_card_objects(**kwargs_fig) - else: - fig, txt_site_title, txt_page_title, txt_description, txt_url = plt_objects + fig, txt_site_title, txt_page_title, txt_description, txt_url = plt_objects # Update the matplotlib text objects with new text from this page txt_site_title.set_text(site_title) @@ -171,16 +182,16 @@ def render_social_card( def create_social_card_objects( - image=None, - image_mini=None, - page_title_color="#2f363d", - description_color="#585e63", - site_title_color="#585e63", - site_url_color="#2f363d", - background_color="white", - line_color="#5A626B", - font=None, -): + image: Path | None = None, + image_mini: Path | None = None, + page_title_color: str = "#2f363d", + description_color: str = "#585e63", + site_title_color: str = "#585e63", + site_url_color: str = "#2f363d", + background_color: str = "white", + line_color: str = "#5A626B", + font: str | None = None, +) -> PltObjects: """Create the Matplotlib objects for the first time.""" # If no font specified, load the Roboto Flex font as a fallback if font is None: @@ -224,9 +235,7 @@ def create_social_card_objects( left_margin, site_title_y_offset, "Test site title", - { - "size": 24, - }, + {"size": 24}, ha="left", va="top", wrap=True, @@ -248,7 +257,7 @@ def create_social_card_objects( c=page_title_color, ) - txt_page._get_wrap_line_width = _set_page_title_line_width + txt_page._get_wrap_line_width = _set_page_title_line_width # NoQA: SLF001 # description # Just below site title, smallest font and many lines. @@ -268,7 +277,7 @@ def create_social_card_objects( wrap=True, c=description_color, ) - txt_description._get_wrap_line_width = _set_description_line_width + txt_description._get_wrap_line_width = _set_description_line_width # NoQA: SLF001 # url # Aligned to the left of the mini image diff --git a/sphinxext/opengraph/titleparser.py b/sphinxext/opengraph/titleparser.py index 9c2cce7..a2bcd84 100644 --- a/sphinxext/opengraph/titleparser.py +++ b/sphinxext/opengraph/titleparser.py @@ -1,12 +1,12 @@ +from __future__ import annotations + from html.parser import HTMLParser class HTMLTextParser(HTMLParser): - """ - Parse HTML into text - """ + """Parse HTML into text.""" - def __init__(self): + def __init__(self) -> None: super().__init__() # All text found self.text = "" @@ -14,24 +14,21 @@ def __init__(self): self.text_outside_tags = "" self.level = 0 - def handle_starttag(self, tag, attrs) -> None: + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: self.level += 1 - def handle_endtag(self, tag) -> None: + def handle_endtag(self, tag: str) -> None: self.level -= 1 - def handle_data(self, data) -> None: + def handle_data(self, data: str) -> None: self.text += data if self.level == 0: self.text_outside_tags += data -def get_title(title: str, skip_html_tags: bool = False): +def get_title(title: str) -> tuple[str, str]: htp = HTMLTextParser() htp.feed(title) htp.close() - if skip_html_tags: - return htp.text_outside_tags - else: - return htp.text + return htp.text, htp.text_outside_tags diff --git a/tests/conftest.py b/tests/conftest.py index 57ac73c..a5966d6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,22 +1,28 @@ -import pytest -from bs4 import BeautifulSoup -from sphinx.testing.path import path +from __future__ import annotations -from sphinx.application import Sphinx +from pathlib import Path +import pytest +import sphinx +from bs4 import BeautifulSoup -pytest_plugins = "sphinx.testing.fixtures" +pytest_plugins = ["sphinx.testing.fixtures"] @pytest.fixture(scope="session") def rootdir(): - return path(__file__).parent.abspath() / "roots" + if sphinx.version_info[:2] < (7, 2): + from sphinx.testing.path import path + + return path(__file__).parent.abspath() / "roots" + + return Path(__file__).parent.resolve() / "roots" -@pytest.fixture() +@pytest.fixture def content(app): - app.build() - yield app + app.build(force_all=True) + return app def _meta_tags(content, subdir=None): @@ -33,19 +39,19 @@ def _og_meta_tags(content): ] -@pytest.fixture() +@pytest.fixture def meta_tags(content): return _meta_tags(content) -@pytest.fixture() +@pytest.fixture def og_meta_tags(content): return [ tag for tag in _meta_tags(content) if tag.get("property", "").startswith("og:") ] -@pytest.fixture() +@pytest.fixture def og_meta_tags_sub(content): return [ tag diff --git a/tests/roots/test-arbitrary-tags/conf.py b/tests/roots/test-arbitrary-tags/conf.py index f7f742a..f9bfe69 100644 --- a/tests/roots/test-arbitrary-tags/conf.py +++ b/tests/roots/test-arbitrary-tags/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-custom-tags/conf.py b/tests/roots/test-custom-tags/conf.py index 1deded7..8a5faf6 100644 --- a/tests/roots/test-custom-tags/conf.py +++ b/tests/roots/test-custom-tags/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-description-length/conf.py b/tests/roots/test-description-length/conf.py index fabe335..88fe65f 100644 --- a/tests/roots/test-description-length/conf.py +++ b/tests/roots/test-description-length/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-double-spacing/conf.py b/tests/roots/test-double-spacing/conf.py index f7f742a..f9bfe69 100644 --- a/tests/roots/test-double-spacing/conf.py +++ b/tests/roots/test-double-spacing/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-first-image-no-image/conf.py b/tests/roots/test-first-image-no-image/conf.py index a492090..dcc8a2d 100644 --- a/tests/roots/test-first-image-no-image/conf.py +++ b/tests/roots/test-first-image-no-image/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-first-image/conf.py b/tests/roots/test-first-image/conf.py index a492090..dcc8a2d 100644 --- a/tests/roots/test-first-image/conf.py +++ b/tests/roots/test-first-image/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-image-rel-paths/conf.py b/tests/roots/test-image-rel-paths/conf.py index 88d2f22..461f3a1 100644 --- a/tests/roots/test-image-rel-paths/conf.py +++ b/tests/roots/test-image-rel-paths/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-image/conf.py b/tests/roots/test-image/conf.py index 8731182..d87074b 100644 --- a/tests/roots/test-image/conf.py +++ b/tests/roots/test-image/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-list/conf.py b/tests/roots/test-list/conf.py index f7f742a..f9bfe69 100644 --- a/tests/roots/test-list/conf.py +++ b/tests/roots/test-list/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-local-image/conf.py b/tests/roots/test-local-image/conf.py index c18a8d1..40c536d 100644 --- a/tests/roots/test-local-image/conf.py +++ b/tests/roots/test-local-image/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-meta-name-description-manual-description/conf.py b/tests/roots/test-meta-name-description-manual-description/conf.py index 8a6134e..27929d7 100644 --- a/tests/roots/test-meta-name-description-manual-description/conf.py +++ b/tests/roots/test-meta-name-description-manual-description/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-meta-name-description-manual-og-description/conf.py b/tests/roots/test-meta-name-description-manual-og-description/conf.py index 8a6134e..27929d7 100644 --- a/tests/roots/test-meta-name-description-manual-og-description/conf.py +++ b/tests/roots/test-meta-name-description-manual-og-description/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-meta-name-description/conf.py b/tests/roots/test-meta-name-description/conf.py index b31eaac..89ebeea 100644 --- a/tests/roots/test-meta-name-description/conf.py +++ b/tests/roots/test-meta-name-description/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-nested-lists/conf.py b/tests/roots/test-nested-lists/conf.py index f7f742a..f9bfe69 100644 --- a/tests/roots/test-nested-lists/conf.py +++ b/tests/roots/test-nested-lists/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-overrides-complex/conf.py b/tests/roots/test-overrides-complex/conf.py index e924f97..30ec7df 100644 --- a/tests/roots/test-overrides-complex/conf.py +++ b/tests/roots/test-overrides-complex/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-overrides-disable/conf.py b/tests/roots/test-overrides-disable/conf.py index 04b6d13..48df71c 100644 --- a/tests/roots/test-overrides-disable/conf.py +++ b/tests/roots/test-overrides-disable/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-overrides-simple/conf.py b/tests/roots/test-overrides-simple/conf.py index 04b6d13..48df71c 100644 --- a/tests/roots/test-overrides-simple/conf.py +++ b/tests/roots/test-overrides-simple/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-quotation-marks/conf.py b/tests/roots/test-quotation-marks/conf.py index 0087204..a0afe4f 100644 --- a/tests/roots/test-quotation-marks/conf.py +++ b/tests/roots/test-quotation-marks/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-rtd-default/conf.py b/tests/roots/test-rtd-default/conf.py index 7f99bb3..d51db6f 100644 --- a/tests/roots/test-rtd-default/conf.py +++ b/tests/roots/test-rtd-default/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-rtd-invalid/conf.py b/tests/roots/test-rtd-invalid/conf.py index 7f99bb3..d51db6f 100644 --- a/tests/roots/test-rtd-invalid/conf.py +++ b/tests/roots/test-rtd-invalid/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-simple/conf.py b/tests/roots/test-simple/conf.py index f7f742a..f9bfe69 100644 --- a/tests/roots/test-simple/conf.py +++ b/tests/roots/test-simple/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-sitename-from-project/conf.py b/tests/roots/test-sitename-from-project/conf.py index dc7805b..04c4bca 100644 --- a/tests/roots/test-sitename-from-project/conf.py +++ b/tests/roots/test-sitename-from-project/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] project = "Project name" diff --git a/tests/roots/test-sitename/conf.py b/tests/roots/test-sitename/conf.py index e3009f7..5827fc0 100644 --- a/tests/roots/test-sitename/conf.py +++ b/tests/roots/test-sitename/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-skip-admonitions/conf.py b/tests/roots/test-skip-admonitions/conf.py index f7f742a..f9bfe69 100644 --- a/tests/roots/test-skip-admonitions/conf.py +++ b/tests/roots/test-skip-admonitions/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-skip-code-block/conf.py b/tests/roots/test-skip-code-block/conf.py index 79ec2e8..c85dd57 100644 --- a/tests/roots/test-skip-code-block/conf.py +++ b/tests/roots/test-skip-code-block/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-skip-comments/conf.py b/tests/roots/test-skip-comments/conf.py index f7f742a..f9bfe69 100644 --- a/tests/roots/test-skip-comments/conf.py +++ b/tests/roots/test-skip-comments/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-skip-raw/conf.py b/tests/roots/test-skip-raw/conf.py index 79ec2e8..c85dd57 100644 --- a/tests/roots/test-skip-raw/conf.py +++ b/tests/roots/test-skip-raw/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-skip-title/conf.py b/tests/roots/test-skip-title/conf.py index f7f742a..f9bfe69 100644 --- a/tests/roots/test-skip-title/conf.py +++ b/tests/roots/test-skip-title/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-social-cards-svg/conf.py b/tests/roots/test-social-cards-svg/conf.py index 8582158..b59bec9 100644 --- a/tests/roots/test-social-cards-svg/conf.py +++ b/tests/roots/test-social-cards-svg/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/roots/test-type/conf.py b/tests/roots/test-type/conf.py index ecb20bd..cceccc0 100644 --- a/tests/roots/test-type/conf.py +++ b/tests/roots/test-type/conf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + extensions = ["sphinxext.opengraph"] master_doc = "index" diff --git a/tests/test_options.py b/tests/test_options.py index 529d133..87fa8bb 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -1,10 +1,17 @@ -import pytest -from sphinx.application import Sphinx +from __future__ import annotations + +from typing import TYPE_CHECKING + import conftest +import pytest +from sphinx.errors import ExtensionError + +if TYPE_CHECKING: + from sphinx.application import Sphinx def get_tag(tags, tag_type, kind="property", prefix="og"): - return [tag for tag in tags if tag.get(kind) == f"{prefix}:{tag_type}"][0] + return next(tag for tag in tags if tag.get(kind) == f"{prefix}:{tag_type}") def get_tag_content(tags, tag_type, kind="property", prefix="og"): @@ -13,9 +20,8 @@ def get_tag_content(tags, tag_type, kind="property", prefix="og"): def get_meta_description(tags): - return [tag for tag in tags if tag.get("name") == "description"][0].get( - "content", "" - ) + tag = next(tag for tag in tags if tag.get("name") == "description") + return tag.get("content", "") @pytest.mark.sphinx("html", testroot="simple") @@ -313,7 +319,7 @@ def test_rtd_invalid(app: Sphinx, monkeypatch): monkeypatch.setenv("READTHEDOCS", "True") app.config.html_baseurl = None - with pytest.raises(Exception): + with pytest.raises(ExtensionError, match="did not provide a valid canonical URL"): app.build()