diff --git a/.gitattributes b/.gitattributes index d0f6ad06464..c8acd10815a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -51,6 +51,8 @@ tests/roots/test-pycode/cp_1251_coded.py dos # Non UTF-8 encodings tests/roots/test-pycode/cp_1251_coded.py working-tree-encoding=windows-1251 +tests/roots/test-root/wrongenc.inc working-tree-encoding=latin-1 +tests/roots/test-warnings/wrongenc.inc working-tree-encoding=latin-1 # Generated files # https://github.com/github/linguist/blob/master/docs/overrides.md @@ -62,4 +64,5 @@ tests/roots/test-pycode/cp_1251_coded.py working-tree-encoding=windows-1251 tests/js/fixtures/**/*.js generated sphinx/search/minified-js/*.js generated +sphinx/search/_stopwords/ generated sphinx/themes/bizstyle/static/css3-mediaqueries.js generated diff --git a/.github/workflows/builddoc.yml b/.github/workflows/builddoc.yml index 7f8471deecb..aa982884afc 100644 --- a/.github/workflows/builddoc.yml +++ b/.github/workflows/builddoc.yml @@ -21,22 +21,22 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Install graphviz run: sudo apt-get install --no-install-recommends --yes graphviz - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install .[docs] + run: uv pip install . --group docs - name: Render the documentation run: > sphinx-build diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 6f3ebf264a8..8279f552fbe 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -27,31 +27,27 @@ jobs: attestations: write # for actions/attest id-token: write # for actions/attest & PyPI trusted publishing steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install build dependencies (pypa/build, twine) - run: | - uv pip install build "twine>=5.1" - # resolution fails without betterproto - uv pip install pypi-attestations==0.0.21 betterproto==2.0.0b6 + run: uv pip install --group package - name: Build distribution run: python -m build - name: Check distribution - run: | - twine check dist/* + run: twine check dist/* - name: Create Sigstore attestations for built distributions uses: actions/attest@v1 @@ -90,39 +86,10 @@ jobs: name: attestation-bundles path: /tmp/attestation-bundles/ - - name: Mint PyPI API token - id: mint-token - uses: actions/github-script@v7 - with: - # language=JavaScript - script: | - // retrieve the ambient OIDC token - const oidc_request_token = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; - const oidc_request_url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; - const oidc_resp = await fetch(`${oidc_request_url}&audience=pypi`, { - headers: {Authorization: `bearer ${oidc_request_token}`}, - }); - const oidc_token = (await oidc_resp.json()).value; - - // exchange the OIDC token for an API token - const mint_resp = await fetch('https://pypi.org/_/oidc/github/mint-token', { - method: 'post', - body: `{"token": "${oidc_token}"}` , - headers: {'Content-Type': 'application/json'}, - }); - const api_token = (await mint_resp.json()).token; - - // mask the newly minted API token, so that we don't accidentally leak it - core.setSecret(api_token) - core.setOutput('api-token', api_token) - - name: Upload to PyPI env: TWINE_NON_INTERACTIVE: "true" - TWINE_USERNAME: "__token__" - TWINE_PASSWORD: "${{ steps.mint-token.outputs.api-token }}" - run: | - twine upload dist/* --attestations + run: twine upload dist/* --attestations github-release: runs-on: ubuntu-latest @@ -132,12 +99,12 @@ jobs: permissions: contents: write # for softprops/action-gh-release to create GitHub release steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Get release version id: get_version - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: core.setOutput('version', context.ref.replace("refs/tags/v", "")) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a3b5cf7ae52..f4f950a05cc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false @@ -42,20 +42,20 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install ".[lint,test]" + run: uv pip install -r pyproject.toml --group package --group test --group types - name: Type check with mypy run: mypy @@ -63,20 +63,20 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install ".[lint,test]" + run: uv pip install -r pyproject.toml --group package --group test --group types - name: Type check with pyright run: pyright @@ -84,20 +84,20 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install --upgrade sphinx-lint + run: uv pip install --group lint - name: Lint documentation with sphinx-lint run: make doclinter @@ -105,21 +105,39 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install --upgrade twine build + run: uv pip install --group package - name: Lint with twine run: | python -m build . twine check dist/* + + prettier: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: "20" + cache: "npm" + - run: > + npx prettier@3.5 + --check + "sphinx/themes/**/*.js" + "!sphinx/themes/bizstyle/static/css3-mediaqueries*.js" + "tests/js/**/*.{js,mjs}" + "!tests/js/fixtures/**" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index e9d58e4896a..04831b72dcc 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -15,7 +15,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v8 with: retries: 3 # language=JavaScript diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1758254c633..26fa5fb5ab0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -37,16 +37,19 @@ jobs: - "3.11" - "3.12" - "3.13" + - "3.13t" + - "3.14" + - "3.14t" docutils: - "0.20" - - "0.21" -# include: -# # test every supported Docutils version for the latest supported Python -# - python: "3.13" -# docutils: "0.20" + - "0.22" + include: + # test every supported Docutils version for the latest supported Python + - python: "3.14" + docutils: "0.21" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Mount the test roots as read-only @@ -54,7 +57,7 @@ jobs: mkdir -p ./tests/roots-read-only sudo mount -v --bind --read-only ./tests/roots ./tests/roots-read-only - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} - name: Check Python version @@ -62,14 +65,18 @@ jobs: - name: Install graphviz run: sudo apt-get install --no-install-recommends --yes graphviz - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install .[test] + run: uv pip install . --group test + env: + UV_PYTHON: "python${{ matrix.python }}" - name: Install Docutils ${{ matrix.docutils }} run: uv pip install --upgrade "docutils~=${{ matrix.docutils }}.0" + env: + UV_PYTHON: "python${{ matrix.python }}" - name: Test with pytest run: python -m pytest -n logical --dist=worksteal -vv --durations 25 env: @@ -83,13 +90,13 @@ jobs: fail-fast: false matrix: python: - - "3.14" + - "3.15" docutils: - "0.20" - - "0.21" + - "0.22" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python ${{ matrix.python }} (deadsnakes) @@ -103,7 +110,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install .[test] + python -m pip install . --group test - name: Install Docutils ${{ matrix.docutils }} run: python -m pip install --upgrade "docutils~=${{ matrix.docutils }}.0" - name: Test with pytest @@ -111,38 +118,6 @@ jobs: env: PYTHONWARNINGS: "error" # treat all warnings as errors - free-threaded: - runs-on: ubuntu-latest - name: Python ${{ matrix.python }} (free-threaded) - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - python: - - "3.13" - - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Set up Python ${{ matrix.python }} (deadsnakes) - uses: deadsnakes/action@v3.2.0 - with: - python-version: ${{ matrix.python }} - nogil: true - - name: Check Python version - run: python --version --version - - name: Install graphviz - run: sudo apt-get install --no-install-recommends --yes graphviz - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install .[test] - - name: Test with pytest - run: python -m pytest -n logical --dist=worksteal -vv --durations 25 - env: - PYTHONWARNINGS: "error" # treat all warnings as errors - deadsnakes-free-threaded: runs-on: ubuntu-latest name: Python ${{ matrix.python }} (free-threaded) @@ -151,10 +126,10 @@ jobs: fail-fast: false matrix: python: - - "3.14" + - "3.15" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python ${{ matrix.python }} (deadsnakes) @@ -169,7 +144,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install .[test] + python -m pip install . --group test - name: Test with pytest run: python -m pytest -n logical --dist=worksteal -vv --durations 25 env: @@ -181,11 +156,19 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + # https://github.com/actions/runner-images/issues/8755 + # On standard runners, the D: drive is much faster. + - name: Set %TMP% and %TEMP% to D:\\Temp + run: | + mkdir "D:\\Tmp" + echo "TMP=D:\\Tmp" >> $env:GITHUB_ENV + echo "TEMP=D:\\Tmp" >> $env:GITHUB_ENV + + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Check Python version @@ -193,12 +176,12 @@ jobs: - name: Install graphviz run: choco install --no-progress graphviz - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install .[test] + run: uv pip install . --group test - name: Test with pytest run: python -m pytest -vv --durations 25 env: @@ -210,11 +193,11 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Check Python version @@ -222,12 +205,12 @@ jobs: - name: Install graphviz run: brew install graphviz - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install .[test] + run: uv pip install . --group test - name: Test with pytest run: python -m pytest -vv --durations 25 env: @@ -245,11 +228,11 @@ jobs: mkdir /tmp/epubcheck && cd /tmp/epubcheck wget --no-verbose https://github.com/w3c/epubcheck/releases/download/v${EPUBCHECK_VERSION}/epubcheck-${EPUBCHECK_VERSION}.zip unzip epubcheck-${EPUBCHECK_VERSION}.zip - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Check Python version @@ -257,12 +240,12 @@ jobs: - name: Install graphviz run: sudo apt-get install --no-install-recommends --yes graphviz - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install .[test] + run: uv pip install . --group test - name: Install Docutils' HEAD run: uv pip install "docutils @ git+https://repo.or.cz/docutils.git#subdirectory=docutils" - name: Test with pytest @@ -278,11 +261,11 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Check Python version @@ -290,13 +273,13 @@ jobs: - name: Install graphviz run: sudo apt-get install --no-install-recommends --yes graphviz - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies run: | - uv pip install .[test] --resolution lowest-direct + uv pip install . --group test --resolution lowest-direct uv pip install alabaster==1.0.0 - name: Test with pytest run: python -m pytest -n logical --dist=worksteal -vv --durations 25 @@ -311,22 +294,24 @@ jobs: image: ghcr.io/sphinx-doc/sphinx-ci steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Check Python version run: python --version --version - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install .[test] + run: uv pip install . --group test + - name: Install Docutils' HEAD + run: uv pip install "docutils @ git+https://repo.or.cz/docutils.git#subdirectory=docutils" - name: Test with pytest run: python -m pytest -vv --durations 25 env: @@ -340,11 +325,11 @@ jobs: timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Check Python version @@ -352,12 +337,12 @@ jobs: - name: Install graphviz run: sudo apt-get install --no-install-recommends --yes graphviz - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install .[test] pytest-cov + run: uv pip install . --group test pytest-cov - name: Test with pytest run: python -m pytest -vv --cov . --cov-append --cov-config pyproject.toml env: diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 84727288fde..267c5e6372a 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -33,11 +33,11 @@ jobs: node-version: "20" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Use Node.js ${{ env.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{ env.node-version }} cache: "npm" diff --git a/.github/workflows/transifex.yml b/.github/workflows/transifex.yml index 09437cb7ece..af89ce146f7 100644 --- a/.github/workflows/transifex.yml +++ b/.github/workflows/transifex.yml @@ -23,11 +23,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Install transifex client @@ -36,12 +36,12 @@ jobs: curl -o- https://raw.githubusercontent.com/transifex/cli/master/install.sh | bash shell: bash - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install --upgrade babel jinja2 + run: uv pip install --group translations - name: Extract translations from source code run: python utils/babel_runner.py extract - name: Push translations to transifex.com @@ -59,11 +59,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3" - name: Install transifex client @@ -72,12 +72,12 @@ jobs: curl -o- https://raw.githubusercontent.com/transifex/cli/master/install.sh | bash shell: bash - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v6 with: version: latest enable-cache: false - name: Install dependencies - run: uv pip install --upgrade babel jinja2 + run: uv pip install --group translations - name: Extract translations from source code run: python utils/babel_runner.py extract - name: Pull translations from transifex.com diff --git a/.gitignore b/.gitignore index 35fd23178f5..5a50535097e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.so *.swp +.auto/ .dir-locals.el .cache/ .idea diff --git a/.prettierrc.toml b/.prettierrc.toml new file mode 100644 index 00000000000..1799612bfdd --- /dev/null +++ b/.prettierrc.toml @@ -0,0 +1,2 @@ +# https://prettier.io/docs/options +experimentalOperatorPosition = "start" diff --git a/.ruff.toml b/.ruff.toml index f82928eca65..8011e7ffc55 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -9,6 +9,9 @@ extend-exclude = [ "tests/roots/test-pycode/cp_1251_coded.py", # Not UTF-8 ] +[per-file-target-version] +"tests/roots/test-ext-autodoc/target/pep695.py" = "py312" + [format] preview = true quote-style = "single" diff --git a/AUTHORS.rst b/AUTHORS.rst index ff92ab7eab7..707c77aec04 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -63,6 +63,7 @@ Contributors * Hong Xu -- svg support in imgmath extension and various bug fixes * Horst Gutmann -- internationalization support * Hugo van Kemenade -- support FORCE_COLOR and NO_COLOR +* Ian Hunt-Isaak -- typealias reference improvement * Ian Lee -- quickstart improvements * Jacob Mason -- websupport library (GSOC project) * James Addison -- linkcheck and HTML search improvements @@ -83,10 +84,12 @@ Contributors * Louis Maddox -- better docstrings * Łukasz Langa -- partial support for autodoc * Marco Buttu -- doctest extension (pyversion option) +* Mark Ostroth -- semantic HTML contributions * Martin Hans -- autodoc improvements * Martin Larralde -- additional napoleon admonitions * Martin Liška -- option directive and role improvements * Martin Mahner -- nature theme +* Martin Matouš -- initial support for PEP 695 * Matthew Fernandez -- todo extension fix * Matthew Woodcraft -- text output improvements * Matthias Geier -- style improvements @@ -102,11 +105,16 @@ Contributors * Slawek Figiel -- additional warning suppression * Stefan Seefeld -- toctree improvements * Stefan van der Walt -- autosummary extension +* Steve Piercy -- documentation improvements +* Szymon Karpinski -- intersphinx improvements * \T. Powers -- HTML output improvements * Taku Shimizu -- epub3 builder +* Tamika Nomara -- bug fixes * Thomas Lamb -- linkcheck builder * Thomas Waldmann -- apidoc module fixes +* Till Hoffmann -- doctest option to exit after first failed test * Tim Hoffmann -- theme improvements +* Victor Wheeler -- documentation improvements * Vince Salvino -- JavaScript search improvements * Will Maier -- directory HTML builder * Zac Hatfield-Dodds -- doctest reporting improvements, intersphinx performance diff --git a/CHANGES.rst b/CHANGES.rst index c257b3b11b1..cd36d83957b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,17 +4,143 @@ Release 8.3.0 (in development) Dependencies ------------ +* #13786: Support `Docutils 0.22`_. Patch by Adam Turner. + + .. _Docutils 0.22: https://docutils.sourceforge.io/RELEASE-NOTES.html#release-0-22-2026-07-29 + Incompatible changes -------------------- +* #13639: :py:meth:`!SphinxComponentRegistry.create_source_parser` no longer + has an *app* parameter, instead taking *config* and *env*. + Patch by Adam Turner. + Deprecated ---------- +* 13627: Deprecate remaining public :py:attr:`!.app` attributes, + including ``builder.app``, ``env.app``, ``events.app``, + and ``SphinxTransform.`app``. + Patch by Adam Turner. +* #13637: Deprecate the :py:meth:`!set_application` method + of :py:class:`~sphinx.parsers.Parser` objects. + Patch by Adam Turner. +* #13644: Deprecate the :py:attr:`!Parser.config` and :py:attr:`!env` attributes. + Patch by Adam Turner. +* #13665: Deprecate support for non-UTF 8 source encodings, + scheduled for removal in Sphinx 10. + Patch by Adam Turner. +* #13679: Non-decodable characters in source files will raise an error in Sphinx 9. + Currently, such bytes are replaced with '?' along with logging a warning. + Patch by Adam Turner. +* #13682: Deprecate :py:mod:`!sphinx.io`. + Sphinx no longer uses the :py:mod:`!sphinx.io` classes, + having replaced them with standard Python I/O. + The entire :py:mod:`!sphinx.io` module will be removed in Sphinx 10. + Patch by Adam Turner. + Features added -------------- +* #13332: Add :confval:`doctest_fail_fast` option to exit after the first failed + test. + Patch by Till Hoffmann. +* #13439: linkcheck: Permit warning on every redirect with + ``linkcheck_allowed_redirects = {}``. + Patch by Adam Turner and James Addison. +* #13497: Support C domain objects in the table of contents. +* #13500: LaTeX: add support for ``fontawesome6`` package. + Patch by Jean-François B. +* #13509: autodoc: Detect :py:func:`typing_extensions.overload ` + and :py:func:`~typing.final` decorators. + Patch by Spencer Brown. +* #13535: html search: Update to the latest version of Snowball (v3.0.1). + Patch by Adam Turner. +* #13647: LaTeX: allow more cases of table nesting. + Patch by Jean-François B. +* #13657: LaTeX: support CSS3 length units. + Patch by Jean-François B. +* #13684: intersphinx: Add a file-based cache for remote inventories. + The location of the cache directory must not be relied upon externally, + as it may change without notice or warning in future releases. + Patch by Adam Turner. +* #13805: LaTeX: add support for ``fontawesome7`` package. + Patch by Jean-François B. +* #13508: Initial support for :pep:`695` type aliases. + Patch by Martin Matouš, Jeremy Maitin-Shepard, and Adam Turner. + Bugs fixed ---------- +* #13926: multiple py:type directives for the same canonical type no + longer result in spurious duplicate object description warnings. + Patch by Jeremy Maitin-Shepard. +* #1327: LaTeX: tables using longtable raise error if + :rst:dir:`tabularcolumns` specifies automatic widths + (``L``, ``R``, ``C``, or ``J``). + Patch by Jean-François B. +* #3447: LaTeX: when assigning longtable class to table for PDF, it may render + "horizontally" and overflow in right margin. + Patch by Jean-François B. +* #8828: LaTeX: adding a footnote to a longtable cell causes table to occupy + full width. + Patch by Jean-François B. +* #11498: LaTeX: Table in cell fails to build if it has many rows. + Patch by Jean-François B. +* #11515: LaTeX: longtable does not allow nested table. + Patch by Jean-François B. +* #11973: LaTeX: links in table captions do not work in PDF. + Patch by Jean-François B. +* #12821: LaTeX: URLs/links in section titles should render in PDF. + Patch by Jean-François B. +* #13369: Correctly parse and cross-reference unpacked type annotations. + Patch by Alicia Garcia-Raboso. +* #13528: Add tilde ``~`` prefix support for :rst:role:`py:deco`. + Patch by Shengyu Zhang and Adam Turner. +* #13597: LaTeX: table nested in a merged cell leads to invalid LaTeX mark-up + and PDF cannot be built. + Patch by Jean-François B. +* #13619: LaTeX: possible duplicated footnotes in PDF from object signatures + (typically if :confval:`latex_show_urls` ``= 'footnote'``). + Patch by Jean-François B. +* #13635: LaTeX: if a cell contains a table, row coloring is turned off for + the next table cells. + Patch by Jean-François B. +* #13685: gettext: Correctly ignore trailing backslashes. + Patch by Bénédikt Tran. +* #13712: intersphinx: Don't add "v" prefix to non-numeric versions. + Patch by Szymon Karpinski. +* #13688: HTML builder: Replace ```` with + ```` for attribute type annotations + to improve `semantic HTML structure + `__. + Patch by Mark Ostroth. +* #13812 (discussion): LaTeX: long :rst:dir:`confval` value does not wrap at + spaces in PDF. + Patch by Jean-François B. +* #10785: Autodoc: Allow type aliases defined in the project to be properly + cross-referenced when used as type annotations. This makes it possible + for objects documented as ``:py:data:`` to be hyperlinked in function signatures. +* #13858: doctest: doctest blocks are now correctly added to a group defined by the + configuration variable ``doctest_test_doctest_blocks``. +* #13885: Coverage builder: Fix TypeError when warning about missing modules. + Patch by Damien Ayers. +* #13929: Duplicate equation label warnings now have a new warning + sub-type, ``ref.equation``. + Patch by Jared Dillard. +* #13935: autoclass: parent class members no longer considered + directly defined in certain cases, depending on autodoc processing + order. + Patch by Jeremy Maitin-Shepard. +* #13939: LaTeX: page break can separate admonition title from contents. + Patch by Jean-François B. +* #14004: Fix :confval:`autodoc_type_aliases` when they appear in PEP 604 + union syntax (``Alias | Type``). + Patch by Tamika Nomara. +* #14059: LaTeX: Footnotes cause pdflatex error with French language + (since late June 2025 upstream change to LaTeX ``babel-french``). + Patch by Jean-François B. + + Testing ------- diff --git a/doc/changes/5.3.rst b/doc/changes/5.3.rst index b2a2e5a78f1..171b0792bbe 100644 --- a/doc/changes/5.3.rst +++ b/doc/changes/5.3.rst @@ -8,7 +8,10 @@ Release 5.3.0 (released Oct 16, 2022) * #10759: LaTeX: add :confval:`latex_table_style` and support the ``'booktabs'``, ``'borderless'``, and ``'colorrows'`` styles. - (thanks to Stefan Wiehler for initial pull requests #6666, #6671) + (thanks to Stefan Wiehler for initial pull requests #6666, #6671). + Using the ``'booktabs'`` style solves #6740 (Removing LaTeX + column borders for automatic colspec). + Patch by Jean-François B. * #10840: One can cross-reference including an option value like ``:option:`--module=foobar```, ``:option:`--module[=foobar]```, or ``:option:`--module foobar```. diff --git a/doc/changes/7.3.rst b/doc/changes/7.3.rst index b544a722041..c9395c18c4a 100644 --- a/doc/changes/7.3.rst +++ b/doc/changes/7.3.rst @@ -86,7 +86,7 @@ Dependencies * #11858: Increase the minimum supported version of Alabaster to 0.7.14. Patch by Adam Turner. -* #11411: Support `Docutils 0.21`_. Patch by Adam Turner. +* #12267: Support `Docutils 0.21`_. Patch by Adam Turner. .. _Docutils 0.21: https://docutils.sourceforge.io/RELEASE-NOTES.html#release-0-21-2024-04-09 * #12012: Use ``types-docutils`` instead of ``docutils-stubs``. diff --git a/doc/conf.py b/doc/conf.py index 9cf2f9b4856..ef28f92ff1a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -185,7 +185,11 @@ ('js:func', 'number'), ('js:func', 'string'), ('py:attr', 'srcline'), + # sphinx.application.Sphinx.connect ('py:class', '_AutodocProcessDocstringListener'), + # sphinx.application.Sphinx.connect + ('py:class', '_AutodocProcessSignatureListener'), + ('py:class', '_AutodocSkipMemberListener'), # sphinx.application.Sphinx.connect ('py:class', '_ConfigRebuild'), # sphinx.application.Sphinx.add_config_value # sphinx.application.Sphinx.add_html_math_renderer ('py:class', '_MathsBlockRenderers'), @@ -231,6 +235,7 @@ ('py:class', 'pygments.lexer.Lexer'), ('py:class', 'sphinx.directives.ObjDescT'), ('py:class', 'sphinx.domains.IndexEntry'), + # sphinx.application.Sphinx.add_autodocumenter ('py:class', 'sphinx.ext.autodoc.Documenter'), ('py:class', 'sphinx.errors.NoUri'), ('py:class', 'sphinx.roles.XRefRole'), @@ -297,14 +302,12 @@ def linkify_issues_in_changelog( ) -> None: """Linkify issue references like #123 in changelog to GitHub.""" if docname == 'changes': + linkified_changelog = re.sub(r'(?:PR)?#([0-9]+)\b', _linkify, source[0]) + source[0] = linkified_changelog - def linkify(match: re.Match[str]) -> str: - url = 'https://github.com/sphinx-doc/sphinx/issues/' + match[1] - return f'`{match[0]} <{url}>`_' - - linkified_changelog = re.sub(r'(?:PR)?#([0-9]+)\b', linkify, source[0]) - source[0] = linkified_changelog +def _linkify(match: re.Match[str], /) -> str: + return f'`{match[0]} `__' REDIRECT_TEMPLATE = """ diff --git a/doc/development/html_themes/templating.rst b/doc/development/html_themes/templating.rst index e7c1d11f453..77b43882f86 100644 --- a/doc/development/html_themes/templating.rst +++ b/doc/development/html_themes/templating.rst @@ -6,6 +6,32 @@ Templating ========== +What Is Templating? +------------------- + +Templating is a method of generating HTML pages by combining static templates +with variable data. +The template files contain the static parts of the desired HTML output +and include special syntax describing how variable content will be inserted. +For example, this can be used to insert the current date in the footer of each page, +or to surround the main content of the document with a scaffold of HTML for layout +and formatting purposes. +Doing so only requires an understanding of HTML and the templating syntax. +Knowledge of Python can be helpful, but is not required. + +Templating uses an inheritance mechanism which allows child templates files +(e.g. in a theme) to override as much (or as little) of their 'parents' as desired. +Likewise, content authors can use their own local templates to override as much (or +as little) of the theme templates as desired. + +The result is that the Sphinx core, without needing to be changed, provides basic +HTML generation, independent of the structure and appearance of the final output, +while granting a great deal of flexibility to theme and content authors. + + +Sphinx Templating +----------------- + Sphinx uses the `Jinja `_ templating engine for its HTML templates. Jinja is a text-based engine, inspired by Django templates, so anyone having used Django will already be familiar with it. It diff --git a/doc/development/tutorials/examples/autodoc_intenum.py b/doc/development/tutorials/examples/autodoc_intenum.py index 2dd8d6324e6..bb36ea0e6bf 100644 --- a/doc/development/tutorials/examples/autodoc_intenum.py +++ b/doc/development/tutorials/examples/autodoc_intenum.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from sphinx.ext.autodoc import ClassDocumenter, bool_option +from sphinx.ext.autodoc._generate import _docstring_source_name if TYPE_CHECKING: from typing import Any @@ -11,47 +12,53 @@ from docutils.statemachine import StringList from sphinx.application import Sphinx + from sphinx.ext.autodoc import Documenter from sphinx.util.typing import ExtensionMetadata class IntEnumDocumenter(ClassDocumenter): objtype = 'intenum' directivetype = ClassDocumenter.objtype - priority = 10 + ClassDocumenter.priority + priority = 25 option_spec = dict(ClassDocumenter.option_spec) option_spec['hex'] = bool_option @classmethod def can_document_member( - cls, member: Any, membername: str, isattr: bool, parent: Any + cls, member: Any, membername: str, isattr: bool, parent: Documenter ) -> bool: try: return issubclass(member, IntEnum) except TypeError: return False - def add_directive_header(self, sig: str) -> None: - super().add_directive_header(sig) - self.add_line(' :final:', self.get_sourcename()) + def add_line(self, line: str, source: str = '', *lineno: int, indent: str) -> None: + """Append one line of generated reST to the output.""" + analyzer_source = '' if self.analyzer is None else self.analyzer.srcname + source_name = _docstring_source_name(props=self.props, source=analyzer_source) + if line.strip(): # not a blank line + self.result.append(indent + line, source_name, *lineno) + else: + self.result.append('', source_name, *lineno) - def add_content( - self, - more_content: StringList | None, - ) -> None: - super().add_content(more_content) + def add_directive_header(self, *, indent: str) -> None: + super().add_directive_header(indent=indent) + self.add_line(' :final:', indent=indent) - source_name = self.get_sourcename() - enum_object: IntEnum = self.object + def add_content(self, more_content: StringList | None, *, indent: str) -> None: + super().add_content(more_content, indent=indent) + + enum_object: IntEnum = self.props._obj use_hex = self.options.hex - self.add_line('', source_name) + self.add_line('', indent=indent) for the_member_name, enum_member in enum_object.__members__.items(): # type: ignore[attr-defined] the_member_value = enum_member.value if use_hex: the_member_value = hex(the_member_value) - self.add_line(f'**{the_member_name}**: {the_member_value}', source_name) - self.add_line('', source_name) + self.add_line(f'**{the_member_name}**: {the_member_value}', indent=indent) + self.add_line('', indent=indent) def setup(app: Sphinx) -> ExtensionMetadata: diff --git a/doc/development/tutorials/examples/recipe.py b/doc/development/tutorials/examples/recipe.py index 9848629216a..da52fa2df67 100644 --- a/doc/development/tutorials/examples/recipe.py +++ b/doc/development/tutorials/examples/recipe.py @@ -165,7 +165,7 @@ def add_recipe(self, signature, ingredients): name, signature, 'Recipe', - self.env.docname, + self.env.current_document.docname, anchor, 0, )) diff --git a/doc/development/tutorials/examples/todo.py b/doc/development/tutorials/examples/todo.py index a8aa1ec4a1d..c9993eda198 100644 --- a/doc/development/tutorials/examples/todo.py +++ b/doc/development/tutorials/examples/todo.py @@ -44,7 +44,7 @@ def run(self): self.env.todo_all_todos = [] self.env.todo_all_todos.append({ - 'docname': self.env.docname, + 'docname': self.env.current_document.docname, 'lineno': self.lineno, 'todo': todo_node.deepcopy(), 'target': targetnode, diff --git a/doc/development/tutorials/extending_build.rst b/doc/development/tutorials/extending_build.rst index 4d3606a0a33..9894d656fed 100644 --- a/doc/development/tutorials/extending_build.rst +++ b/doc/development/tutorials/extending_build.rst @@ -143,7 +143,7 @@ Looking first at the ``TodolistDirective`` directive: .. literalinclude:: examples/todo.py :language: python :linenos: - :lines: 24-27 + :pyobject: TodolistDirective It's very simple, creating and returning an instance of our ``todolist`` node class. The ``TodolistDirective`` directive itself has neither content nor @@ -153,7 +153,7 @@ directive: .. literalinclude:: examples/todo.py :language: python :linenos: - :lines: 30-53 + :pyobject: TodoDirective Several important things are covered here. First, as you can see, we're now subclassing the :class:`~sphinx.util.docutils.SphinxDirective` helper class @@ -168,16 +168,16 @@ new unique integer on each call and therefore leads to unique target names. The target node is instantiated without any text (the first two arguments). On creating admonition node, the content body of the directive are parsed using -``self.state.nested_parse``. The first argument gives the content body, and -the second one gives content offset. The third argument gives the parent node -of parsed result, in our case the ``todo`` node. Following this, the ``todo`` -node is added to the environment. This is needed to be able to create a list of -all todo entries throughout the documentation, in the place where the author -puts a ``todolist`` directive. For this case, the environment attribute -``todo_all_todos`` is used (again, the name should be unique, so it is prefixed -by the extension name). It does not exist when a new environment is created, so -the directive must check and create it if necessary. Various information about -the todo entry's location are stored along with a copy of the node. +``self.parse_content_to_nodes()``. +Following this, the ``todo`` node is added to the environment. +This is needed to be able to create a list of all todo entries throughout +the documentation, in the place where the author puts a ``todolist`` directive. +For this case, the environment attribute ``todo_all_todos`` is used +(again, the name should be unique, so it is prefixed by the extension name). +It does not exist when a new environment is created, so the directive must +check and create it if necessary. +Various information about the todo entry's location are stored along with +a copy of the node. In the last line, the nodes that should be put into the doctree are returned: the target node and the admonition node. @@ -211,7 +211,7 @@ the :event:`env-purge-doc` event: .. literalinclude:: examples/todo.py :language: python :linenos: - :lines: 56-61 + :pyobject: purge_todos Since we store information from source files in the environment, which is persistent, it may become out of date when the source file changes. Therefore, @@ -229,7 +229,7 @@ to be merged: .. literalinclude:: examples/todo.py :language: python :linenos: - :lines: 64-68 + :pyobject: merge_todos The other handler belongs to the :event:`doctree-resolved` event: @@ -237,12 +237,13 @@ The other handler belongs to the :event:`doctree-resolved` event: .. literalinclude:: examples/todo.py :language: python :linenos: - :lines: 71-113 + :pyobject: process_todo_nodes -The :event:`doctree-resolved` event is emitted at the end of :ref:`phase 3 -(resolving) ` and allows custom resolving to be done. The handler -we have written for this event is a bit more involved. If the -``todo_include_todos`` config value (which we'll describe shortly) is false, +The :event:`doctree-resolved` event is emitted for each document that is +about to be written at the end of :ref:`phase 3 (resolving) ` +and allows custom resolving to be done on that document. +The handler we have written for this event is a bit more involved. +If the ``todo_include_todos`` config value (which we'll describe shortly) is false, all ``todo`` and ``todolist`` nodes are removed from the documents. If not, ``todo`` nodes just stay where and how they are. ``todolist`` nodes are replaced by a list of todo entries, complete with backlinks to the location @@ -266,17 +267,17 @@ the other parts of our extension. Let's look at our ``setup`` function: .. literalinclude:: examples/todo.py :language: python :linenos: - :lines: 116- + :pyobject: setup The calls in this function refer to the classes and functions we added earlier. What the individual calls do is the following: * :meth:`~Sphinx.add_config_value` lets Sphinx know that it should recognize the - new *config value* ``todo_include_todos``, whose default value should be - ``False`` (this also tells Sphinx that it is a boolean value). + new *config value* ``todo_include_todos``, whose default value is ``False`` + (which also tells Sphinx that it is a boolean value). - If the third argument was ``'html'``, HTML documents would be full rebuild if the - config value changed its value. This is needed for config values that + If the third argument was ``'html'``, HTML documents would be fully rebuilt + if the config value changed its value. This is needed for config values that influence reading (build :ref:`phase 1 (reading) `). * :meth:`~Sphinx.add_node` adds a new *node class* to the build system. It also diff --git a/doc/extdev/deprecated.rst b/doc/extdev/deprecated.rst index ad05b054d99..484f52cb7e7 100644 --- a/doc/extdev/deprecated.rst +++ b/doc/extdev/deprecated.rst @@ -22,6 +22,36 @@ The following is a list of deprecated interfaces. - Removed - Alternatives + * - ``sphinx.io`` (entire module) + - 8.3 + - 10.0 + - ``docutils.io`` or standard Python I/O + + * - ``sphinx.builders.Builder.app`` + - 8.3 + - 10.0 + - N/A + + * - ``sphinx.environment.BuildEnvironment.app`` + - 8.3 + - 10.0 + - N/A + + * - ``sphinx.transforms.Transform.app`` + - 8.3 + - 10.0 + - N/A + + * - ``sphinx.transforms.post_transforms.SphinxPostTransform.app`` + - 8.3 + - 10.0 + - N/A + + * - ``sphinx.events.EventManager.app`` + - 8.3 + - 10.0 + - N/A + * - ``sphinx.builders.singlehtml.SingleFileHTMLBuilder.fix_refuris`` - 8.2 - 10.0 diff --git a/doc/extdev/event_callbacks.rst b/doc/extdev/event_callbacks.rst index 04eae51be1d..aec9a47e848 100644 --- a/doc/extdev/event_callbacks.rst +++ b/doc/extdev/event_callbacks.rst @@ -70,8 +70,8 @@ Below is an overview of the core event that happens during a build. 14. apply post-transforms (by priority): docutils.document -> docutils.document 15. event.doctree-resolved(app, doctree, docname) - In the event that any reference nodes fail to resolve, the following may emit: - - event.missing-reference(env, node, contnode) - - event.warn-missing-reference(domain, node) + - event.missing-reference(app, env, node, contnode) + - event.warn-missing-reference(app, domain, node) 16. Generate output files 17. event.build-finished(app, exception) diff --git a/doc/extdev/markupapi.rst b/doc/extdev/markupapi.rst index 7aa632446da..184bd2bd8e4 100644 --- a/doc/extdev/markupapi.rst +++ b/doc/extdev/markupapi.rst @@ -173,9 +173,9 @@ The methods are used as follows: def run(self) -> list[Node]: container = docutils.nodes.Element() # either - nested_parse_with_titles(self.state, self.result, container) + nested_parse_with_titles(self.state, self.result, container, self.content_offset) # or - self.state.nested_parse(self.result, 0, container) + self.state.nested_parse(self.result, self.content_offset, container) parsed = container.children return parsed diff --git a/doc/internals/contributing.rst b/doc/internals/contributing.rst index 4b8ca84a945..90d7600866d 100644 --- a/doc/internals/contributing.rst +++ b/doc/internals/contributing.rst @@ -138,6 +138,10 @@ These are the basic steps needed to start developing on Sphinx. #. Wait for a core developer or contributor to review your changes. + You may be asked to address comments on the review. If so, please avoid + force pushing to the branch. Sphinx uses the *squash merge* strategy when + merging PRs, so follow-up commits will all be combined. + Coding style ~~~~~~~~~~~~ @@ -201,7 +205,7 @@ You can also test by installing dependencies in your local environment: .. code-block:: shell - pip install .[test] + pip install . --group test To run JavaScript tests, use :program:`npm`: @@ -337,13 +341,15 @@ Updating generated files ------------------------ * JavaScript stemming algorithms in :file:`sphinx/search/non-minified-js/*.js` - are generated using `snowball `_ - by cloning the repository, executing ``make dist_libstemmer_js`` and then - unpacking the tarball which is generated in :file:`dist` directory. + and stopword files in :file:`sphinx/search/_stopwords/` + are generated from the `Snowball project`_ + by running :file:`utils/generate_snowball.py`. Minified files in :file:`sphinx/search/minified-js/*.js` are generated from - non-minified ones using :program:`uglifyjs` (installed via npm), with ``-m`` - option to enable mangling. + non-minified ones using :program:`uglifyjs` (installed via npm). + See :file:`sphinx/search/minified-js/README.rst`. + + .. _Snowball project: https://snowballstem.org/ * The :file:`searchindex.js` files found in the :file:`tests/js/fixtures/*` directories diff --git a/doc/latex.rst b/doc/latex.rst index fce61480941..edb2f0c18cb 100644 --- a/doc/latex.rst +++ b/doc/latex.rst @@ -500,7 +500,7 @@ Keys that don't need to be overridden unless in special cases are: .. hint:: If the key value is set to - :code-tex:`r'\\newcommand\sphinxbackoftitlepage{}\\sphinxmaketitle'`, then ```` will be typeset on back of title page (``'manual'`` docclass only). @@ -1006,18 +1006,20 @@ The color used in the above example is available from having passed the ``iconpackage`` - The name of the LaTeX package used for icons in the admonition titles. It - defaults to ``fontawesome5`` or to fall-back ``fontawesome``. In case - neither one is available the option value will automatically default to - ``none``, which means that no attempt at loading a package is done. - Independently of this setting, arbitrary LaTeX code can be associated to - each admonition type via ``div._icon-title`` keys which are - described in the :ref:`additionalcss` section. If these keys are not - used, Sphinx will either apply its default choices of icons (if - ``fontawesome{5,}`` is available) or not draw the icon at all. Notice that - if fall-back ``fontawesome`` is used the common icon for :dudir:`caution` - and :dudir:`danger` will default to "bolt" not "radiation", which is only - found in ``fontawesome5``. + The name of the LaTeX package used for rendering icons in the admonition + titles. Its default is set dynamically to either ``fontawesome7``, + ``fontawesome6``, + ``fontawesome5``, ``fontawesome``, or ``none``, in decreasing order of + priority and depending on whether + packages with those names exist in the used LaTeX installation. The LaTeX + code for each admonition icon will use ``\faIcon`` command if with + ``fontawesome{5,6,7}`` and + ``\faicon`` if with ``fontawesome``. + If no "Font Awesome" related package is found (or if the option is set + forcefully to ``none``) the icons are silently dropped. User can set this + option to some specific package and must configure then the + ``div.note_title-icon`` and similar keys to use then that LaTeX package + interface (see the :ref:`additionalcss` section about this). .. versionadded:: 7.4.0 @@ -1410,17 +1412,21 @@ The next keys, for admonitions, :dudir:`topic`, contents_, and (it applies only to the icon, not to the title of the admonition). - ``div._title-icon``: the LaTeX code responsible for producing the - icon. For example, the default for :dudir:`note` is - ``div.note_title-icon=\faIcon{info-circle}``. This uses a command from the - LaTeX ``fontawesome5`` package, which is loaded automatically if available. - - If neither ``fontawesome5`` nor fall-back ``fontawesome`` (for which the - associated command is :code-tex:`\\faicon`, not :code-tex:`\\faIcon`) are - found, or if the ``iconpackage`` key of :ref:`'sphinxsetup' - ` is set to load some other user-chosen package, or no - package at all, all the ``title-icons`` default to empty LaTeX code. It is - up to user to employ this interface to inject the icon (or anything else) - into the PDF output. + icon for the given ````. + For example the default for :dudir:`note` is + ``div.note_title-icon=\faIcon{info-circle}`` with ``fontawesome5``, but + ``div.note_title-icon=\faIcon{circle-info}`` with ``fontawesome6`` + and ``fontawesome7``. + If you want to modify the icons used by Sphinx, employ in these keys + the ``\faIcon`` LaTeX command if one of ``fontawesome5``, ``6`` or ``7`` is + on your LaTeX installation. + If your system only provides the + ``fontawesome`` package use its command ``\faicon`` (not ``\faIcon``) + in order to modify the choice of icons. The ``iconpackage`` key of + ``'sphinxsetup'`` can be used to force usage of one among + ``fontawesome{,5,6,7}`` or be the name of some other icon-providing package. + In that latter case you must configure the ``div._title-icon`` keys + to use the LaTeX commands appropriate to that custom icon package. .. note:: @@ -1694,7 +1700,7 @@ Macros .. hint:: If adding to preamble the loading of ``tocloft`` package, also add to - preamble :code-tex:`\\renewcommand\sphinxtableofcontentshook{}` else it + preamble :code-tex:`\\renewcommand\\sphinxtableofcontentshook{}` else it will reset :code-tex:`\\l@section` and :code-tex:`\\l@subsection` cancelling ``tocloft`` customization. diff --git a/doc/man/sphinx-build.rst b/doc/man/sphinx-build.rst index 63af7e49b4c..6815d10a424 100644 --- a/doc/man/sphinx-build.rst +++ b/doc/man/sphinx-build.rst @@ -58,6 +58,9 @@ Options *info* Build Texinfo files and run them through :program:`makeinfo`. + *help* + Output a list of valid builder targets, and exit. + .. note:: The default output directory locations when using *make-mode* @@ -272,13 +275,13 @@ Options From Sphinx 8.1, :option:`!--keep-going` is always enabled. Previously, it was only applicable whilst using :option:`--fail-on-warning`, which by default exited :program:`sphinx-build` on the first warning. - Using :option:`!--keep-going` runs :program:`!sphinx-build` to completion + Using :option:`!--keep-going` runs :program:`sphinx-build` to completion and exits with exit status 1 if errors are encountered. .. versionadded:: 1.8 .. versionchanged:: 8.1 :program:`sphinx-build` no longer exits on the first warning, - meaning that in effect :option:`!--fail-on-warning` is always enabled. + meaning that in effect :option:`!--keep-going` is always enabled. The option is retained for compatibility, but may be removed at some later date. diff --git a/doc/tutorial/first-steps.rst b/doc/tutorial/first-steps.rst index fd5c631353e..dccf1838de3 100644 --- a/doc/tutorial/first-steps.rst +++ b/doc/tutorial/first-steps.rst @@ -73,6 +73,7 @@ shown right after the corresponding link, in parentheses. You can change that behavior by adding the following code at the end of your ``conf.py``: .. code-block:: python + :caption: docs/source/conf.py # EPUB options epub_show_urls = 'footnote' diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 75e08d7654b..ff903fa4f6c 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -1157,6 +1157,9 @@ Options for source files The recommended encoding is ``'utf-8-sig'``. .. versionadded:: 0.5 + .. deprecated:: 8.3 + Support for source encodings other than UTF-8 is deprecated. + Sphinx 10 will only support UTF-8 files. .. confval:: source_suffix :type: :code-py:`dict[str, str] | Sequence[str] | str` @@ -1391,6 +1394,7 @@ Options for warning control * ``ref.any`` * ``ref.citation`` * ``ref.doc`` + * ``ref.equation`` * ``ref.footnote`` * ``ref.keyword`` * ``ref.numref`` @@ -3083,7 +3087,7 @@ These options influence LaTeX output. the :code-tex:`\\rowcolors` LaTeX command becomes a no-op (this command has limitations and has never correctly supported all types of tables Sphinx produces in LaTeX). - Please update your project to use the + Please use the :ref:`latex table color configuration ` keys instead. To customise the styles for a table, @@ -3096,7 +3100,7 @@ These options influence LaTeX output. The latter two can be combined with any of the first three. The ``standard`` class produces tables with both horizontal and vertical lines - (as has been the default so far with Sphinx). + (as had been the default prior to Sphinx 6.0.0). A single-row multi-column merged cell will obey the row colour, if it is set. @@ -3642,7 +3646,6 @@ and which failures and redirects it ignores. .. confval:: linkcheck_allowed_redirects :type: :code-py:`dict[str, str]` - :default: :code-py:`{}` A dictionary that maps a pattern of the source URI to a pattern of the canonical URI. @@ -3668,6 +3671,11 @@ and which failures and redirects it ignores. .. versionadded:: 4.1 + .. versionchanged:: 8.3 + Setting :confval:`!linkcheck_allowed_redirects` to an empty dictionary + may now be used to warn on all redirects encountered + by the *linkcheck* builder. + .. confval:: linkcheck_anchors :type: :code-py:`bool` :default: :code-py:`True` diff --git a/doc/usage/domains/index.rst b/doc/usage/domains/index.rst index cc3f272646c..acb0f2ee97b 100644 --- a/doc/usage/domains/index.rst +++ b/doc/usage/domains/index.rst @@ -35,6 +35,71 @@ easier to write. This section describes what the domains that are included with Sphinx provide. The domain API is documented as well, in the section :ref:`domain-api`. +Built-in domains +---------------- + +The following domains are included within Sphinx: + +.. toctree:: + :maxdepth: 1 + + standard + c + cpp + javascript + mathematics + python + restructuredtext + + +Third-party domains +------------------- + +Several third-party domains are available as extensions, including: + +* `Ada `__ +* `Antlr4 `__ +* `Bazel `__ +* `BibTex `__ +* `Bison/YACC `__ +* `Chapel `__ +* `CMake `__ +* `Common Lisp `__ +* `Erlang `__ +* `Fortran `__ +* `GraphQL `__ +* `Go `__ +* `HTTP `__ +* `Hy `__ +* `Lua `__ +* `MATLAB `__ +* `PHP `__ +* `Ruby `__ +* `Rust `__ +* `Verilog `__ +* `VHDL `__ +* `Visual Basic `__ + +Other domains may be found on the Python Package Index +(via the `Framework :: Sphinx :: Domain`__ classifier), +`GitHub `__, or +`GitLab `__. + +__ https://pypi.org/search/?c=Framework+%3A%3A+Sphinx+%3A%3A+Domain + +.. NOTE: The following all seem unmaintained, last released 2018 or earlier. + The links are preserved in this comment for reference. + + * `CoffeeScript `__ + * `DotNET `__ + * `dqn `__ + * `Jinja `__ + * `JSON `__ + * `Lasso `__ + * `Operation `__ + * `Scala `__ + * `Lua `__ + .. _basic-domain-markup: @@ -174,40 +239,3 @@ In short: component of the target. For example, ``:py:meth:`~queue.Queue.get``` will refer to ``queue.Queue.get`` but only display ``get`` as the link text. - -Built-in domains ----------------- - -The following domains are included within Sphinx: - -.. toctree:: - :maxdepth: 1 - - standard - c - cpp - javascript - mathematics - python - restructuredtext - -More domains ------------- - -There are several third-party domains available as extensions, including: - -* `Ada `__ -* `Chapel `__ -* `CoffeeScript `__ -* `Common Lisp `__ -* `dqn `__ -* `Erlang `__ -* `Go `__ -* `HTTP `__ -* `Jinja `__ -* `Lasso `__ -* `MATLAB `__ -* `Operation `__ -* `PHP `__ -* `Ruby `__ -* `Scala `__ diff --git a/doc/usage/extensions/autodoc.rst b/doc/usage/extensions/autodoc.rst index 1b873f0d819..925d1450acf 100644 --- a/doc/usage/extensions/autodoc.rst +++ b/doc/usage/extensions/autodoc.rst @@ -966,6 +966,38 @@ Automatically document attributes or data ``:no-value:`` has no effect. +Automatically document type aliases +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. rst:directive:: autotype + + .. versionadded:: 8.3 + + Document a :pep:`695` type alias (the :keyword:`type` statement). + By default, the directive only inserts the docstring of the alias itself: + + The directive can also contain content of its own, + which will be inserted into the resulting non-auto directive source + after the docstring (but before any automatic member documentation). + + Therefore, you can also mix automatic and non-automatic member documentation. + + .. rubric:: Options + + .. rst:directive:option:: no-index + :type: + + Do not generate an index entry for the documented class + or any auto-documented members. + + .. rst:directive:option:: no-index-entry + :type: + + Do not generate an index entry for the documented class + or any auto-documented members. + Unlike ``:no-index:``, cross-references are still created. + + Configuration ------------- @@ -1379,7 +1411,7 @@ autodoc provides the following additional events: ``'(parameter_1, parameter_2)'``, or ``None`` if introspection didn't succeed and signature wasn't specified in the directive. :param return_annotation: function return annotation as a string of the form - ``' -> annotation'``, or ``None`` if there is no return annotation + ``'annotation'``, or ``''`` if there is no return annotation. The :mod:`sphinx.ext.autodoc` module provides factory functions for commonly needed docstring processing in event :event:`autodoc-process-docstring`: diff --git a/doc/usage/extensions/autosectionlabel.rst b/doc/usage/extensions/autosectionlabel.rst index 1e9e1dba722..161b285a290 100644 --- a/doc/usage/extensions/autosectionlabel.rst +++ b/doc/usage/extensions/autosectionlabel.rst @@ -8,6 +8,9 @@ .. versionadded:: 1.4 +.. role:: code-py(code) + :language: Python + By default, cross-references to sections use labels (see :rst:role:`ref`). This extension allows you to instead refer to sections by their title. diff --git a/doc/usage/extensions/autosummary.rst b/doc/usage/extensions/autosummary.rst index 456faee1830..c84dcb60eff 100644 --- a/doc/usage/extensions/autosummary.rst +++ b/doc/usage/extensions/autosummary.rst @@ -412,3 +412,27 @@ the title of a page. Stub pages are generated also based on these directives. .. _`escape filter`: https://jinja.palletsprojects.com/en/3.0.x/templates/#jinja-filters.escape + +Autolink role +------------- + +.. rst:role:: autolink + + The ``:autolink:`` role functions as ``:py:obj:`` when the referenced *name* + can be resolved to a Python object, and otherwise it becomes simple emphasis. + + There are some known design flaws. + For example, in the case of multiple objects having the same name, + :rst:role:`!autolink` could resolve to the wrong object. + It will fail silently if the referenced object is not found, + for example due to a spelling mistake or renaming. + This is sometimes unwanted behaviour. + + Some users choose to configure their :confval:`default_role` to ``autolink`` + for 'smart' referencing using the default interpreted text role (```content```). + + .. seealso:: + + :rst:role:`any` + + :rst:role:`py:obj` diff --git a/doc/usage/extensions/doctest.rst b/doc/usage/extensions/doctest.rst index 60c67827967..10e8f67dfe2 100644 --- a/doc/usage/extensions/doctest.rst +++ b/doc/usage/extensions/doctest.rst @@ -452,3 +452,11 @@ The doctest extension uses the following configuration values: Also, removal of ```` and ``# doctest:`` options only works in :rst:dir:`doctest` blocks, though you may set :confval:`trim_doctest_flags` to achieve that in all code blocks with Python console content. + +.. confval:: doctest_fail_fast + :type: :code-py:`bool` + :default: :code-py:`False` + + Exit when the first failure is encountered. + + .. versionadded:: 8.3 diff --git a/doc/usage/extensions/math.rst b/doc/usage/extensions/math.rst index 6fa8ab851f8..fb41d66d8fb 100644 --- a/doc/usage/extensions/math.rst +++ b/doc/usage/extensions/math.rst @@ -318,14 +318,25 @@ Sphinx but is set to automatically include it from a third-party site. This has been renamed to :confval:`mathjax2_config`. :confval:`mathjax_config` is still supported for backwards compatibility. -:mod:`sphinx.ext.jsmath` -- Render math via JavaScript ------------------------------------------------------- +:mod:`sphinxcontrib.jsmath` -- Render math via JavaScript +--------------------------------------------------------- -.. module:: sphinx.ext.jsmath +.. module:: sphinxcontrib.jsmath :synopsis: Render math using JavaScript via JSMath. This extension works just as the MathJax extension does, but uses the older -package jsMath_. It provides this config value: +package jsMath_. jsMath is no longer actively developed, but it has the +advantage that the size of the JavaScript package is much smaller than +MathJax. + +.. versionadded:: 0.5 + The :mod:`!sphinx.ext.jsmath` extension. +.. versionchanged:: 2.0 + :mod:`!sphinx.ext.jsmath` was moved to :mod:`sphinxcontrib.jsmath`. +.. versionremoved:: 4.0 + The alias from :mod:`!sphinx.ext.jsmath` to :mod:`sphinxcontrib.jsmath`. + +Config value: .. confval:: jsmath_path :type: :code-py:`str` @@ -337,7 +348,7 @@ package jsMath_. It provides this config value: The path can be absolute or relative; if it is relative, it is relative to the ``_static`` directory of the built docs. - For example, if you put JSMath into the static path of the Sphinx docs, this + For example, if you put jsMath into the static path of the Sphinx docs, this value would be ``jsMath/easy/load.js``. If you host more than one Sphinx documentation set on one server, it is advisable to install jsMath in a shared location. @@ -347,5 +358,5 @@ package jsMath_. It provides this config value: .. _dvisvgm: https://dvisvgm.de/ .. _dvisvgm FAQ: https://dvisvgm.de/FAQ .. _MathJax: https://www.mathjax.org/ -.. _jsMath: https://www.math.union.edu/~dpvc/jsmath/ +.. _jsMath: https://www.math.union.edu/~dpvc/jsMath/ .. _LaTeX preview package: https://www.gnu.org/software/auctex/preview-latex.html diff --git a/doc/usage/installation.rst b/doc/usage/installation.rst index 8b0aca1cab3..27adf3ab676 100644 --- a/doc/usage/installation.rst +++ b/doc/usage/installation.rst @@ -56,7 +56,7 @@ Run the following command:: Or, if writing documentation for a Python package, place the dependencies in the `pyproject.toml file`__:: - $ pip install .[docs] + $ pip install . --group docs __ https://pip.pypa.io/en/stable/reference/requirements-file-format/ __ https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#dependencies-optional-dependencies diff --git a/doc/usage/referencing.rst b/doc/usage/referencing.rst index 2597c9ce597..571d3c798bc 100644 --- a/doc/usage/referencing.rst +++ b/doc/usage/referencing.rst @@ -136,8 +136,8 @@ There is also a way to directly link to documents: .. rst:role:: doc - Link to the specified document; the document name can be specified in - absolute or relative fashion. For example, if the reference + Link to the specified document; the document name can be a relative or absolute + path and is always case-sensitive, even on Windows. For example, if the reference ``:doc:`parrot``` occurs in the document ``sketches/index``, then the link refers to ``sketches/parrot``. If the reference is ``:doc:`/people``` or ``:doc:`../people```, the link refers to ``people``. diff --git a/doc/usage/restructuredtext/basics.rst b/doc/usage/restructuredtext/basics.rst index 5d60ea81de4..8f408f45e38 100644 --- a/doc/usage/restructuredtext/basics.rst +++ b/doc/usage/restructuredtext/basics.rst @@ -208,11 +208,39 @@ Hyperlinks External links ~~~~~~~~~~~~~~ -Use ```Link text `_`` for inline web links. If the -link text should be the web address, you don't need special markup at all, the -parser finds links and mail addresses in ordinary text. +URLs and email addresses in text are automatically linked and do not need +explicit markup at all. +For example, https://domain.invalid/ is written with no special markup +in the source of this document, and is recognised as an external hyperlink. -.. important:: There must be a space between the link text and the opening \< for the URL. +To create text with a link, the best approach is generally to put the URL +below the paragraph as follows (:duref:`ref `):: + + This is a paragraph that contains `a link`_. + + .. _a link: https://domain.invalid/ + +This keeps the paragraph more readable in source code. + +Alternatively, you can embed the URL within the prose for an 'inline link'. +This can lead to longer lines, but has the benefit of keeping the link text +and the URL pointed to in the same place. +This uses the following syntax: ```Link text `__`` +(:duref:`ref `). + +.. important:: + + There must be a space between the link text + and the opening angle bracket ('``<``') for the URL. + +.. tip:: + + Use two trailing underscores when embedding the URL. + Technically, a single underscore works as well, + but that would create a named reference instead of an anonymous one. + Named references typically do not have a benefit when the URL is embedded. + Moreover, they have the disadvantage that you must make sure that you + do not use the same "Link text" for another link in your document. You can also separate the link and the target definition (:duref:`ref `), like this:: @@ -618,10 +646,11 @@ configurations: Source encoding --------------- -Since the easiest way to include special characters like em dashes or copyright -signs in reStructuredText is to directly write them as Unicode characters, one has to -specify an encoding. Sphinx assumes source files to be encoded in UTF-8 by -default; you can change this with the :confval:`source_encoding` config value. +Sphinx supports source files that are encoded in UTF-8. +This means that the full range of Unicode__ characters may be used +directly in reStructuredText. + +__ https://www.unicode.org/ Gotchas diff --git a/doc/usage/restructuredtext/directives.rst b/doc/usage/restructuredtext/directives.rst index 33269b522a6..4b1a2042df9 100644 --- a/doc/usage/restructuredtext/directives.rst +++ b/doc/usage/restructuredtext/directives.rst @@ -473,8 +473,8 @@ and the generic :rst:dir:`admonition` directive. .. seealso:: - Module :py:mod:`zipfile` - Documentation of the :py:mod:`zipfile` standard module. + Python's :py:mod:`zipfile` module + Documentation of Python's standard :py:mod:`zipfile` module. `GNU tar manual, Basic Tar Format `_ Documentation for tar archive files, including GNU tar extensions. @@ -537,7 +537,8 @@ Describing changes between versions pair: changes; in version pair: removed; in version -.. rst:directive:: .. versionadded:: version [brief explanation] +.. rst:directive:: .. version-added:: version [brief explanation] + .. versionadded:: version [brief explanation] This directive documents the version of the project which added the described feature. @@ -551,56 +552,75 @@ Describing changes between versions There must be no blank line between the directive head and the explanation; this is to make these blocks visually continuous in the markup. + .. version-changed:: 8.3 + The :rst:dir:`versionadded` directive was renamed to :rst:dir:`version-added`. + The previous name is retained as an alias. + Example:: - .. versionadded:: 2.5 + .. version-added:: 2.5 The *spam* parameter. - .. versionadded:: 2.5 + .. version-added:: 2.5 The *spam* parameter. -.. rst:directive:: .. versionchanged:: version [brief explanation] +.. rst:directive:: .. version-changed:: version [brief explanation] + .. versionchanged:: version [brief explanation] - Similar to :rst:dir:`versionadded`, but describes when and what changed in + Similar to :rst:dir:`version-added`, but describes when and what changed in the named feature in some way (new parameters, changed side effects, etc.). + .. version-changed:: 8.3 + The :rst:dir:`versionchanged` directive was renamed to :rst:dir:`version-changed`. + The previous name is retained as an alias. + Example:: - .. versionchanged:: 2.8 + .. version-changed:: 2.8 The *spam* parameter is now of type *boson*. - .. versionchanged:: 2.8 + .. version-changed:: 2.8 The *spam* parameter is now of type *boson*. -.. rst:directive:: .. deprecated:: version [brief explanation] +.. rst:directive:: .. version-deprecated:: version [brief explanation] + .. deprecated:: version [brief explanation] - Similar to :rst:dir:`versionadded`, but describes when the feature was + Similar to :rst:dir:`version-added`, but describes when the feature was deprecated. A *brief* explanation can also be given, for example to tell the reader what to use instead. + .. version-changed:: 8.3 + The :rst:dir:`deprecated` directive was renamed to :rst:dir:`version-deprecated`. + The previous name is retained as an alias + Example:: - .. deprecated:: 3.1 + .. version-deprecated:: 3.1 Use :py:func:`spam` instead. - .. deprecated:: 3.1 + .. version-deprecated:: 3.1 Use :py:func:`!spam` instead. -.. rst:directive:: .. versionremoved:: version [brief explanation] +.. rst:directive:: .. version-removed:: version [brief explanation] + .. versionremoved:: version [brief explanation] - Similar to :rst:dir:`versionadded`, but describes when the feature was removed. + Similar to :rst:dir:`version-added`, but describes when the feature was removed. An explanation may be provided to tell the reader what to use instead, or why the feature was removed. - .. versionadded:: 7.3 + .. version-added:: 7.3 + + .. version-changed:: 8.3 + The :rst:dir:`versionremoved` directive was renamed to :rst:dir:`version-removed`. + The previous name is retained as an alias. Example:: - .. versionremoved:: 4.0 + .. version-removed:: 4.0 The :py:func:`spam` function is more flexible, and should be used instead. - .. versionremoved:: 4.0 + .. version-removed:: 4.0 The :py:func:`!spam` function is more flexible, and should be used instead. @@ -971,7 +991,7 @@ __ https://pygments.org/docs/lexers :type: text Explicitly specify the encoding of the file. - This overwrites the default encoding (:confval:`source_encoding`). + This overwrites the default encoding (UTF-8). For example: .. code-block:: rst @@ -1472,11 +1492,20 @@ Check the :confval:`latex_table_style`. complex contents such as multiple paragraphs, blockquotes, lists, literal blocks, will render correctly to LaTeX output. +.. versionchanged:: 8.3.0 + The partial support of the LaTeX builder for nesting a table in another + has been extended. + Formerly Sphinx would raise an error if ``longtable`` class was specified + for a table containing a nested table, and some cases would not raise an + error at Sphinx level but fail at LaTeX level during PDF build. This is a + complex topic in LaTeX rendering and the output can sometimes be improved + via the :rst:dir:`tabularcolumns` directive. + .. rst:directive:: .. tabularcolumns:: column spec - This directive influences only the LaTeX output for the next table in - source. The mandatory argument is a column specification (known as an - "alignment preamble" in LaTeX idiom). Please refer to a LaTeX + This directive influences only the LaTeX output, and only for the next + table in source. The mandatory argument is a column specification (known + as an "alignment preamble" in LaTeX idiom). Please refer to a LaTeX documentation, such as the `wiki page`_, for basics of such a column specification. @@ -1484,52 +1513,85 @@ Check the :confval:`latex_table_style`. .. versionadded:: 0.3 + Sphinx renders tables with at most 30 rows using ``tabulary`` (or + ``tabular`` if at least one cell contains either a code-block or a nested + table), and those with more rows with ``longtable``. The advantage of + using ``tabulary`` is that it tries to compute automatically (internally to + LaTeX) suitable column widths. + + The ``tabulary`` algorithm often works well, but in some cases when a cell + contains long paragraphs, the column will be given a large width and other + columns whose cells contain only single words may end up too narrow. The + :rst:dir:`tabularcolumns` can help solve this via providing to LaTeX a + custom "alignment preamble" (aka "colspec"). For example ``lJJ`` will be + suitable for a three-columns table whose first column contains only single + words and the other two have cells with long paragraphs. + .. note:: - :rst:dir:`tabularcolumns` conflicts with ``:widths:`` option of table - directives. If both are specified, ``:widths:`` option will be ignored. + Of course, a fully automated solution would be better, and it is still + hoped for, but it is an intrinsic aspect of ``tabulary``, and the latter + is in use by Sphinx ever since ``0.3``... It looks as if solving the + problem of squeezed columns could require substantial changes to that + LaTeX package. And no good alternative appears to exist, as of 2025. - Sphinx will render tables with more than 30 rows with ``longtable``. - Besides the ``l``, ``r``, ``c`` and ``p{width}`` column specifiers, one can - also use ``\X{a}{b}`` (new in version 1.5) which configures the column - width to be a fraction ``a/b`` of the total line width and ``\Y{f}`` (new - in version 1.6) where ``f`` is a decimal: for example ``\Y{0.2}`` means that - the column will occupy ``0.2`` times the line width. + .. hint:: - When this directive is used for a table with at most 30 rows, Sphinx will - render it with ``tabulary``. One can then use specific column types ``L`` - (left), ``R`` (right), ``C`` (centered) and ``J`` (justified). They have - the effect of a ``p{width}`` (i.e. each cell is a LaTeX ``\parbox``) with - the specified internal text alignment and an automatically computed - ``width``. + A way to solve the issue for all tables at once, is to inject in the + LaTeX preamble (see :confval:`latex_elements`) a command such as + ``\setlength{\tymin}{1cm}`` which causes all columns to be at least + ``1cm`` wide (not counting inter-column whitespace). Currently, Sphinx + configures ``\tymin`` to allow room for three characters at least. - .. warning:: + Here is a more sophisticated "colspec", for a 4-columns table: - - Cells that contain list-like elements such as object descriptions, - blockquotes or any kind of lists are not compatible with the ``LRCJ`` - column types. The column type must then be some ``p{width}`` with an - explicit ``width`` (or ``\X{a}{b}`` or ``\Y{f}``). + .. code-block:: latex - - Literal blocks do not work with ``tabulary`` at all. Sphinx will - fall back to ``tabular`` or ``longtable`` environments and generate a - suitable column specification. + .. tabularcolumns:: >{\raggedright}\Y{.4}>{\centering}\Y{.1}>{\sphinxcolorblend{!95!red}\centering\noindent\bfseries\color{red}}\Y{.12}>{\raggedright\arraybackslash}\Y{.38} -In absence of the :rst:dir:`tabularcolumns` directive, and for a table with at -most 30 rows and no problematic cells as described in the above warning, -Sphinx uses ``tabulary`` and the ``J`` column-type for every column. + This is used in Sphinx own PDF docs at :ref:`dev-deprecated-apis`. + Regarding column widths, this "colspec" achieves the same as would + ``:widths:`` option set to ``40 10 12 38`` but it injects extra effects. -.. versionchanged:: 1.6 + .. note:: - Formerly, the ``L`` column-type was used (text is flushed-left). To revert - to this, include ``\newcolumntype{T}{L}`` in the LaTeX preamble, as in fact - Sphinx uses ``T`` and sets it by default to be an alias of ``J``. + In case both :rst:dir:`tabularcolumns` and ``:widths:`` option of table + directives are used, ``:widths:`` option will be ignored by the LaTeX + builder. Of course it is obeyed by other builders. -.. hint:: + Literal blocks do not work at all with ``tabulary`` and Sphinx will then + fall back to ``tabular`` LaTeX environment. It will employ the + :rst:dir:`tabularcolumns` specification in that case only if it contains no + usage of the ``tabulary`` specific column types (which are ``L``, ``R``, + ``C`` and ``J``). + + Besides the LaTeX ``l``, ``r``, ``c`` and ``p{width}`` column specifiers, + and the ``tabulary`` specific ``L``, ``R``, ``C`` and ``J``, one can also + use (with all table types) ``\X{a}{b}`` which configures the column width + to be a fraction ``a/b`` of the total line width and ``\Y{f}`` where ``f`` + is a decimal: for example ``\Y{0.2}`` means that the column will occupy + ``0.2`` times the line width. + +.. versionchanged:: 1.6 - A frequent issue with ``tabulary`` is that columns with little contents - appear to be "squeezed". One can add to the LaTeX preamble for example - ``\setlength{\tymin}{40pt}`` to ensure a minimal column width of ``40pt``, - the ``tabulary`` default of ``10pt`` being too small. + Sphinx uses ``J`` (justified) by default with ``tabulary``, not ``L`` + (flushed-left). To revert, include ``\newcolumntype{T}{L}`` in the LaTeX + preamble, as in fact Sphinx uses ``T`` and sets it by default to be an + alias of ``J``. + +.. versionchanged:: 8.3.0 + + Formerly, Sphinx did not use ``tabulary`` if the table had at least one + cell containing "problematic" elements such as lists, object descriptions, + blockquotes (etc...) because such contents are not out-of-the-box + compatible with ``tabulary``. At ``8.3.0`` a technique, which was already + in use for merged cells, was extended to such cases, and the sole + "problematic" contents are code-blocks and nested tables. So tables + containing (only) cells with mutliple paragraphs, bullet or enumerated + lists, or line blocks, will now better fit to their contents (if not + rendered by ``longtable``). Cells with object descriptions or admonitions + will still have a tendency to induce the table to fill the full text area + width, but columns in that table with no such contents will be tighter. .. hint:: diff --git a/pyproject.toml b/pyproject.toml index 9645f148dd3..e3418ab98a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["flit_core>=3.11"] +requires = ["flit_core>=3.12"] build-backend = "flit_core.buildapi" # project metadata @@ -39,6 +39,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Framework :: Sphinx", @@ -75,7 +76,7 @@ dependencies = [ "sphinxcontrib-serializinghtml>=1.1.9", "Jinja2>=3.1", "Pygments>=2.17", - "docutils>=0.20,<0.22", + "docutils>=0.20,<0.23", "snowballstemmer>=2.2", "babel>=2.13", "alabaster>=0.7.14", @@ -84,38 +85,10 @@ dependencies = [ "roman-numerals-py>=1.0.0", "packaging>=23.0", "colorama>=0.4.6; sys_platform == 'win32'", + "ipython>=9.6.0", ] dynamic = ["version"] -[project.optional-dependencies] -docs = [ - "sphinxcontrib-websupport", -] -lint = [ - "ruff==0.9.9", - "mypy==1.15.0", - "sphinx-lint>=0.9", - "types-colorama==0.4.15.20240311", - "types-defusedxml==0.7.0.20240218", - "types-docutils==0.21.0.20241128", - "types-Pillow==10.2.0.20240822", - "types-Pygments==2.19.0.20250219", - "types-requests==2.32.0.20250301", # align with requests - "types-urllib3==1.26.25.14", - "pyright==1.1.396", - "pytest>=8.0", - "pypi-attestations==0.0.21", - "betterproto==2.0.0b6", -] -test = [ - "pytest>=8.0", - "pytest-xdist[psutil]>=3.4", - "defusedxml>=0.7.1", # for secure XML/HTML parsing - "cython>=3.0", - "setuptools>=70.0", # for Cython compilation - "typing_extensions>=4.9", # for typing_extensions.Unpack -] - [[project.authors]] name = "Adam Turner" email = "aa-turner@users.noreply.github.com" @@ -130,6 +103,50 @@ sphinx-quickstart = "sphinx.cmd.quickstart:main" sphinx-apidoc = "sphinx.ext.apidoc:main" sphinx-autogen = "sphinx.ext.autosummary.generate:main" +[dependency-groups] +docs = [ + "sphinxcontrib-websupport", +] +lint = [ + "ruff==0.14.0", + "sphinx-lint>=0.9", +] +package = [ + "betterproto==2.0.0b6", # resolution fails without betterproto + "build", + "pypi-attestations==0.0.27", + "twine>=6.1", +] +test = [ + "pytest>=8.0", + "pytest-xdist[psutil]>=3.4", + "cython>=3.0", # for Cython compilation + "defusedxml>=0.7.1", # for secure XML/HTML parsing + "setuptools>=70.0", # for Cython compilation + "typing_extensions>=4.9", # for typing_extensions.Unpack +] +translations = [ + "babel>=2.13", + "Jinja2>=3.1", +] +types = [ + "mypy==1.18.2", + "pyrefly", + "pyright==1.1.406", + "ty", + { include-group = "type-stubs" }, +] +type-stubs = [ + # align with versions used elsewhere + "types-colorama==0.4.15.20250801", + "types-defusedxml==0.7.0.20250822", + "types-docutils==0.21.0.20250525", + "types-Pillow==10.2.0.20240822", + "types-Pygments==2.19.0.20250809", + "types-requests==2.32.4.20250809", + "types-urllib3==1.26.25.14", +] + [tool.flit.module] name = "sphinx" @@ -156,7 +173,7 @@ exclude = [ [tool.mypy] files = [ "doc/conf.py", - "doc/development/tutorials/examples/autodoc_intenum.py", +# "doc/development/tutorials/examples/autodoc_intenum.py", "doc/development/tutorials/examples/helloworld.py", "sphinx", "tests", @@ -203,57 +220,32 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = [ # tests/ - "tests.test_addnodes", - "tests.test_application", - "tests.test_events", - "tests.test_highlighting", - "tests.test_project", "tests.test_versioning", # tests/test_builders "tests.test_builders.test_build", "tests.test_builders.test_build_html", "tests.test_builders.test_build_html_5_output", - "tests.test_builders.test_build_html_assets", - "tests.test_builders.test_build_html_maths", - "tests.test_builders.test_build_html_numfig", - "tests.test_builders.test_build_html_tocdepth", - "tests.test_builders.test_build_html_toctree", "tests.test_builders.test_build_linkcheck", - "tests.test_builders.test_build_warnings", # tests/test_directives "tests.test_directives.test_directive_code", "tests.test_directives.test_directives_no_typesetting", - # tests/test_environment - "tests.test_environment.test_environment", + # tests/test_ext_autodoc + "tests.test_ext_autodoc.test_ext_autodoc_autoclass", + # tests/test_ext_autosummary + "tests.test_ext_autosummary.test_ext_autosummary_imports", + # tests/test_ext_intersphinx + "tests.test_ext_intersphinx.test_ext_intersphinx_cache", + # tests/test_ext_napoleon + "tests.test_ext_napoleon.test_ext_napoleon", # tests/test_extensions - "tests.test_extensions.test_ext_autodoc_autoclass", - "tests.test_extensions.test_ext_autosummary_imports", - "tests.test_extensions.test_ext_imgconverter", - "tests.test_extensions.test_ext_intersphinx_cache", "tests.test_extensions.test_ext_math", - "tests.test_extensions.test_ext_napoleon", - "tests.test_extensions.test_ext_todo", - "tests.test_extensions.test_ext_viewcode", - # tests/test_intl - "tests.test_intl.test_catalogs", - "tests.test_intl.test_locale", # tests/test_markup "tests.test_markup.test_markup", - "tests.test_markup.test_parser", # tests/test_theming "tests.test_theming.test_templating", "tests.test_theming.test_theming", # tests/test_transforms - "tests.test_transforms.test_transforms_move_module_targets", "tests.test_transforms.test_transforms_post_transforms_images", - "tests.test_transforms.test_transforms_reorder_nodes", - # tests/test_util - "tests.test_util.test_util", - "tests.test_util.test_util_display", - "tests.test_util.test_util_docutils", - "tests.test_util.test_util_images", - "tests.test_util.test_util_inventory", - "tests.test_util.test_util_matching", # tests/test_writers "tests.test_writers.test_docutilsconf", ] @@ -262,14 +254,12 @@ disallow_untyped_defs = false [[tool.mypy.overrides]] module = [ # tests/ - "tests.test_quickstart", "tests.test_search", # tests/test_builders "tests.test_builders.test_build_latex", # tests/test_config "tests.test_config.test_config", # tests/test_directives - "tests.test_directives.test_directive_only", "tests.test_directives.test_directive_other", "tests.test_directives.test_directive_patch", # tests/test_domains @@ -284,25 +274,25 @@ module = [ "tests.test_domains.test_domain_std", # tests/test_environment "tests.test_environment.test_environment_toctree", + # tests/test_ext_autodoc + "tests.test_ext_autodoc.test_ext_autodoc", + "tests.test_ext_autodoc.test_ext_autodoc_events", + "tests.test_ext_autodoc.test_ext_autodoc_mock", + # tests/test_ext_autosummary + "tests.test_ext_autosummary.test_ext_autosummary", + # tests/test_ext_intersphinx + "tests.test_ext_intersphinx.test_ext_intersphinx", + # tests/test_ext_napoleon + "tests.test_ext_napoleon.test_ext_napoleon_docstring", # tests/test_extensions "tests.test_extensions.test_ext_apidoc", - "tests.test_extensions.test_ext_autodoc", - "tests.test_extensions.test_ext_autodoc_events", - "tests.test_extensions.test_ext_autodoc_mock", - "tests.test_extensions.test_ext_autosummary", "tests.test_extensions.test_ext_doctest", "tests.test_extensions.test_ext_inheritance_diagram", - "tests.test_extensions.test_ext_intersphinx", - "tests.test_extensions.test_ext_napoleon_docstring", # tests/test_intl "tests.test_intl.test_intl", - # tests/test_pycode - "tests.test_pycode.test_pycode", - "tests.test_pycode.test_pycode_ast", # tests/test_transforms "tests.test_transforms.test_transforms_post_transforms", # tests/test_util - "tests.test_util.test_util_fileutil", "tests.test_util.test_util_i18n", "tests.test_util.test_util_inspect", "tests.test_util.test_util_logging", @@ -313,7 +303,6 @@ check_untyped_defs = false disable_error_code = [ "annotation-unchecked", ] -disallow_incomplete_defs = false disallow_untyped_calls = false disallow_untyped_defs = false @@ -422,3 +411,6 @@ reportUnusedFunction = "none" reportUnusedImport = "none" reportUnusedVariable = "none" reportWildcardImportFromLibrary = "none" + +[tool.uv] +default-groups = "all" diff --git a/pyrefly.toml b/pyrefly.toml new file mode 100644 index 00000000000..88ccae4d84c --- /dev/null +++ b/pyrefly.toml @@ -0,0 +1,27 @@ +# Configuration file for Pyrefly_. +# n.b. Pyrefly is early in development. +# Sphinx's current primary/reference type-checker is mypy. +# +# .. _Pyrefly: https://pyrefly.org/en/docs/configuration/ + +project_includes = [ + "doc/conf.py", + "doc/development/tutorials/examples/autodoc_intenum.py", + "doc/development/tutorials/examples/helloworld.py", + "sphinx", + "tests", + "utils", +] +project_excludes = [ + "**/tests/roots*", +] +python_version = "3.11" +replace_imports_with_any = [ + "imagesize", + "pyximport", + "snowballstemmer", +] + +# https://pyrefly.org/en/docs/error-kinds/ +[errors] +implicitly-defined-attribute = false # many false positives diff --git a/sphinx/__init__.py b/sphinx/__init__.py index b70b6db47a6..79df3e09df3 100644 --- a/sphinx/__init__.py +++ b/sphinx/__init__.py @@ -5,13 +5,7 @@ from __future__ import annotations -import warnings - -# work around flit error in parsing annotated assignments -try: - from sphinx.util._pathlib import _StrPath -except ImportError: - from pathlib import Path as _StrPath # type: ignore[assignment] +from sphinx.util._pathlib import _StrPath TYPE_CHECKING = False if TYPE_CHECKING: @@ -20,13 +14,6 @@ __version__: Final = '8.3.0' __display_version__: Final = __version__ # used for command line version -warnings.filterwarnings( - 'ignore', - 'The frontend.Option class .*', - DeprecationWarning, - module='docutils.frontend', -) - #: Version info for better programmatic use. #: #: A tuple of five elements; for Sphinx version 1.2.1 beta 3 this would be @@ -38,6 +25,7 @@ version_info: Final = (8, 3, 0, 'beta', 0) package_dir: Final = _StrPath(__file__).resolve().parent +del _StrPath _in_development = True if _in_development: diff --git a/sphinx/_cli/__init__.py b/sphinx/_cli/__init__.py index 87128b0a5a0..8c305442de3 100644 --- a/sphinx/_cli/__init__.py +++ b/sphinx/_cli/__init__.py @@ -64,7 +64,7 @@ def _load_subcommand_descriptions() -> Iterator[tuple[str, str]]: # log an error here, but don't fail the full enumeration print(f'Failed to load the description for {command}', file=sys.stderr) else: - yield command, description.split('\n\n', 1)[0] + yield command, description.partition('\n\n')[0] class _RootArgumentParser(argparse.ArgumentParser): diff --git a/sphinx/addnodes.py b/sphinx/addnodes.py index 3cf63b6b053..4ce85dabcf2 100644 --- a/sphinx/addnodes.py +++ b/sphinx/addnodes.py @@ -402,8 +402,8 @@ class desc_sig_literal_char(desc_sig_element, _sig_element=True): class versionmodified(nodes.Admonition, nodes.TextElement): """Node for version change entries. - Currently used for "versionadded", "versionchanged", "deprecated" - and "versionremoved" directives. + Currently used for "version-added", "version-changed", "version-deprecated" + and "version-removed" directives, along with their aliases. """ diff --git a/sphinx/application.py b/sphinx/application.py index fe0e8bdf195..f1ca7d13541 100644 --- a/sphinx/application.py +++ b/sphinx/application.py @@ -52,7 +52,12 @@ from sphinx.config import ENUM, _ConfigRebuild from sphinx.domains import Domain, Index from sphinx.environment.collectors import EnvironmentCollector - from sphinx.ext.autodoc import Documenter, _AutodocProcessDocstringListener + from sphinx.ext.autodoc._documenters import Documenter + from sphinx.ext.autodoc._event_listeners import ( + _AutodocProcessDocstringListener, + _AutodocProcessSignatureListener, + _AutodocSkipMemberListener, + ) from sphinx.ext.todo import todo_node from sphinx.extension import Extension from sphinx.registry import ( @@ -196,7 +201,6 @@ def __init__( :param pdb: If true, enable the Python debugger on an exception. :param exception_on_warning: If true, raise an exception on warnings. """ - self.phase = BuildPhase.INITIALIZATION self.verbosity = verbosity self._fresh_env_used: bool | None = None self.extensions: dict[str, Extension] = {} @@ -240,7 +244,7 @@ def __init__( self._fail_on_warnings = bool(warningiserror) self.pdb = pdb self._exception_on_warning = exception_on_warning - logging.setup(self, self._status, self._warning) + logging.setup(self, self._status, self._warning, verbosity=verbosity) self.events = EventManager(self) @@ -255,15 +259,17 @@ def __init__( self.statuscode = 0 # read config + overrides = confoverrides or {} self.tags = Tags(tags) if confdir is None: # set confdir to srcdir if -C given (!= no confdir); a few pieces # of code expect a confdir to be set self.confdir = self.srcdir - self.config = Config({}, confoverrides or {}) + self.config = Config({}, overrides) else: self.confdir = _StrPath(confdir).resolve() - self.config = Config.read(self.confdir, confoverrides or {}, self.tags) + self.config = Config.read(self.confdir, overrides=overrides, tags=self.tags) + self.config._verbosity = -1 if self.quiet else self.verbosity # set up translation infrastructure self._init_i18n() @@ -338,6 +344,12 @@ def fresh_env_used(self) -> bool | None: """ return self._fresh_env_used + @property + def phase(self) -> BuildPhase: + if not hasattr(self, 'builder'): + return BuildPhase.INITIALIZATION + return self.builder.phase + def _init_i18n(self) -> None: """Load translated strings from the configured localedirs if enabled in the configuration. @@ -399,6 +411,8 @@ def _post_init_env(self) -> None: if self._fresh_env_used: self.env.find_files(self.config, self.builder) + self.env._builder_cls = self.builder.__class__ + def preload_builder(self, name: str) -> None: self.registry.preload_builder(self, name) @@ -416,7 +430,7 @@ def _init_builder(self) -> None: # ---- main "build" method ------------------------------------------------- def build(self, force_all: bool = False, filenames: Sequence[Path] = ()) -> None: - self.phase = BuildPhase.READING + self.builder.phase = BuildPhase.READING try: if force_all: self.builder.build_all() @@ -715,20 +729,7 @@ def connect( def connect( self, event: Literal['autodoc-process-signature'], - callback: Callable[ - [ - Sphinx, - Literal[ - 'module', 'class', 'exception', 'function', 'method', 'attribute' - ], - str, - Any, - dict[str, bool], - str | None, - str | None, - ], - tuple[str | None, str | None] | None, - ], + callback: _AutodocProcessSignatureListener, priority: int = 500, ) -> int: ... @@ -744,19 +745,7 @@ def connect( def connect( self, event: Literal['autodoc-skip-member'], - callback: Callable[ - [ - Sphinx, - Literal[ - 'module', 'class', 'exception', 'function', 'method', 'attribute' - ], - str, - Any, - bool, - dict[str, bool], - ], - bool, - ], + callback: _AutodocSkipMemberListener, priority: int = 500, ) -> int: ... @@ -933,6 +922,9 @@ def add_config_value( ``'env'``) to a string. However, booleans are still accepted and converted internally. + .. versionadded:: 1.4 + The *types* parameter. + .. versionadded:: 7.4 The *description* parameter. """ @@ -1108,7 +1100,7 @@ def setup(app): .. versionchanged:: 0.6 Docutils 0.5-style directive classes are now supported. - .. deprecated:: 1.8 + .. versionchanged:: 1.8 Docutils 0.4-style (function based) directives support is deprecated. .. versionchanged:: 1.8 Add *override* keyword. @@ -1637,8 +1629,9 @@ def add_autodocumenter(self, cls: type[Documenter], override: bool = False) -> N logger.debug('[app] adding autodocumenter: %r', cls) from sphinx.ext.autodoc.directive import AutodocDirective - self.registry.add_documenter(cls.objtype, cls) - self.add_directive('auto' + cls.objtype, AutodocDirective, override=override) + objtype = cls.objtype # type: ignore[attr-defined] + self.registry.add_documenter(objtype, cls) + self.add_directive('auto' + objtype, AutodocDirective, override=override) def add_autodoc_attrgetter( self, typ: type, getter: Callable[[Any, str, Any], Any] diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index 21a1eb8b5c4..2dd972ecfe0 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -11,9 +11,9 @@ from typing import TYPE_CHECKING, final from docutils import nodes -from docutils.utils import DependencyList from sphinx._cli.util.colour import bold +from sphinx.deprecation import _deprecation_warning from sphinx.environment import ( CONFIG_CHANGED_REASON, CONFIG_OK, @@ -22,16 +22,12 @@ from sphinx.environment.adapters.asset import ImageAdapter from sphinx.errors import SphinxError from sphinx.locale import __ -from sphinx.util import ( - get_filetype, - logging, - rst, -) +from sphinx.util import get_filetype, logging from sphinx.util._importer import import_object from sphinx.util._pathlib import _StrPathProperty from sphinx.util.build_phase import BuildPhase from sphinx.util.display import progress_message, status_iterator -from sphinx.util.docutils import sphinx_domains +from sphinx.util.docutils import _parse_str_to_doctree from sphinx.util.i18n import CatalogRepository, docname_to_domain from sphinx.util.osutil import ensuredir, relative_uri, relpath from sphinx.util.parallel import ( @@ -48,7 +44,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Sequence, Set from gettext import NullTranslations - from typing import Any, Literal + from typing import Any, ClassVar, Literal from docutils.nodes import Node @@ -70,37 +66,39 @@ class Builder: #: The builder's name. #: This is the value used to select builders on the command line. - name: str = '' + name: ClassVar[str] = '' #: The builder's output format, or '' if no document output is produced. #: This is commonly the file extension, e.g. "html", #: though any string value is accepted. #: The builder's format string can be used by various components #: such as :class:`.SphinxPostTransform` or extensions to determine #: their compatibility with the builder. - format: str = '' + format: ClassVar[str] = '' #: The message emitted upon successful build completion. #: This can be a printf-style template string #: with the following keys: ``outdir``, ``project`` - epilog: str = '' + epilog: ClassVar[str] = '' #: default translator class for the builder. This can be overridden by #: :py:meth:`~sphinx.application.Sphinx.set_translator`. - default_translator_class: type[nodes.NodeVisitor] + default_translator_class: ClassVar[type[nodes.NodeVisitor]] # doctree versioning method - versioning_method = 'none' - versioning_compare = False + versioning_method: ClassVar[str] = 'none' + versioning_compare: ClassVar[bool] = False #: Whether it is safe to make parallel :meth:`~.Builder.write_doc` calls. - allow_parallel: bool = False + allow_parallel: ClassVar[bool] = False # support translation - use_message_catalog = True + use_message_catalog: ClassVar[bool] = True #: The list of MIME types of image formats supported by the builder. #: Image files are searched in the order in which they appear here. - supported_image_types: list[str] = [] + supported_image_types: ClassVar[list[str]] = [] #: The builder can produce output documents that may fetch external images when opened. - supported_remote_images: bool = False + supported_remote_images: ClassVar[bool] = False #: The file format produced by the builder allows images to be embedded using data-URIs. - supported_data_uri_images: bool = False + supported_data_uri_images: ClassVar[bool] = False + + phase: BuildPhase = BuildPhase.INITIALIZATION srcdir = _StrPathProperty() confdir = _StrPathProperty() @@ -114,7 +112,7 @@ def __init__(self, app: Sphinx, env: BuildEnvironment) -> None: self.doctreedir = app.doctreedir ensuredir(self.doctreedir) - self.app: Sphinx = app + self._app: Sphinx = app self.env: BuildEnvironment = env self.env.set_versioning_method(self.versioning_method, self.versioning_compare) self.events: EventManager = app.events @@ -124,6 +122,7 @@ def __init__(self, app: Sphinx, env: BuildEnvironment) -> None: self.tags.add(self.name) self.tags.add(f'format_{self.format}') self.tags.add(f'builder_{self.name}') + self._registry = app.registry # images that need to be copied over (source -> dest) self.images: dict[str, str] = {} @@ -136,13 +135,20 @@ def __init__(self, app: Sphinx, env: BuildEnvironment) -> None: self.parallel_ok = False self.finish_tasks: Any = None + @property + def app(self) -> Sphinx: + cls_module = self.__class__.__module__ + cls_name = self.__class__.__qualname__ + _deprecation_warning(cls_module, f'{cls_name}.app', remove=(10, 0)) + return self._app + @property def _translator(self) -> NullTranslations | None: - return self.app.translator + return self._app.translator def get_translator_class(self, *args: Any) -> type[nodes.NodeVisitor]: """Return a class of translator.""" - return self.env._registry.get_translator_class(self) + return self._registry.get_translator_class(self) def create_translator(self, *args: Any) -> nodes.NodeVisitor: """Return an instance of translator. @@ -150,7 +156,7 @@ def create_translator(self, *args: Any) -> nodes.NodeVisitor: This method returns an instance of ``default_translator_class`` by default. Users can replace the translator class with ``app.set_translator()`` API. """ - return self.env._registry.create_translator(self, *args) + return self._registry.create_translator(self, *args) # helper methods def init(self) -> None: @@ -258,7 +264,7 @@ def cat2relpath(cat: CatalogInfo, srcdir: Path = self.srcdir) -> str: __('writing output... '), 'darkgreen', len(catalogs), - self.app.verbosity, + self.config.verbosity, stringify_func=cat2relpath, ): catalog.write_mo( @@ -397,14 +403,14 @@ def build( # while reading, collect all warnings from docutils with ( nullcontext() - if self.app._exception_on_warning + if self._app._exception_on_warning else logging.pending_warnings() ): updated_docnames = set(self.read()) doccount = len(updated_docnames) logger.info(bold(__('looking for now-outdated files... ')), nonl=True) - updated_docnames.update(self.env.check_dependents(self.app, updated_docnames)) + updated_docnames.update(self.env.check_dependents(self._app, updated_docnames)) outdated = len(updated_docnames) - doccount if outdated: logger.info(__('%d found'), outdated) @@ -422,14 +428,14 @@ def build( pickle.dump(self.env, f, pickle.HIGHEST_PROTOCOL) # global actions - self.app.phase = BuildPhase.CONSISTENCY_CHECK + self.phase = BuildPhase.CONSISTENCY_CHECK with progress_message(__('checking consistency')): self.env.check_consistency() else: if method == 'update' and not docnames: logger.info(bold(__('no targets are out of date.'))) - self.app.phase = BuildPhase.RESOLVING + self.phase = BuildPhase.RESOLVING # filter "docnames" (list of outdated files) by the updated # found_docs of the environment; this will remove docs that @@ -438,14 +444,14 @@ def build( docnames = set(docnames) & self.env.found_docs # determine if we can write in parallel - if parallel_available and self.app.parallel > 1 and self.allow_parallel: - self.parallel_ok = self.app.is_parallel_allowed('write') + if parallel_available and self._app.parallel > 1 and self.allow_parallel: + self.parallel_ok = self._app.is_parallel_allowed('write') else: self.parallel_ok = False # create a task executor to use for misc. "finish-up" tasks # if self.parallel_ok: - # self.finish_tasks = ParallelTasks(self.app.parallel) + # self.finish_tasks = ParallelTasks(self._app.parallel) # else: # for now, just execute them serially self.finish_tasks = SerialTasks() @@ -508,13 +514,13 @@ def read(self) -> list[str]: self.events.emit('env-before-read-docs', self.env, docnames) # check if we should do parallel or serial read - if parallel_available and self.app.parallel > 1: - par_ok = self.app.is_parallel_allowed('read') + if parallel_available and self._app.parallel > 1: + par_ok = self._app.is_parallel_allowed('read') else: par_ok = False if par_ok: - self._read_parallel(docnames, nproc=self.app.parallel) + self._read_parallel(docnames, nproc=self._app.parallel) else: self._read_serial(docnames) @@ -576,7 +582,7 @@ def _read_serial(self, docnames: list[str]) -> None: __('reading sources... '), 'purple', len(docnames), - self.app.verbosity, + self.config.verbosity, ): # remove all inventory entries for that file self.events.emit('env-purge-doc', self.env, docname) @@ -589,7 +595,11 @@ def _read_parallel(self, docnames: list[str], nproc: int) -> None: # create a status_iterator to step progressbar after reading a document # (see: ``merge()`` function) progress = status_iterator( - chunks, __('reading sources... '), 'purple', len(chunks), self.app.verbosity + chunks, + __('reading sources... '), + 'purple', + len(chunks), + self.config.verbosity, ) # clear all outdated docs at once @@ -598,7 +608,7 @@ def _read_parallel(self, docnames: list[str], nproc: int) -> None: self.env.clear_doc(docname) def read_process(docs: list[str]) -> bytes: - self.env.app = self.app + self.env._app = self._app for docname in docs: self.read_doc(docname, _cache=False) # allow pickling self to send it back @@ -606,7 +616,7 @@ def read_process(docs: list[str]) -> bytes: def merge(docs: list[str], otherenv: bytes) -> None: env = pickle.loads(otherenv) - self.env.merge_info_from(docs, env, self.app) + self.env.merge_info_from(docs, env, self._app) next(progress) @@ -629,24 +639,34 @@ def read_doc(self, docname: str, *, _cache: bool = True) -> None: if docutils_conf.is_file(): env.note_dependency(docutils_conf) - filename = str(env.doc2path(docname)) - filetype = get_filetype(self.app.config.source_suffix, filename) - publisher = self.env._registry.get_publisher(self.app, filetype) - self.env.current_document._parser = publisher.parser - # record_dependencies is mutable even though it is in settings, - # explicitly re-initialise for each document - publisher.settings.record_dependencies = DependencyList() - with ( - sphinx_domains(env), - rst.default_role(docname, self.config.default_role), - ): - # set up error_handler for the target document - error_handler = _UnicodeDecodeErrorHandler(docname) - codecs.register_error('sphinx', error_handler) # type: ignore[arg-type] + filename = env.doc2path(docname) - publisher.set_source(source_path=filename) - publisher.publish() - doctree = publisher.document + # set up error_handler for the target document + # xref RemovedInSphinx90Warning + error_handler = _UnicodeDecodeErrorHandler(docname) + codecs.register_error('sphinx', error_handler) # type: ignore[arg-type] + + # read the source file + content = filename.read_text( + encoding=env.settings['input_encoding'], errors='sphinx' + ) + + # TODO: move the "source-read" event to here. + + filetype = get_filetype(self.config.source_suffix, filename) + parser = self._registry.create_source_parser( + filetype, config=self.config, env=env + ) + doctree = _parse_str_to_doctree( + content, + filename=filename, + default_role=self.config.default_role, + default_settings=env.settings, + env=env, + events=self.events, + parser=parser, + transforms=self._registry.get_transforms(), + ) # store time of reading, for outdated files detection env.all_docs[docname] = time.time_ns() // 1_000 @@ -744,14 +764,14 @@ def write_documents(self, docnames: Set[str]) -> None: if self.parallel_ok: # number of subprocesses is parallel-1 because the main process # is busy loading doctrees and doing write_doc_serialized() - self._write_parallel(sorted_docnames, nproc=self.app.parallel - 1) + self._write_parallel(sorted_docnames, nproc=self._app.parallel - 1) else: self._write_serial(sorted_docnames) def _write_serial(self, docnames: Sequence[str]) -> None: with ( nullcontext() - if self.app._exception_on_warning + if self._app._exception_on_warning else logging.pending_warnings() ): for docname in status_iterator( @@ -759,27 +779,19 @@ def _write_serial(self, docnames: Sequence[str]) -> None: __('writing output... '), 'darkgreen', len(docnames), - self.app.verbosity, + self.config.verbosity, ): - self.app.phase = BuildPhase.RESOLVING - doctree = self.env.get_and_resolve_doctree(docname, self) - self.app.phase = BuildPhase.WRITING - self.write_doc_serialized(docname, doctree) - self.write_doc(docname, doctree) + _write_docname(docname, env=self.env, builder=self, tags=self.tags) def _write_parallel(self, docnames: Sequence[str], nproc: int) -> None: def write_process(docs: list[tuple[str, nodes.document]]) -> None: - self.app.phase = BuildPhase.WRITING + self.phase = BuildPhase.WRITING for docname, doctree in docs: self.write_doc(docname, doctree) # warm up caches/compile templates using the first document firstname, docnames = docnames[0], docnames[1:] - self.app.phase = BuildPhase.RESOLVING - doctree = self.env.get_and_resolve_doctree(firstname, self) - self.app.phase = BuildPhase.WRITING - self.write_doc_serialized(firstname, doctree) - self.write_doc(firstname, doctree) + _write_docname(firstname, env=self.env, builder=self, tags=self.tags) tasks = ParallelTasks(nproc) chunks = make_chunks(docnames, nproc) @@ -791,17 +803,19 @@ def write_process(docs: list[tuple[str, nodes.document]]) -> None: __('writing output... '), 'darkgreen', len(chunks), - self.app.verbosity, + self.config.verbosity, ) def on_chunk_done(args: list[tuple[str, nodes.document]], result: None) -> None: next(progress) - self.app.phase = BuildPhase.RESOLVING + self.phase = BuildPhase.RESOLVING for chunk in chunks: arg = [] for docname in chunk: - doctree = self.env.get_and_resolve_doctree(docname, self) + doctree = self.env.get_and_resolve_doctree( + docname, self, tags=self.tags + ) self.write_doc_serialized(docname, doctree) arg.append((docname, doctree)) tasks.add_task(write_process, arg, on_chunk_done) @@ -867,6 +881,22 @@ def get_builder_config(self, option: str, default: str) -> Any: return getattr(self.config, optname) +def _write_docname( + docname: str, + /, + *, + env: BuildEnvironment, + builder: Builder, + tags: Tags, +) -> None: + """Write a single document.""" + builder.phase = BuildPhase.RESOLVING + doctree = env.get_and_resolve_doctree(docname, builder=builder, tags=tags) + builder.phase = BuildPhase.WRITING + builder.write_doc_serialized(docname, doctree) + builder.write_doc(docname, doctree) + + class _UnicodeDecodeErrorHandler: """Custom error handler for open() that warns and replaces.""" @@ -874,20 +904,21 @@ def __init__(self, docname: str, /) -> None: self.docname = docname def __call__(self, error: UnicodeDecodeError) -> tuple[str, int]: - line_start = error.object.rfind(b'\n', 0, error.start) - line_end = error.object.find(b'\n', error.start) + obj = error.object + line_start = obj.rfind(b'\n', 0, error.start) + line_end = obj.find(b'\n', error.start) if line_end == -1: - line_end = len(error.object) - line_num = error.object.count(b'\n', 0, error.start) + 1 + line_end = len(obj) + line_num = obj.count(b'\n', 0, error.start) + 1 logger.warning( - __('undecodable source characters, replacing with "?": %r'), - ( - error.object[line_start + 1 : error.start] - + b'>>>' - + error.object[error.start : error.end] - + b'<<<' - + error.object[error.end : line_end] + __( + "undecodable source characters, replacing with '?': '%s>>>%s<<<%s'. " + 'This will become an error in Sphinx 9.0.' + # xref RemovedInSphinx90Warning ), + obj[line_start + 1 : error.start].decode(errors='backslashreplace'), + obj[error.start : error.end].decode(errors='backslashreplace'), + obj[error.end : line_end].decode(errors='backslashreplace'), location=(self.docname, line_num), ) return '?', error.end diff --git a/sphinx/builders/_epub_base.py b/sphinx/builders/_epub_base.py index a9527c3c0e3..3c7c93dfd1f 100644 --- a/sphinx/builders/_epub_base.py +++ b/sphinx/builders/_epub_base.py @@ -114,8 +114,8 @@ class NavPoint(NamedTuple): def sphinx_smarty_pants(t: str, language: str = 'en') -> str: t = t.replace('"', '"') - t = smartquotes.educateDashesOldSchool(t) # type: ignore[no-untyped-call] - t = smartquotes.educateQuotes(t, language) # type: ignore[no-untyped-call] + t = smartquotes.educateDashesOldSchool(t) + t = smartquotes.educateQuotes(t, language) t = t.replace('"', '"') return t @@ -233,7 +233,11 @@ def get_toc(self) -> None: and pre and post files not managed by Sphinx. """ doctree = self.env.get_and_resolve_doctree( - self.config.master_doc, self, prune_toctrees=False, includehidden=True + self.config.master_doc, + self, + tags=self.tags, + prune_toctrees=False, + includehidden=True, ) self.refnodes = self.get_refnodes(doctree, []) master_dir = Path(self.config.master_doc).parent @@ -279,16 +283,6 @@ def fix_ids(self, tree: nodes.document) -> None: Some readers crash because they interpret the part as a transport protocol specification. """ - - def update_node_id(node: Element) -> None: - """Update IDs of given *node*.""" - new_ids: list[str] = [] - for node_id in node['ids']: - new_id = self.fix_fragment('', node_id) - if new_id not in new_ids: - new_ids.append(new_id) - node['ids'] = new_ids - for reference in tree.findall(nodes.reference): if 'refuri' in reference: m = self.refuri_re.match(reference['refuri']) @@ -298,66 +292,75 @@ def update_node_id(node: Element) -> None: reference['refid'] = self.fix_fragment('', reference['refid']) for target in tree.findall(nodes.target): - update_node_id(target) + self._update_node_id(target) next_node: Node = target.next_node(ascend=True) if isinstance(next_node, nodes.Element): - update_node_id(next_node) + self._update_node_id(next_node) for desc_signature in tree.findall(addnodes.desc_signature): - update_node_id(desc_signature) + self._update_node_id(desc_signature) + + def _update_node_id(self, node: Element, /) -> None: + """Update IDs of given *node*.""" + new_ids: list[str] = [] + for node_id in node['ids']: + new_id = self.fix_fragment('', node_id) + if new_id not in new_ids: + new_ids.append(new_id) + node['ids'] = new_ids + + @staticmethod + def _make_footnote_ref(doc: nodes.document, label: str) -> nodes.footnote_reference: + """Create a footnote_reference node with children""" + footnote_ref = nodes.footnote_reference('[#]_') + footnote_ref.append(nodes.Text(label)) + doc.note_autofootnote_ref(footnote_ref) + return footnote_ref + + @staticmethod + def _make_footnote(doc: nodes.document, label: str, uri: str) -> nodes.footnote: + """Create a footnote node with children""" + footnote = nodes.footnote(uri) + para = nodes.paragraph() + para.append(nodes.Text(uri)) + footnote.append(para) + footnote.insert(0, nodes.label('', label)) + doc.note_autofootnote(footnote) + return footnote + + @staticmethod + def _footnote_spot(tree: nodes.document) -> tuple[Element, int]: + """Find or create a spot to place footnotes. + + The function returns the tuple (parent, index). + """ + # The code uses the following heuristic: + # a) place them after the last existing footnote + # b) place them after an (empty) Footnotes rubric + # c) create an empty Footnotes rubric at the end of the document + fns = list(tree.findall(nodes.footnote)) + if fns: + fn = fns[-1] + return fn.parent, fn.parent.index(fn) + 1 + for node in tree.findall(nodes.rubric): + if len(node) == 1 and node.astext() == FOOTNOTES_RUBRIC_NAME: + return node.parent, node.parent.index(node) + 1 + doc = next(tree.findall(nodes.document)) + rub = nodes.rubric() + rub.append(nodes.Text(FOOTNOTES_RUBRIC_NAME)) + doc.append(rub) + return doc, doc.index(rub) + 1 def add_visible_links( self, tree: nodes.document, show_urls: str = 'inline' ) -> None: """Add visible link targets for external links""" - - def make_footnote_ref( - doc: nodes.document, label: str - ) -> nodes.footnote_reference: - """Create a footnote_reference node with children""" - footnote_ref = nodes.footnote_reference('[#]_') - footnote_ref.append(nodes.Text(label)) - doc.note_autofootnote_ref(footnote_ref) - return footnote_ref - - def make_footnote(doc: nodes.document, label: str, uri: str) -> nodes.footnote: - """Create a footnote node with children""" - footnote = nodes.footnote(uri) - para = nodes.paragraph() - para.append(nodes.Text(uri)) - footnote.append(para) - footnote.insert(0, nodes.label('', label)) - doc.note_autofootnote(footnote) - return footnote - - def footnote_spot(tree: nodes.document) -> tuple[Element, int]: - """Find or create a spot to place footnotes. - - The function returns the tuple (parent, index). - """ - # The code uses the following heuristic: - # a) place them after the last existing footnote - # b) place them after an (empty) Footnotes rubric - # c) create an empty Footnotes rubric at the end of the document - fns = list(tree.findall(nodes.footnote)) - if fns: - fn = fns[-1] - return fn.parent, fn.parent.index(fn) + 1 - for node in tree.findall(nodes.rubric): - if len(node) == 1 and node.astext() == FOOTNOTES_RUBRIC_NAME: - return node.parent, node.parent.index(node) + 1 - doc = next(tree.findall(nodes.document)) - rub = nodes.rubric() - rub.append(nodes.Text(FOOTNOTES_RUBRIC_NAME)) - doc.append(rub) - return doc, doc.index(rub) + 1 - if show_urls == 'no': return if show_urls == 'footnote': doc = next(tree.findall(nodes.document)) - fn_spot, fn_idx = footnote_spot(tree) + fn_spot, fn_idx = self._footnote_spot(tree) nr = 1 for node in list(tree.findall(nodes.reference)): uri = node.get('refuri', '') @@ -371,9 +374,9 @@ def footnote_spot(tree: nodes.document) -> tuple[Element, int]: elif show_urls == 'footnote': label = FOOTNOTE_LABEL_TEMPLATE % nr nr += 1 - footnote_ref = make_footnote_ref(doc, label) + footnote_ref = self._make_footnote_ref(doc, label) node.parent.insert(idx, footnote_ref) - footnote = make_footnote(doc, label, uri) + footnote = self._make_footnote(doc, label, uri) fn_spot.insert(fn_idx, footnote) footnote_ref['refid'] = footnote['ids'][0] footnote.add_backref(footnote_ref['ids'][0]) @@ -422,7 +425,7 @@ def copy_image_files_pil(self) -> None: __('copying images... '), 'brown', len(self.images), - self.app.verbosity, + self.config.verbosity, ): dest = self.images[src] try: @@ -766,7 +769,11 @@ def build_toc(self) -> None: if self.config.epub_tocscope == 'default': doctree = self.env.get_and_resolve_doctree( - self.config.root_doc, self, prune_toctrees=False, includehidden=False + self.config.root_doc, + self, + tags=self.tags, + prune_toctrees=False, + includehidden=False, ) refnodes = self.get_refnodes(doctree, []) self.toc_add_files(refnodes) diff --git a/sphinx/builders/changes.py b/sphinx/builders/changes.py index aa926e0809c..6d38a6acbbc 100644 --- a/sphinx/builders/changes.py +++ b/sphinx/builders/changes.py @@ -23,14 +23,19 @@ class ChangesBuilder(Builder): - """Write a summary with all versionadded/changed/deprecated/removed directives.""" + """Write a summary with all version-related directives.""" name = 'changes' epilog = __('The overview file is in %(outdir)s.') def init(self) -> None: self.create_template_bridge() - theme_factory = HTMLThemeFactory(self.app) + theme_factory = HTMLThemeFactory( + confdir=self.confdir, + app=self._app, + config=self.config, + registry=self._registry, + ) self.theme = theme_factory.create('default') self.templates.init(self, self.theme) @@ -38,9 +43,13 @@ def get_outdated_docs(self) -> str: return str(self.outdir) typemap = { + 'version-added': 'added', 'versionadded': 'added', + 'version-changed': 'changed', 'versionchanged': 'changed', + 'version-deprecated': 'deprecated', 'deprecated': 'deprecated', + 'version-removed': 'removed', 'versionremoved': 'removed', } @@ -107,9 +116,13 @@ def write_documents(self, _docnames: Set[str]) -> None: f.write(self.templates.render('changes/versionchanges.html', ctx)) hltext = [ + f'.. version-added:: {version}', f'.. versionadded:: {version}', + f'.. version-changed:: {version}', f'.. versionchanged:: {version}', + f'.. version-deprecated:: {version}', f'.. deprecated:: {version}', + f'.. version-removed:: {version}', f'.. versionremoved:: {version}', ] diff --git a/sphinx/builders/epub3.py b/sphinx/builders/epub3.py index 2ea66c34b8b..e09b884046a 100644 --- a/sphinx/builders/epub3.py +++ b/sphinx/builders/epub3.py @@ -7,7 +7,6 @@ import html import os -import os.path import re import time from typing import TYPE_CHECKING, NamedTuple @@ -190,7 +189,11 @@ def build_navigation_doc(self) -> None: if self.config.epub_tocscope == 'default': doctree = self.env.get_and_resolve_doctree( - self.config.root_doc, self, prune_toctrees=False, includehidden=False + self.config.root_doc, + self, + tags=self.tags, + prune_toctrees=False, + includehidden=False, ) refnodes = self.get_refnodes(doctree, []) self.toc_add_files(refnodes) diff --git a/sphinx/builders/gettext.py b/sphinx/builders/gettext.py index f5f26ffcc88..fc659d744d5 100644 --- a/sphinx/builders/gettext.py +++ b/sphinx/builders/gettext.py @@ -2,7 +2,6 @@ from __future__ import annotations -import codecs import operator import os import os.path @@ -165,7 +164,7 @@ class I18nBuilder(Builder): def init(self) -> None: super().init() self.env.set_versioning_method(self.versioning_method, self.config.gettext_uuid) - self.tags = self.app.tags = I18nTags() + self.tags = self._app.tags = I18nTags() self.catalogs: defaultdict[str, Catalog] = defaultdict(Catalog) def get_target_uri(self, docname: str, typ: str | None = None) -> str: @@ -212,7 +211,7 @@ def should_write(filepath: Path, new_content: str) -> bool: if not filepath.exists(): return True try: - with codecs.open(str(filepath), encoding='utf-8') as oldpot: + with open(filepath, encoding='utf-8') as oldpot: old_content = oldpot.read() old_header_index = old_content.index('"POT-Creation-Date:') new_header_index = new_content.index('"POT-Creation-Date:') @@ -251,7 +250,7 @@ def init(self) -> None: def _collect_templates(self) -> set[str]: template_files = set() for template_path in self.config.templates_path: - tmpl_abs_path = self.app.srcdir / template_path + tmpl_abs_path = self.srcdir / template_path for dirpath, _dirs, files in walk(tmpl_abs_path): for fn in files: if fn.endswith('.html'): @@ -268,10 +267,14 @@ def _extract_from_template(self) -> None: extract_translations = self.templates.environment.extract_translations for template in status_iterator( - files, __('reading templates... '), 'purple', len(files), self.app.verbosity + files, + __('reading templates... '), + 'purple', + len(files), + self.config.verbosity, ): try: - with codecs.open(template, encoding='utf-8') as f: + with open(template, encoding='utf-8') as f: context = f.read() for line, _meth, msg in extract_translations(context): origin = MsgOrigin(source=template, line=line) @@ -307,7 +310,7 @@ def finish(self) -> None: __('writing message catalogs... '), 'darkgreen', len(self.catalogs), - self.app.verbosity, + self.config.verbosity, operator.itemgetter(0), ): # noop if config.gettext_compact is set @@ -315,14 +318,14 @@ def finish(self) -> None: context['messages'] = list(catalog) template_path = [ - self.app.srcdir / rel_path for rel_path in self.config.templates_path + self.srcdir / rel_path for rel_path in self.config.templates_path ] renderer = GettextRenderer(template_path, outdir=self.outdir) content = renderer.render('message.pot.jinja', context) pofn = self.outdir / f'{textdomain}.pot' if should_write(pofn, content): - with codecs.open(str(pofn), 'w', encoding='utf-8') as pofile: + with open(pofn, 'w', encoding='utf-8') as pofile: pofile.write(content) diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 5e6acdeaf9d..6146201fa9b 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -10,17 +10,16 @@ import re import shutil import sys -import warnings from pathlib import Path from types import NoneType from typing import TYPE_CHECKING from urllib.parse import quote +import docutils.parsers.rst import docutils.readers.doctree +import docutils.utils +import jinja2.exceptions from docutils import nodes -from docutils.core import Publisher -from docutils.frontend import OptionParser -from docutils.io import DocTreeInput, StringOutput from sphinx import __display_version__, package_dir from sphinx import version_info as sphinx_version @@ -48,7 +47,7 @@ from sphinx.util._timestamps import _format_rfc3339_microseconds from sphinx.util._uri import is_url from sphinx.util.display import progress_message, status_iterator -from sphinx.util.docutils import new_document +from sphinx.util.docutils import _get_settings, new_document from sphinx.util.fileutil import copy_asset from sphinx.util.i18n import format_date from sphinx.util.inventory import InventoryFile @@ -69,7 +68,6 @@ from typing import Any, TypeAlias from docutils.nodes import Node - from docutils.readers import Reader from sphinx.application import Sphinx from sphinx.config import Config @@ -93,6 +91,10 @@ bool, ] +_READER_TRANSFORMS = docutils.readers.doctree.Reader().get_transforms() +_PARSER_TRANSFORMS = docutils.parsers.rst.Parser().get_transforms() +_WRITER_TRANSFORMS = HTMLWriter(None).get_transforms() # type: ignore[arg-type] + def convert_locale_to_language_tag(locale: str | None) -> str | None: """Convert a locale string to a language tag (ex. en_US -> en-US). @@ -150,19 +152,13 @@ def __init__(self, app: Sphinx, env: BuildEnvironment) -> None: # JS files self._js_files: list[_JavaScript] = [] - # Cached Publisher for writing doctrees to HTML - reader: Reader[DocTreeInput] = docutils.readers.doctree.Reader( - parser_name='restructuredtext' - ) - pub = Publisher( - reader=reader, - parser=reader.parser, - writer=HTMLWriter(self), - source_class=DocTreeInput, - destination=StringOutput(encoding='unicode'), + # Cached settings for render_partial() + self._settings = _get_settings( + docutils.readers.doctree.Reader, + docutils.parsers.rst.Parser, + HTMLWriter, + defaults={'output_encoding': 'unicode', 'traceback': True}, ) - pub.get_settings(output_encoding='unicode', traceback=True) - self._publisher = pub def init(self) -> None: self.build_info = self.create_build_info() @@ -227,7 +223,12 @@ def get_theme_config(self) -> tuple[str, dict[str, str | int | bool]]: return self.config.html_theme, self.config.html_theme_options def init_templates(self) -> None: - theme_factory = HTMLThemeFactory(self.app) + theme_factory = HTMLThemeFactory( + confdir=self.confdir, + app=self._app, + config=self.config, + registry=self._registry, + ) theme_name, theme_options = self.get_theme_config() self.theme = theme_factory.create(theme_name) self.theme_options = theme_options @@ -254,11 +255,6 @@ def init_highlighter(self) -> None: self.dark_highlighter: PygmentsBridge | None if dark_style is not None: self.dark_highlighter = PygmentsBridge('html', dark_style) - self.app.add_css_file( - 'pygments_dark.css', - media='(prefers-color-scheme: dark)', - id='pygments_dark_css', - ) else: self.dark_highlighter = None @@ -272,11 +268,18 @@ def css_files(self) -> list[_CascadingStyleSheet]: def init_css_files(self) -> None: self._css_files = [] self.add_css_file('pygments.css', priority=200) + if self.dark_highlighter is not None: + self.add_css_file( + 'pygments_dark.css', + priority=200, + media='(prefers-color-scheme: dark)', + id='pygments_dark_css', + ) for filename in self._get_style_filenames(): self.add_css_file(filename, priority=200) - for filename, attrs in self.env._registry.css_files: + for filename, attrs in self._registry.css_files: self.add_css_file(filename, **attrs) for filename, attrs in self.get_builder_config('css_files', 'html'): @@ -303,7 +306,7 @@ def init_js_files(self) -> None: self.add_js_file('doctools.js', priority=200) self.add_js_file('sphinx_highlight.js', priority=200) - for filename, attrs in self.env._registry.js_files: + for filename, attrs in self._registry.js_files: self.add_js_file(filename or '', **attrs) for filename, attrs in self.get_builder_config('js_files', 'html'): @@ -328,7 +331,7 @@ def math_renderer_name(self) -> str | None: return name else: # not given: choose a math_renderer from registered ones as possible - renderers = list(self.env._registry.html_inline_math_renderers) + renderers = list(self._registry.html_inline_math_renderers) if len(renderers) == 1: # only default math_renderer (mathjax) is registered return renderers[0] @@ -421,12 +424,19 @@ def render_partial(self, node: Node | None) -> dict[str, str]: """Utility: Render a lone doctree node.""" if node is None: return {'fragment': ''} - - doc = new_document('') + doc = docutils.utils.new_document('', self._settings) doc.append(node) - self._publisher.set_source(doc) - self._publisher.publish() - return self._publisher.writer.parts + doc.transformer.add_transforms(_READER_TRANSFORMS) + doc.transformer.add_transforms(_PARSER_TRANSFORMS) + doc.transformer.add_transforms(_WRITER_TRANSFORMS) + doc.transformer.apply_transforms() + visitor: HTML5Translator = self.create_translator(doc, self) # type: ignore[assignment] + doc.walkabout(visitor) + parts = { + 'fragment': ''.join(visitor.fragment), + 'title': ''.join(visitor.title), + } + return parts def prepare_writing(self, docnames: Set[str]) -> None: # create the search indexer @@ -443,16 +453,9 @@ def prepare_writing(self, docnames: Set[str]) -> None: ) self.load_indexer(docnames) - self.docwriter = HTMLWriter(self) - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=DeprecationWarning) - # DeprecationWarning: The frontend.OptionParser class will be replaced - # by a subclass of argparse.ArgumentParser in Docutils 0.21 or later. - self.docsettings: Any = OptionParser( - defaults=self.env.settings, - components=(self.docwriter,), - read_config_files=True, - ).get_default_values() + self.docsettings = _get_settings( + HTMLWriter, defaults=self.env.settings, read_config_files=True + ) self.docsettings.compact_lists = bool(self.config.html_compact_lists) # determine the additional indices to include @@ -516,9 +519,9 @@ def prepare_writing(self, docnames: Set[str]) -> None: )) # add assets registered after ``Builder.init()``. - for css_filename, attrs in self.env._registry.css_files: + for css_filename, attrs in self._registry.css_files: self.add_css_file(css_filename, **attrs) - for js_filename, attrs in self.env._registry.js_files: + for js_filename, attrs in self._registry.js_files: self.add_js_file(js_filename or '', **attrs) # back up _css_files and _js_files to allow adding CSS/JS files to a specific page. @@ -659,7 +662,6 @@ def copy_assets(self) -> None: self.finish_tasks.join() def write_doc(self, docname: str, doctree: nodes.document) -> None: - destination = StringOutput(encoding='utf-8') doctree.settings = self.docsettings self.secnumbers = self.env.toc_secnumbers.get(docname, {}) @@ -667,13 +669,13 @@ def write_doc(self, docname: str, doctree: nodes.document) -> None: self.imgpath = relative_uri(self.get_target_uri(docname), '_images') self.dlpath = relative_uri(self.get_target_uri(docname), '_downloads') self.current_docname = docname - self.docwriter.write(doctree, destination) - self.docwriter.assemble_parts() - body = self.docwriter.parts['fragment'] - metatags = self.docwriter.clean_meta + visitor: HTML5Translator = self.create_translator(doctree, self) # type: ignore[assignment] + doctree.walkabout(visitor) + body = ''.join(visitor.fragment) + clean_meta = ''.join(visitor.meta[2:]) - ctx = self.get_doc_context(docname, body, metatags) - ctx['has_maths_elements'] = self.docwriter._has_maths_elements + ctx = self.get_doc_context(docname, body, clean_meta) + ctx['has_maths_elements'] = getattr(visitor, '_has_maths_elements', False) self.handle_page(docname, ctx, event_arg=doctree) def write_doc_serialized(self, docname: str, doctree: nodes.document) -> None: @@ -779,7 +781,7 @@ def copy_image_files(self) -> None: __('copying images... '), 'brown', len(self.images), - self.app.verbosity, + self.config.verbosity, stringify_func=stringify_func, ): dest = self.images[src] @@ -806,7 +808,7 @@ def to_relpath(f: str) -> str: __('copying downloadable files... '), 'brown', len(self.env.dlfiles), - self.app.verbosity, + self.config.verbosity, stringify_func=to_relpath, ): try: @@ -1028,7 +1030,7 @@ def _get_local_toctree( if kwargs.get('maxdepth') == '': # NoQA: PLC1901 kwargs.pop('maxdepth') toctree = global_toctree_for_doc( - self.env, docname, self, collapse=collapse, **kwargs + self.env, docname, self, tags=self.tags, collapse=collapse, **kwargs ) return self.render_partial(toctree)['fragment'] @@ -1038,31 +1040,30 @@ def get_output_path(self, page_name: str, /) -> Path: def get_outfilename(self, pagename: str) -> _StrPath: return _StrPath(self.get_output_path(pagename)) - def add_sidebars(self, pagename: str, ctx: dict[str, Any]) -> None: - def has_wildcard(pattern: str) -> bool: - return any(char in pattern for char in '*?[') - + def _get_sidebars(self, pagename: str, /) -> tuple[str, ...]: matched = None # default sidebars settings for selected theme - sidebars = list(self.theme.sidebar_templates) + sidebars = self.theme.sidebar_templates # user sidebar settings html_sidebars = self.get_builder_config('sidebars', 'html') msg = __('page %s matches two patterns in html_sidebars: %r and %r') for pattern, pat_sidebars in html_sidebars.items(): if patmatch(pagename, pattern): - if matched and has_wildcard(pattern): + if matched and _has_wildcard(pattern): # warn if both patterns contain wildcards - if has_wildcard(matched): + if _has_wildcard(matched): logger.warning(msg, pagename, matched, pattern) # else the already matched pattern is more specific # than the present one, because it contains no wildcard continue matched = pattern - sidebars = pat_sidebars + sidebars = tuple(pat_sidebars) + return sidebars - ctx['sidebars'] = list(sidebars) + def add_sidebars(self, pagename: str, ctx: dict[str, Any]) -> None: + ctx['sidebars'] = list(self._get_sidebars(pagename)) # --------- these are overwritten by the serialization builder @@ -1121,13 +1122,13 @@ def hasdoc(name: str) -> bool: ctx['hasdoc'] = hasdoc ctx['toctree'] = lambda **kwargs: self._get_local_toctree(pagename, **kwargs) - self.add_sidebars(pagename, ctx) + ctx['sidebars'] = list(self._get_sidebars(pagename)) ctx.update(addctx) # 'blah.html' should have content_root = './' not ''. ctx['content_root'] = (f'..{SEP}' * default_baseuri.count(SEP)) or f'.{SEP}' - outdir = self.app.outdir + outdir = self.outdir def css_tag(css: _CascadingStyleSheet) -> str: attrs = [ @@ -1221,6 +1222,19 @@ def js_tag(js: _JavaScript | str) -> str: ) return except Exception as exc: + if ( + isinstance(exc, jinja2.exceptions.UndefinedError) + and exc.message == "'style' is undefined" + ): + msg = __( + "The '%s' theme does not support this version of Sphinx, " + "because it uses the 'style' field in HTML templates, " + 'which was was deprecated in Sphinx 5.1 and removed in Sphinx 7.0. ' + "The theme must be updated to use the 'styles' field instead. " + 'See https://www.sphinx-doc.org/en/master/development/html_themes/templating.html#styles' + ) + raise ThemeError(msg % self.config.html_theme) from None + msg = __('An error happened in rendering the page %s.\nReason: %r') % ( pagename, exc, @@ -1277,6 +1291,10 @@ def dump_search_index(self) -> None: Path(search_index_tmp).replace(search_index_path) +def _has_wildcard(pattern: str, /) -> bool: + return any(char in pattern for char in '*?[') + + def convert_html_css_files(app: Sphinx, config: Config) -> None: """Convert string styled html_css_files to tuple styled one.""" html_css_files: list[tuple[str, dict[str, str]]] = [] diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index 5aeafca8bfd..69c11d515b8 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -4,12 +4,9 @@ import os import os.path -import warnings from pathlib import Path from typing import TYPE_CHECKING -from docutils.frontend import OptionParser - import sphinx.builders.latex.nodes # NoQA: F401 # Workaround: import this before writer to avoid ImportError from sphinx import addnodes, highlighting, package_dir from sphinx._cli.util.colour import darkgreen @@ -27,7 +24,7 @@ from sphinx.locale import _, __ from sphinx.util import logging, texescape from sphinx.util.display import progress_message, status_iterator -from sphinx.util.docutils import SphinxFileOutput, new_document +from sphinx.util.docutils import _get_settings, new_document from sphinx.util.fileutil import copy_asset_file from sphinx.util.i18n import format_date from sphinx.util.nodes import inline_all_toctrees @@ -132,7 +129,7 @@ def init(self) -> None: self.context: dict[str, Any] = {} self.docnames: Iterable[str] = {} self.document_data: list[tuple[str, str, str, str, str, bool]] = [] - self.themes = ThemeFactory(self.app) + self.themes = ThemeFactory(srcdir=self.srcdir, config=self.config) texescape.init() self.init_context() @@ -211,7 +208,7 @@ def init_context(self) -> None: def update_context(self) -> None: """Update template variables for .tex file just before writing.""" # Apply extension settings to context - registry = self.env._registry + registry = self._registry self.context['packages'] = registry.latex_packages self.context['packages_after_hyperref'] = registry.latex_packages_after_hyperref @@ -300,16 +297,9 @@ def copy_assets(self) -> None: self.copy_latex_additional_files() def write_documents(self, _docnames: Set[str]) -> None: - docwriter = LaTeXWriter(self) - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=DeprecationWarning) - # DeprecationWarning: The frontend.OptionParser class will be replaced - # by a subclass of argparse.ArgumentParser in Docutils 0.21 or later. - docsettings: Any = OptionParser( - defaults=self.env.settings, - components=(docwriter,), - read_config_files=True, - ).get_default_values() + docsettings = _get_settings( + LaTeXWriter, defaults=self.env.settings, read_config_files=True + ) for entry in self.document_data: docname, targetname, title, author, themename = entry[:5] @@ -317,11 +307,6 @@ def write_documents(self, _docnames: Set[str]) -> None: toctree_only = False if len(entry) > 5: toctree_only = entry[5] - destination = SphinxFileOutput( - destination_path=self.outdir / targetname, - encoding='utf-8', - overwrite_if_changed=True, - ) with progress_message(__('processing %s') % targetname, nonl=False): doctree = self.env.get_doctree(docname) toctree = next(doctree.findall(addnodes.toctree), None) @@ -352,8 +337,16 @@ def write_documents(self, _docnames: Set[str]) -> None: docsettings._docclass = theme.name doctree.settings = docsettings - docwriter.theme = theme - docwriter.write(doctree, destination) + visitor: LaTeXTranslator = self.create_translator(doctree, self, theme) # type: ignore[assignment] + doctree.walkabout(visitor) + output = visitor.astext() + destination_path = self.outdir / targetname + # https://github.com/sphinx-doc/sphinx/issues/4362 + if ( + not destination_path.is_file() + or destination_path.read_bytes() != output.encode() + ): + destination_path.write_text(output, encoding='utf-8') def get_contentsname(self, indexfile: str) -> str: tree = self.env.get_doctree(indexfile) @@ -481,7 +474,7 @@ def copy_image_files(self) -> None: __('copying images... '), 'brown', len(self.images), - self.app.verbosity, + self.config.verbosity, stringify_func=stringify_func, ): dest = self.images[src] @@ -513,9 +506,9 @@ def write_message_catalog(self) -> None: formats = self.config.numfig_format context = { 'addtocaptions': r'\@iden', - 'figurename': formats.get('figure', '').split('%s', 1), - 'tablename': formats.get('table', '').split('%s', 1), - 'literalblockname': formats.get('code-block', '').split('%s', 1), + 'figurename': formats.get('figure', '').split('%s', maxsplit=1), + 'tablename': formats.get('table', '').split('%s', maxsplit=1), + 'literalblockname': formats.get('code-block', '').split('%s', maxsplit=1), } if self.context['babel'] or self.context['polyglossia']: diff --git a/sphinx/builders/latex/theming.py b/sphinx/builders/latex/theming.py index f55c077c9ca..df8eb48ec4f 100644 --- a/sphinx/builders/latex/theming.py +++ b/sphinx/builders/latex/theming.py @@ -12,7 +12,6 @@ if TYPE_CHECKING: from pathlib import Path - from sphinx.application import Sphinx from sphinx.config import Config logger = logging.getLogger(__name__) @@ -102,11 +101,11 @@ def __init__(self, name: str, filename: Path) -> None: class ThemeFactory: """A factory class for LaTeX Themes.""" - def __init__(self, app: Sphinx) -> None: + def __init__(self, *, srcdir: Path, config: Config) -> None: self.themes: dict[str, Theme] = {} - self.theme_paths = [app.srcdir / p for p in app.config.latex_theme_path] - self.config = app.config - self.load_builtin_themes(app.config) + self.theme_paths = [srcdir / p for p in config.latex_theme_path] + self.config = config + self.load_builtin_themes(config) def load_builtin_themes(self, config: Config) -> None: """Load built-in themes.""" diff --git a/sphinx/builders/latex/transforms.py b/sphinx/builders/latex/transforms.py index 9fa180a7dd9..2d7cbb80809 100644 --- a/sphinx/builders/latex/transforms.py +++ b/sphinx/builders/latex/transforms.py @@ -40,7 +40,7 @@ class FootnoteDocnameUpdater(SphinxTransform): def apply(self, **kwargs: Any) -> None: matcher = NodeMatcher(*self.TARGET_NODES) for node in matcher.findall(self.document): - node['docname'] = self.env.docname + node['docname'] = self.env.current_document.docname class SubstitutionDefinitionsRemover(SphinxPostTransform): @@ -420,7 +420,7 @@ def depart_caption(self, node: nodes.caption) -> None: self.unrestrict(node) def visit_title(self, node: nodes.title) -> None: - if isinstance(node.parent, nodes.section | nodes.table): + if isinstance(node.parent, (nodes.section, nodes.table)): self.restrict(node) def depart_title(self, node: nodes.title) -> None: diff --git a/sphinx/builders/linkcheck.py b/sphinx/builders/linkcheck.py index 93ab2e78b00..d3ce638fea4 100644 --- a/sphinx/builders/linkcheck.py +++ b/sphinx/builders/linkcheck.py @@ -25,6 +25,7 @@ from sphinx._cli.util.colour import darkgray, darkgreen, purple, red, turquoise from sphinx.builders.dummy import DummyBuilder +from sphinx.errors import ConfigError from sphinx.locale import __ from sphinx.transforms.post_transforms import SphinxPostTransform from sphinx.util import logging, requests @@ -70,6 +71,15 @@ class _Status(StrEnum): DEFAULT_DELAY = 60.0 +@object.__new__ +class _SENTINEL_LAR: + def __repr__(self) -> str: + return '_SENTINEL_LAR' + + def __reduce__(self) -> str: + return self.__class__.__name__ + + class CheckExternalLinksBuilder(DummyBuilder): """Checks for broken external links.""" @@ -97,7 +107,7 @@ def finish(self) -> None: self.process_result(result) if self.broken_hyperlinks or self.timed_out_hyperlinks: - self.app.statuscode = 1 + self._app.statuscode = 1 def process_result(self, result: CheckResult) -> None: filename = self.env.doc2path(result.docname, False) @@ -129,7 +139,7 @@ def process_result(self, result: CheckResult) -> None: case _Status.WORKING: logger.info(darkgreen('ok ') + f'{res_uri}{result.message}') # NoQA: G003 case _Status.TIMEOUT: - if self.app.quiet: + if self.config.verbosity < 0: msg = 'timeout ' + f'{res_uri}{result.message}' logger.warning(msg, location=(result.docname, result.lineno)) else: @@ -144,7 +154,7 @@ def process_result(self, result: CheckResult) -> None: ) self.timed_out_hyperlinks += 1 case _Status.BROKEN: - if self.app.quiet: + if self.config.verbosity < 0: logger.warning( __('broken link: %s (%s)'), res_uri, @@ -178,7 +188,7 @@ def process_result(self, result: CheckResult) -> None: text = 'with unknown code' linkstat['text'] = text redirection = f'{text} to {result.message}' - if self.config.linkcheck_allowed_redirects: + if self.config.linkcheck_allowed_redirects is not _SENTINEL_LAR: msg = f'redirect {res_uri} - {redirection}' logger.warning(msg, location=(result.docname, result.lineno)) else: @@ -258,11 +268,11 @@ def _add_uri(self, uri: str, node: nodes.Element) -> None: :param uri: URI to add :param node: A node class where the URI was found """ - builder = cast('CheckExternalLinksBuilder', self.app.builder) + builder = cast('CheckExternalLinksBuilder', self.env._app.builder) hyperlinks = builder.hyperlinks - docname = self.env.docname + docname = self.env.current_document.docname - if newuri := self.app.events.emit_firstresult('linkcheck-process-uri', uri): + if newuri := self.env.events.emit_firstresult('linkcheck-process-uri', uri): uri = newuri try: @@ -721,6 +731,8 @@ def handle_starttag(self, tag: Any, attrs: Any) -> None: def _allowed_redirect( url: str, new_url: str, allowed_redirects: dict[re.Pattern[str], re.Pattern[str]] ) -> bool: + if allowed_redirects is _SENTINEL_LAR: + return False return any( from_url.match(url) and to_url.match(new_url) for from_url, to_url in allowed_redirects.items() @@ -748,20 +760,26 @@ def rewrite_github_anchor(app: Sphinx, uri: str) -> str | None: def compile_linkcheck_allowed_redirects(app: Sphinx, config: Config) -> None: - """Compile patterns in linkcheck_allowed_redirects to the regexp objects.""" - linkcheck_allowed_redirects = app.config.linkcheck_allowed_redirects - for url, pattern in list(linkcheck_allowed_redirects.items()): + """Compile patterns to the regexp objects.""" + if config.linkcheck_allowed_redirects is _SENTINEL_LAR: + return + if not isinstance(config.linkcheck_allowed_redirects, dict): + msg = __( + f'Invalid value `{config.linkcheck_allowed_redirects!r}` in ' + 'linkcheck_allowed_redirects. Expected a dictionary.' + ) + raise ConfigError(msg) + allowed_redirects = {} + for url, pattern in config.linkcheck_allowed_redirects.items(): try: - linkcheck_allowed_redirects[re.compile(url)] = re.compile(pattern) + allowed_redirects[re.compile(url)] = re.compile(pattern) except re.error as exc: logger.warning( __('Failed to compile regex in linkcheck_allowed_redirects: %r %s'), exc.pattern, exc.msg, ) - finally: - # Remove the original regexp-string - linkcheck_allowed_redirects.pop(url) + config.linkcheck_allowed_redirects = allowed_redirects def setup(app: Sphinx) -> ExtensionMetadata: @@ -772,7 +790,9 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_config_value( 'linkcheck_exclude_documents', [], '', types=frozenset({list, tuple}) ) - app.add_config_value('linkcheck_allowed_redirects', {}, '', types=frozenset({dict})) + app.add_config_value( + 'linkcheck_allowed_redirects', _SENTINEL_LAR, '', types=frozenset({dict}) + ) app.add_config_value('linkcheck_auth', [], '', types=frozenset({list, tuple})) app.add_config_value('linkcheck_request_headers', {}, '', types=frozenset({dict})) app.add_config_value('linkcheck_retries', 1, '', types=frozenset({int})) @@ -799,7 +819,8 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_event('linkcheck-process-uri') - app.connect('config-inited', compile_linkcheck_allowed_redirects, priority=800) + # priority 900 to happen after ``check_confval_types()`` + app.connect('config-inited', compile_linkcheck_allowed_redirects, priority=900) # FIXME: Disable URL rewrite handler for github.com temporarily. # See: https://github.com/sphinx-doc/sphinx/issues/9435 diff --git a/sphinx/builders/manpage.py b/sphinx/builders/manpage.py index 7b62b7dca5a..d30e697d292 100644 --- a/sphinx/builders/manpage.py +++ b/sphinx/builders/manpage.py @@ -2,25 +2,25 @@ from __future__ import annotations -import warnings from typing import TYPE_CHECKING -from docutils.frontend import OptionParser -from docutils.io import FileOutput - from sphinx import addnodes from sphinx._cli.util.colour import darkgreen from sphinx.builders import Builder from sphinx.locale import __ from sphinx.util import logging from sphinx.util.display import progress_message +from sphinx.util.docutils import _get_settings from sphinx.util.nodes import inline_all_toctrees from sphinx.util.osutil import ensuredir, make_filename_from_project -from sphinx.writers.manpage import ManualPageTranslator, ManualPageWriter +from sphinx.writers.manpage import ( + ManualPageTranslator, + ManualPageWriter, + NestedInlineTransform, +) if TYPE_CHECKING: from collections.abc import Set - from typing import Any from sphinx.application import Sphinx from sphinx.config import Config @@ -37,7 +37,7 @@ class ManualPageBuilder(Builder): epilog = __('The manual pages are in %(outdir)s.') default_translator_class = ManualPageTranslator - supported_image_types: list[str] = [] + supported_image_types = [] def init(self) -> None: if not self.config.man_pages: @@ -53,16 +53,9 @@ def get_target_uri(self, docname: str, typ: str | None = None) -> str: @progress_message(__('writing')) def write_documents(self, _docnames: Set[str]) -> None: - docwriter = ManualPageWriter(self) - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=DeprecationWarning) - # DeprecationWarning: The frontend.OptionParser class will be replaced - # by a subclass of argparse.ArgumentParser in Docutils 0.21 or later. - docsettings: Any = OptionParser( - defaults=self.env.settings, - components=(docwriter,), - read_config_files=True, - ).get_default_values() + docsettings = _get_settings( + ManualPageWriter, defaults=self.env.settings, read_config_files=True + ) for info in self.config.man_pages: docname, name, description, authors, section = info @@ -91,10 +84,6 @@ def write_documents(self, _docnames: Set[str]) -> None: targetname = f'{name}.{section}' logger.info('%s { ', darkgreen(targetname)) - destination = FileOutput( - destination_path=self.outdir / targetname, - encoding='utf-8', - ) tree = self.env.get_doctree(docname) docnames: set[str] = set() @@ -108,7 +97,11 @@ def write_documents(self, _docnames: Set[str]) -> None: for pendingnode in largetree.findall(addnodes.pending_xref): pendingnode.replace_self(pendingnode.children) - docwriter.write(largetree, destination) + transform = NestedInlineTransform(largetree) + transform.apply() + visitor: ManualPageTranslator = self.create_translator(largetree, self) # type: ignore[assignment] + largetree.walkabout(visitor) + (self.outdir / targetname).write_text(visitor.astext(), encoding='utf-8') def finish(self) -> None: pass diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index c95603927ce..1888f6679d1 100644 --- a/sphinx/builders/singlehtml.py +++ b/sphinx/builders/singlehtml.py @@ -84,7 +84,7 @@ def _get_local_toctree( if kwargs.get('maxdepth') == '': # NoQA: PLC1901 kwargs.pop('maxdepth') toctree = global_toctree_for_doc( - self.env, docname, self, collapse=collapse, **kwargs + self.env, docname, self, tags=self.tags, collapse=collapse, **kwargs ) return self.render_partial(toctree)['fragment'] @@ -141,7 +141,7 @@ def assemble_toc_fignumbers( def get_doc_context(self, docname: str, body: str, metatags: str) -> dict[str, Any]: # no relation links... toctree = global_toctree_for_doc( - self.env, self.config.root_doc, self, collapse=False + self.env, self.config.root_doc, self, tags=self.tags, collapse=False ) # if there is no toctree, toc is None if toctree: diff --git a/sphinx/builders/texinfo.py b/sphinx/builders/texinfo.py index 79afafab84d..ba3cd0c0d10 100644 --- a/sphinx/builders/texinfo.py +++ b/sphinx/builders/texinfo.py @@ -3,12 +3,9 @@ from __future__ import annotations import os.path -import warnings from typing import TYPE_CHECKING from docutils import nodes -from docutils.frontend import OptionParser -from docutils.io import FileOutput from sphinx import addnodes, package_dir from sphinx._cli.util.colour import darkgreen @@ -18,14 +15,13 @@ from sphinx.locale import _, __ from sphinx.util import logging from sphinx.util.display import progress_message, status_iterator -from sphinx.util.docutils import new_document +from sphinx.util.docutils import _get_settings, new_document from sphinx.util.nodes import inline_all_toctrees from sphinx.util.osutil import SEP, copyfile, ensuredir, make_filename_from_project from sphinx.writers.texinfo import TexinfoTranslator, TexinfoWriter if TYPE_CHECKING: from collections.abc import Iterable, Set - from typing import Any from docutils.nodes import Node @@ -106,10 +102,6 @@ def write_documents(self, _docnames: Set[str]) -> None: toctree_only = False if len(entry) > 7: toctree_only = entry[7] - destination = FileOutput( - destination_path=self.outdir / targetname, - encoding='utf-8', - ) with progress_message(__('processing %s') % targetname, nonl=False): appendices = self.config.texinfo_appendices or [] doctree = self.assemble_doctree( @@ -118,16 +110,9 @@ def write_documents(self, _docnames: Set[str]) -> None: with progress_message(__('writing')): self.post_process_images(doctree) - docwriter = TexinfoWriter(self) - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=DeprecationWarning) - # DeprecationWarning: The frontend.OptionParser class will be replaced - # by a subclass of argparse.ArgumentParser in Docutils 0.21 or later. - settings: Any = OptionParser( - defaults=self.env.settings, - components=(docwriter,), - read_config_files=True, - ).get_default_values() + settings = _get_settings( + TexinfoWriter, defaults=self.env.settings, read_config_files=True + ) settings.author = author settings.title = title settings.texinfo_filename = targetname[:-5] + '.info' @@ -137,7 +122,10 @@ def write_documents(self, _docnames: Set[str]) -> None: settings.texinfo_dir_description = description or '' settings.docname = docname doctree.settings = settings - docwriter.write(doctree, destination) + visitor: TexinfoTranslator = self.create_translator(doctree, self) # type: ignore[assignment] + doctree.walkabout(visitor) + visitor.finish() + (self.outdir / targetname).write_text(visitor.output, encoding='utf-8') self.copy_image_files(targetname[:-5]) def assemble_doctree( @@ -198,7 +186,7 @@ def copy_image_files(self, targetname: str) -> None: __('copying images... '), 'brown', len(self.images), - self.app.verbosity, + self.config.verbosity, stringify_func=stringify_func, ): dest = self.images[src] diff --git a/sphinx/builders/text.py b/sphinx/builders/text.py index bd7731fdb49..186e71e79da 100644 --- a/sphinx/builders/text.py +++ b/sphinx/builders/text.py @@ -4,16 +4,14 @@ from typing import TYPE_CHECKING -from docutils.io import StringOutput - from sphinx.builders import Builder from sphinx.locale import __ from sphinx.util import logging from sphinx.util.osutil import _last_modified_time -from sphinx.writers.text import TextTranslator, TextWriter +from sphinx.writers.text import TextTranslator if TYPE_CHECKING: - from collections.abc import Iterator, Set + from collections.abc import Iterator from docutils import nodes @@ -59,19 +57,16 @@ def get_outdated_docs(self) -> Iterator[str]: def get_target_uri(self, docname: str, typ: str | None = None) -> str: return '' - def prepare_writing(self, docnames: Set[str]) -> None: - self.writer = TextWriter(self) - def write_doc(self, docname: str, doctree: nodes.document) -> None: self.current_docname = docname self.secnumbers = self.env.toc_secnumbers.get(docname, {}) - destination = StringOutput(encoding='utf-8') - self.writer.write(doctree, destination) + visitor: TextTranslator = self.create_translator(doctree, self) # type: ignore[assignment] + doctree.walkabout(visitor) + output = visitor.body out_file_name = self.outdir / (docname + self.out_suffix) out_file_name.parent.mkdir(parents=True, exist_ok=True) try: - with open(out_file_name, 'w', encoding='utf-8') as f: - f.write(self.writer.output) + out_file_name.write_text(output, encoding='utf-8') except OSError as err: logger.warning(__('error writing file %s: %s'), out_file_name, err) diff --git a/sphinx/builders/xml.py b/sphinx/builders/xml.py index fab0f7cb5c4..cf86ea5afef 100644 --- a/sphinx/builders/xml.py +++ b/sphinx/builders/xml.py @@ -5,17 +5,15 @@ from typing import TYPE_CHECKING from docutils import nodes -from docutils.io import StringOutput from docutils.writers.docutils_xml import XMLTranslator from sphinx.builders import Builder from sphinx.locale import __ from sphinx.util import logging from sphinx.util.osutil import _last_modified_time -from sphinx.writers.xml import PseudoXMLWriter, XMLWriter if TYPE_CHECKING: - from collections.abc import Iterator, Set + from collections.abc import Iterator from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata @@ -33,8 +31,6 @@ class XMLBuilder(Builder): out_suffix = '.xml' allow_parallel = True - _writer_class: type[XMLWriter | PseudoXMLWriter] = XMLWriter - writer: XMLWriter | PseudoXMLWriter default_translator_class = XMLTranslator def init(self) -> None: @@ -61,9 +57,6 @@ def get_outdated_docs(self) -> Iterator[str]: def get_target_uri(self, docname: str, typ: str | None = None) -> str: return docname - def prepare_writing(self, docnames: Set[str]) -> None: - self.writer = self._writer_class(self) - def write_doc(self, docname: str, doctree: nodes.document) -> None: # work around multiple string % tuple issues in docutils; # replace tuples in attribute values with lists @@ -79,16 +72,25 @@ def write_doc(self, docname: str, doctree: nodes.document) -> None: for i, val in enumerate(value): if isinstance(val, tuple): value[i] = list(val) - destination = StringOutput(encoding='utf-8') - self.writer.write(doctree, destination) + output = self._translate(doctree) out_file_name = self.outdir / (docname + self.out_suffix) out_file_name.parent.mkdir(parents=True, exist_ok=True) try: - with open(out_file_name, 'w', encoding='utf-8') as f: - f.write(self.writer.output) + out_file_name.write_text(output, encoding='utf-8') except OSError as err: logger.warning(__('error writing file %s: %s'), out_file_name, err) + def _translate(self, doctree: nodes.document) -> str: + doctree.settings.newlines = doctree.settings.indents = self.config.xml_pretty + doctree.settings.xml_declaration = True + doctree.settings.doctype_declaration = True + + # copied from docutils.writers.docutils_xml.Writer.translate() + # so that we can override the translator class + visitor: XMLTranslator = self.create_translator(doctree) + doctree.walkabout(visitor) + return ''.join(visitor.output) + def finish(self) -> None: pass @@ -102,7 +104,8 @@ class PseudoXMLBuilder(XMLBuilder): out_suffix = '.pseudoxml' - _writer_class = PseudoXMLWriter + def _translate(self, doctree: nodes.document) -> str: + return doctree.pformat() def setup(app: Sphinx) -> ExtensionMetadata: diff --git a/sphinx/cmd/build.py b/sphinx/cmd/build.py index 11a70df0c6c..58f3ad26746 100644 --- a/sphinx/cmd/build.py +++ b/sphinx/cmd/build.py @@ -371,14 +371,14 @@ def _parse_confoverrides( val: Any for val in define: try: - key, val = val.split('=', 1) + key, _, val = val.partition('=') except ValueError: parser.error(__('-D option argument must be in the form name=value')) confoverrides[key] = val for val in htmldefine: try: - key, val = val.split('=') + key, _, val = val.partition('=') except ValueError: parser.error(__('-A option argument must be in the form name=value')) with contextlib.suppress(ValueError): diff --git a/sphinx/cmd/quickstart.py b/sphinx/cmd/quickstart.py index 0275343e847..a11856e497a 100644 --- a/sphinx/cmd/quickstart.py +++ b/sphinx/cmd/quickstart.py @@ -89,7 +89,8 @@ # function to get input from terminal -- overridden by the test suite -def term_input(prompt: str) -> str: +# Arguments are positional-only to match ``input``. +def term_input(prompt: str, /) -> str: if sys.platform == 'win32': # Important: On windows, readline is not enabled by default. In these # environment, escape sequences have been broken. To avoid the @@ -801,7 +802,7 @@ def main(argv: Sequence[str] = (), /) -> int: print('[Interrupted.]') return 130 # 128 + SIGINT - for variable in d.get('variables', []): + for variable in d.get('variables', []): # type: ignore[union-attr] try: name, value = variable.split('=') d[name] = value diff --git a/sphinx/config.py b/sphinx/config.py index bedc69f2337..f82e2b761ee 100644 --- a/sphinx/config.py +++ b/sphinx/config.py @@ -65,7 +65,7 @@ def is_serializable(obj: object, *, _seen: frozenset[int] = frozenset()) -> bool is_serializable(key, _seen=seen) and is_serializable(value, _seen=seen) for key, value in obj.items() ) - elif isinstance(obj, list | tuple | set | frozenset): + elif isinstance(obj, (list, tuple, set, frozenset)): seen = _seen | {id(obj)} return all(is_serializable(item, _seen=seen) for item in obj) @@ -89,7 +89,7 @@ def __repr__(self) -> str: return f'ENUM({", ".join(sorted(map(repr, self._candidates)))})' def match(self, value: str | bool | None | Sequence[str | bool | None]) -> bool: # NoQA: RUF036 - if isinstance(value, str | bool | None): + if isinstance(value, (str, bool, types.NoneType)): return value in self._candidates return all(item in self._candidates for item in value) @@ -320,7 +320,7 @@ def __init__( for name in list(self._overrides.keys()): if '.' in name: - real_name, key = name.split('.', 1) + real_name, _, key = name.partition('.') raw_config.setdefault(real_name, {})[key] = self._overrides.pop(name) self.setup: _ExtensionSetupFunc | None = raw_config.get('setup') @@ -333,6 +333,8 @@ def __init__( raw_config['extensions'] = extensions self.extensions: list[str] = raw_config.get('extensions', []) + self._verbosity: int = 0 # updated in Sphinx.__init__() + @property def values(self) -> dict[str, _Opt]: return self._options @@ -341,12 +343,17 @@ def values(self) -> dict[str, _Opt]: def overrides(self) -> dict[str, Any]: return self._overrides + @property + def verbosity(self) -> int: + return self._verbosity + @classmethod def read( cls: type[Config], confdir: str | os.PathLike[str], - overrides: dict[str, Any] | None = None, - tags: Tags | None = None, + *, + overrides: dict[str, Any], + tags: Tags, ) -> Config: """Create a Config object from configuration file.""" filename = Path(confdir, CONFIG_FILENAME) @@ -354,23 +361,7 @@ def read( raise ConfigError( __("config directory doesn't contain a conf.py file (%s)") % confdir ) - namespace = eval_config_file(filename, tags) - - # Note: Old sphinx projects have been configured as "language = None" because - # sphinx-quickstart previously generated this by default. - # To keep compatibility, they should be fallback to 'en' for a while - # (This conversion should not be removed before 2025-01-01). - if namespace.get('language', ...) is None: - logger.warning( - __( - "Invalid configuration value found: 'language = None'. " - 'Update your configuration to a valid language code. ' - "Falling back to 'en' (English)." - ) - ) - namespace['language'] = 'en' - - return cls(namespace, overrides) + return _read_conf_py(filename, overrides=overrides, tags=tags) def convert_overrides(self, name: str, value: str) -> Any: opt = self._options[name] @@ -583,12 +574,28 @@ def __setstate__(self, state: dict[str, Any]) -> None: self.__dict__.update(state) -def eval_config_file( - filename: str | os.PathLike[str], tags: Tags | None -) -> dict[str, Any]: - """Evaluate a config file.""" - filename = Path(filename) +def _read_conf_py(conf_path: Path, *, overrides: dict[str, Any], tags: Tags) -> Config: + """Create a Config object from a conf.py file.""" + namespace = eval_config_file(conf_path, tags) + # Note: Old sphinx projects have been configured as "language = None" because + # sphinx-quickstart previously generated this by default. + # To keep compatibility, they should be fallback to 'en' for a while + # (This conversion should not be removed before 2025-01-01). + if namespace.get('language', ...) is None: + logger.warning( + __( + "Invalid configuration value found: 'language = None'. " + 'Update your configuration to a valid language code. ' + "Falling back to 'en' (English)." + ) + ) + namespace['language'] = 'en' + return Config(namespace, overrides) + + +def eval_config_file(filename: Path, tags: Tags) -> dict[str, Any]: + """Evaluate a config file.""" namespace: dict[str, Any] = { '__file__': str(filename), 'tags': tags, @@ -623,12 +630,12 @@ def _validate_valid_types( ) -> frozenset[type] | ENUM: if not valid_types: return frozenset() - if isinstance(valid_types, frozenset | ENUM): + if isinstance(valid_types, (frozenset, ENUM)): return valid_types if isinstance(valid_types, type): return frozenset((valid_types,)) if valid_types is Any: - return frozenset({Any}) # type: ignore[arg-type] + return frozenset({Any}) if isinstance(valid_types, set): return frozenset(valid_types) try: @@ -656,7 +663,7 @@ def convert_source_suffix(app: Sphinx, config: Config) -> None: source_suffix, config.source_suffix, ) - elif isinstance(source_suffix, list | tuple): + elif isinstance(source_suffix, (list, tuple)): # if list, considers as all of them are default filetype config.source_suffix = dict.fromkeys(source_suffix, 'restructuredtext') logger.info( @@ -888,7 +895,21 @@ def check_master_doc( return changed +def deprecate_source_encoding(_app: Sphinx, config: Config) -> None: + """Warn on non-UTF 8 source_encoding.""" + # RemovedInSphinx10Warning + if config.source_encoding.lower() not in {'utf-8', 'utf-8-sig', 'utf8'}: + msg = _( + 'Support for source encodings other than UTF-8 ' + 'is deprecated and will be removed in Sphinx 10. ' + 'Please comment at https://github.com/sphinx-doc/sphinx/issues/13665 ' + 'if this causes a problem.' + ) + logger.warning(msg) + + def setup(app: Sphinx) -> ExtensionMetadata: + app.connect('config-inited', deprecate_source_encoding, priority=790) app.connect('config-inited', convert_source_suffix, priority=800) app.connect('config-inited', convert_highlight_options, priority=800) app.connect('config-inited', init_numfig_format, priority=800) diff --git a/sphinx/directives/__init__.py b/sphinx/directives/__init__.py index b4fb7f76006..c442ea8e6c8 100644 --- a/sphinx/directives/__init__.py +++ b/sphinx/directives/__init__.py @@ -201,7 +201,7 @@ def run(self) -> list[Node]: * parse the content and handle doc fields in it """ if ':' in self.name: - self.domain, self.objtype = self.name.split(':', 1) + self.domain, _, self.objtype = self.name.partition(':') else: self.domain, self.objtype = '', self.name self.indexnode = addnodes.index(entries=[]) diff --git a/sphinx/directives/other.py b/sphinx/directives/other.py index d9c2b98fd84..090e58a4cf0 100644 --- a/sphinx/directives/other.py +++ b/sphinx/directives/other.py @@ -63,7 +63,7 @@ class TocTree(SphinxDirective): def run(self) -> list[Node]: subnode = addnodes.toctree() - subnode['parent'] = self.env.docname + subnode['parent'] = self.env.current_document.docname # (title, ref) pairs, where ref may be a document, or an external link, # and title may be None if the document's title is to be used @@ -90,7 +90,7 @@ def parse_content(self, toctree: addnodes.toctree) -> None: """Populate ``toctree['entries']`` and ``toctree['includefiles']`` from content.""" generated_docnames = frozenset(StandardDomain._virtual_doc_names) suffixes = self.config.source_suffix - current_docname = self.env.docname + current_docname = self.env.current_document.docname glob = toctree['glob'] # glob target documents @@ -267,7 +267,7 @@ def run(self) -> list[Node]: if len(children) != 1 or not isinstance(children[0], nodes.bullet_list): logger.warning( __('.. acks content is not a list'), - location=(self.env.docname, self.lineno), + location=(self.env.current_document.docname, self.lineno), ) return [] return [addnodes.acks('', *children)] @@ -290,7 +290,7 @@ def run(self) -> list[Node]: if len(children) != 1 or not isinstance(children[0], nodes.bullet_list): logger.warning( __('.. hlist content is not a list'), - location=(self.env.docname, self.lineno), + location=(self.env.current_document.docname, self.lineno), ) return [] fulllist = children[0] @@ -388,7 +388,7 @@ def _insert_input(include_lines: list[str], source: str) -> None: text = '\n'.join(include_lines[:-2]) path = Path(relpath(Path(source).resolve(), start=self.env.srcdir)) - docname = self.env.docname + docname = self.env.current_document.docname # Emit the "include-read" event arg = [text] @@ -411,7 +411,7 @@ def _insert_input(include_lines: list[str], source: str) -> None: if self.arguments[0].startswith('<') and self.arguments[0].endswith('>'): # docutils "standard" includes, do not do path processing return super().run() - rel_filename, filename = self.env.relfn2path(self.arguments[0]) + _rel_filename, filename = self.env.relfn2path(self.arguments[0]) self.arguments[0] = str(filename) self.env.note_included(filename) return super().run() diff --git a/sphinx/directives/patches.py b/sphinx/directives/patches.py index 94184de502c..5b346d6a737 100644 --- a/sphinx/directives/patches.py +++ b/sphinx/directives/patches.py @@ -9,12 +9,11 @@ from docutils.parsers.rst import directives from docutils.parsers.rst.directives import images, tables from docutils.parsers.rst.directives.misc import Meta -from docutils.parsers.rst.roles import set_classes from sphinx.directives import optional_int from sphinx.locale import __ from sphinx.util import logging -from sphinx.util.docutils import SphinxDirective +from sphinx.util.docutils import SphinxDirective, _normalize_options from sphinx.util.nodes import set_source_info from sphinx.util.osutil import SEP, relpath @@ -72,11 +71,11 @@ def run(self) -> list[Node]: 'an absolute path as a relative path from source directory. ' 'Please update your document.' ), - location=(env.docname, self.lineno), + location=(env.current_document.docname, self.lineno), ) else: abspath = env.srcdir / self.options['file'][1:] - doc_dir = env.doc2path(env.docname).parent + doc_dir = env.doc2path(env.current_document.docname).parent self.options['file'] = relpath(abspath, doc_dir) return super().run() @@ -100,7 +99,7 @@ class Code(SphinxDirective): def run(self) -> list[Node]: self.assert_has_content() - set_classes(self.options) + self.options = _normalize_options(self.options) code = '\n'.join(self.content) node = nodes.literal_block( code, @@ -162,7 +161,7 @@ def run(self) -> list[Node]: latex, latex, classes=self.options.get('class', []), - docname=self.env.docname, + docname=self.env.current_document.docname, number=None, label=label, ) @@ -180,7 +179,7 @@ def add_target(self, ret: list[Node]) -> None: # assign label automatically if math_number_all enabled if node['label'] == '' or (self.config.math_number_all and not node['label']): # NoQA: PLC1901 seq = self.env.new_serialno('sphinx.ext.math#equations') - node['label'] = f'{self.env.docname}:{seq}' + node['label'] = f'{self.env.current_document.docname}:{seq}' # no targets and numbers are needed if not node['label']: @@ -188,7 +187,9 @@ def add_target(self, ret: list[Node]) -> None: # register label to domain domain = self.env.domains.math_domain - domain.note_equation(self.env.docname, node['label'], location=node) + domain.note_equation( + self.env.current_document.docname, node['label'], location=node + ) node['number'] = domain.get_equation_number_for(node['label']) # add target node @@ -213,7 +214,7 @@ class Rubric(SphinxDirective): } def run(self) -> list[nodes.rubric | nodes.system_message]: - set_classes(self.options) + self.options = _normalize_options(self.options) rubric_text = self.arguments[0] textnodes, messages = self.parse_inline(rubric_text, lineno=self.lineno) if 'heading-level' in self.options: diff --git a/sphinx/domains/__init__.py b/sphinx/domains/__init__.py index 61be6049579..17aa7bdc453 100644 --- a/sphinx/domains/__init__.py +++ b/sphinx/domains/__init__.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Sequence, Set - from typing import Any + from typing import Any, ClassVar from docutils import nodes from docutils.nodes import Element, Node @@ -82,27 +82,27 @@ class Domain: """ #: domain name: should be short, but unique - name = '' + name: ClassVar[str] = '' #: domain label: longer, more descriptive (used in messages) - label = '' + label: ClassVar[str] = '' #: type (usually directive) name -> ObjType instance - object_types: dict[str, ObjType] = {} + object_types: ClassVar[dict[str, ObjType]] = {} #: directive name -> directive class - directives: dict[str, type[Directive]] = {} + directives: ClassVar[dict[str, type[Directive]]] = {} #: role name -> role callable - roles: dict[str, RoleFunction | XRefRole] = {} + roles: ClassVar[dict[str, RoleFunction | XRefRole]] = {} #: a list of Index subclasses - indices: list[type[Index]] = [] + indices: ClassVar[list[type[Index]]] = [] #: role name -> a warning message if reference is missing - dangling_warnings: dict[str, str] = {} + dangling_warnings: ClassVar[dict[str, str]] = {} #: node_class -> (enum_node_type, title_getter) - enumerable_nodes: dict[type[Node], tuple[str, TitleGetter | None]] = {} + enumerable_nodes: ClassVar[dict[type[Node], tuple[str, TitleGetter | None]]] = {} #: data value for a fresh environment - initial_data: dict[str, Any] = {} + initial_data: ClassVar[dict[str, Any]] = {} #: data value data: dict[str, Any] #: data version, bump this when the format of `self.data` changes - data_version = 0 + data_version: ClassVar[int] = 0 def __init__(self, env: BuildEnvironment) -> None: domain_data: dict[str, dict[str, Any]] = env.domaindata @@ -113,10 +113,10 @@ def __init__(self, env: BuildEnvironment) -> None: self._type2role: dict[str, str] = {} # convert class variables to instance one (to enhance through API) - self.object_types = dict(self.object_types) - self.directives = dict(self.directives) - self.roles = dict(self.roles) - self.indices = list(self.indices) + self.object_types = dict(self.object_types) # type: ignore[misc] + self.directives = dict(self.directives) # type: ignore[misc] + self.roles = dict(self.roles) # type: ignore[misc] + self.indices = list(self.indices) # type: ignore[misc] if self.name not in domain_data: assert isinstance(self.initial_data, dict) diff --git a/sphinx/domains/_index.py b/sphinx/domains/_index.py index afb5be4007b..3845a97ba7b 100644 --- a/sphinx/domains/_index.py +++ b/sphinx/domains/_index.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from collections.abc import Iterable + from typing import ClassVar from sphinx.domains import Domain @@ -73,9 +74,9 @@ class Index(ABC): :rst:role:`ref` role. """ - name: str - localname: str - shortname: str | None = None + name: ClassVar[str] + localname: ClassVar[str] + shortname: ClassVar[str | None] = None def __init__(self, domain: Domain) -> None: if not self.name or self.localname is None: diff --git a/sphinx/domains/c/__init__.py b/sphinx/domains/c/__init__.py index 6dbbf70ac92..194916122cd 100644 --- a/sphinx/domains/c/__init__.py +++ b/sphinx/domains/c/__init__.py @@ -39,7 +39,7 @@ from docutils.nodes import Element, Node, TextElement, system_message - from sphinx.addnodes import pending_xref + from sphinx.addnodes import desc_signature, pending_xref from sphinx.application import Sphinx from sphinx.builders import Builder from sphinx.domains.c._symbol import LookupKey @@ -156,7 +156,7 @@ def _add_enumerator_to_parent(self, ast: ASTDeclaration) -> None: parent=target_symbol, ident=symbol.ident, declaration=decl_clone, - docname=self.env.docname, + docname=self.env.current_document.docname, line=self.get_source_info()[1], ) @@ -259,7 +259,9 @@ def handle_signature(self, sig: str, signode: TextElement) -> ASTDeclaration: try: symbol = parent_symbol.add_declaration( - ast, docname=self.env.docname, line=self.get_source_info()[1] + ast, + docname=self.env.current_document.docname, + line=self.get_source_info()[1], ) # append the new declaration to the sibling list assert symbol.siblingAbove is None @@ -309,6 +311,32 @@ def after_content(self) -> None: self.env.current_document.c_parent_symbol = self.oldParentSymbol self.env.ref_context['c:parent_key'] = self.oldParentKey + def _object_hierarchy_parts(self, sig_node: desc_signature) -> tuple[str, ...]: + last_symbol: Symbol = self.env.current_document.c_last_symbol + return tuple(map(str, last_symbol.get_full_nested_name().names)) + + def _toc_entry_name(self, sig_node: desc_signature) -> str: + if not sig_node.get('_toc_parts'): + return '' + + config = self.config + objtype = sig_node.parent.get('objtype') + if config.add_function_parentheses and ( + objtype in {'function', 'method'} + or (objtype == 'macro' and '(' in sig_node.rawsource) + ): + parens = '()' + else: + parens = '' + *parents, name = sig_node['_toc_parts'] + if config.toc_object_entries_show_parents == 'domain': + return '::'.join((name + parens,)) + if config.toc_object_entries_show_parents == 'hide': + return name + parens + if config.toc_object_entries_show_parents == 'all': + return '::'.join([*parents, name + parens]) + return '' + class CMemberObject(CObject): object_type = 'member' @@ -642,7 +670,7 @@ def run(self) -> list[Node]: The code is therefore based on the ObjectDescription version. """ if ':' in self.name: - self.domain, self.objtype = self.name.split(':', 1) + self.domain, _, self.objtype = self.name.partition(':') else: self.domain, self.objtype = '', self.name @@ -792,7 +820,7 @@ class CDomain(Domain): 'expr': CExprRole(asCode=True), 'texpr': CExprRole(asCode=False), } - initial_data: dict[str, Symbol | dict[str, tuple[str, str, str]]] = { + initial_data: ClassVar[dict[str, Symbol | dict[str, tuple[str, str, str]]]] = { 'root_symbol': Symbol(None, None, None, None, None), 'objects': {}, # fullname -> docname, node_id, objtype } diff --git a/sphinx/domains/c/_parser.py b/sphinx/domains/c/_parser.py index 7eb09f6f7b8..c59352b6ee2 100644 --- a/sphinx/domains/c/_parser.py +++ b/sphinx/domains/c/_parser.py @@ -230,7 +230,7 @@ def _parse_paren_expression_list(self) -> ASTParenExprList | None: # # expression-list # -> initializer-list - exprs, trailing_comma = self._parse_initializer_list( + exprs, _trailing_comma = self._parse_initializer_list( 'parenthesized expression-list', '(', ')' ) if exprs is None: @@ -369,10 +369,7 @@ def _parse_logical_or_expression(self) -> ASTExpression: # pm = cast .*, ->* def _parse_bin_op_expr(self: DefinitionParser, op_id: int) -> ASTExpression: if op_id + 1 == len(_expression_bin_ops): - - def parser() -> ASTExpression: - return self._parse_cast_expression() - + parser = self._parse_cast_expression else: def parser() -> ASTExpression: @@ -760,10 +757,7 @@ def _parse_declarator_name_suffix( if self.skip_string(']'): size = None else: - - def parser() -> ASTExpression: - return self._parse_expression() - + parser = self._parse_expression size = self._parse_expression_fallback([']'], parser) self.skip_ws() if not self.skip_string(']'): @@ -1025,10 +1019,7 @@ def _parse_enumerator(self) -> ASTEnumerator: init = None if self.skip_string('='): self.skip_ws() - - def parser() -> ASTExpression: - return self._parse_constant_expression() - + parser = self._parse_constant_expression init_val = self._parse_expression_fallback([], parser) init = ASTInitializer(init_val) return ASTEnumerator(name, init, attrs) diff --git a/sphinx/domains/c/_symbol.py b/sphinx/domains/c/_symbol.py index cb43910e7ab..7ac555415ac 100644 --- a/sphinx/domains/c/_symbol.py +++ b/sphinx/domains/c/_symbol.py @@ -445,43 +445,19 @@ def on_missing_qualified_symbol( # First check if one of those with a declaration matches. # If it's a function, we need to compare IDs, # otherwise there should be only one symbol with a declaration. - def make_cand_symbol() -> Symbol: - if Symbol.debug_lookup: - Symbol.debug_print('begin: creating candidate symbol') - symbol = Symbol( - parent=lookup_result.parent_symbol, - ident=lookup_result.ident, - declaration=declaration, - docname=docname, - line=line, - ) - if Symbol.debug_lookup: - Symbol.debug_print('end: creating candidate symbol') - return symbol if len(with_decl) == 0: cand_symbol = None else: - cand_symbol = make_cand_symbol() - - def handle_duplicate_declaration( - symbol: Symbol, cand_symbol: Symbol - ) -> None: - if Symbol.debug_lookup: - Symbol.debug_indent += 1 - Symbol.debug_print('redeclaration') - Symbol.debug_indent -= 1 - Symbol.debug_indent -= 2 - # Redeclaration of the same symbol. - # Let the new one be there, but raise an error to the client - # so it can use the real symbol as subscope. - # This will probably result in a duplicate id warning. - cand_symbol.isRedeclaration = True - raise _DuplicateSymbolError(symbol, declaration) + cand_symbol = self._make_cand_symbol( + lookup_result, declaration, docname, line + ) if declaration.objectType != 'function': assert len(with_decl) <= 1 - handle_duplicate_declaration(with_decl[0], cand_symbol) + self._handle_duplicate_declaration( + with_decl[0], cand_symbol, declaration + ) # (not reachable) # a function, so compare IDs @@ -493,7 +469,7 @@ def handle_duplicate_declaration( if Symbol.debug_lookup: Symbol.debug_print('old_id: ', old_id) if cand_id == old_id: - handle_duplicate_declaration(symbol, cand_symbol) + self._handle_duplicate_declaration(symbol, cand_symbol, declaration) # (not reachable) # no candidate symbol found with matching ID # if there is an empty symbol, fill that one @@ -507,7 +483,7 @@ def handle_duplicate_declaration( if cand_symbol is not None: return cand_symbol else: - return make_cand_symbol() + return self._make_cand_symbol(lookup_result, declaration, docname, line) else: if Symbol.debug_lookup: Symbol.debug_print( @@ -529,6 +505,42 @@ def handle_duplicate_declaration( symbol._fill_empty(declaration, docname, line) return symbol + @staticmethod + def _make_cand_symbol( + lookup_result: SymbolLookupResult, + declaration: ASTDeclaration | None, + docname: str | None, + line: int | None, + ) -> Symbol: + if Symbol.debug_lookup: + Symbol.debug_print('begin: creating candidate symbol') + symbol = Symbol( + parent=lookup_result.parent_symbol, + ident=lookup_result.ident, + declaration=declaration, + docname=docname, + line=line, + ) + if Symbol.debug_lookup: + Symbol.debug_print('end: creating candidate symbol') + return symbol + + @staticmethod + def _handle_duplicate_declaration( + symbol: Symbol, cand_symbol: Symbol, declaration: ASTDeclaration + ) -> None: + if Symbol.debug_lookup: + Symbol.debug_indent += 1 + Symbol.debug_print('redeclaration') + Symbol.debug_indent -= 1 + Symbol.debug_indent -= 2 + # Redeclaration of the same symbol. + # Let the new one be there, but raise an error to the client + # so it can use the real symbol as subscope. + # This will probably result in a duplicate id warning. + cand_symbol.isRedeclaration = True + raise _DuplicateSymbolError(symbol, declaration) + def merge_with( self, other: Symbol, docnames: list[str], env: BuildEnvironment ) -> None: diff --git a/sphinx/domains/changeset.py b/sphinx/domains/changeset.py index 2d520e6ff64..4349595f9df 100644 --- a/sphinx/domains/changeset.py +++ b/sphinx/domains/changeset.py @@ -21,6 +21,12 @@ from sphinx.environment import BuildEnvironment from sphinx.util.typing import ExtensionMetadata, OptionSpec +name_aliases = { + 'version-added': 'versionadded', + 'version-changed': 'versionchanged', + 'version-deprecated': 'deprecated', + 'version-removed': 'versionremoved', +} versionlabels = { 'versionadded': _('Added in version %s'), @@ -56,12 +62,13 @@ class VersionChange(SphinxDirective): option_spec: ClassVar[OptionSpec] = {} def run(self) -> list[Node]: + name = name_aliases.get(self.name, self.name) node = addnodes.versionmodified() node.document = self.state.document self.set_source_info(node) - node['type'] = self.name + node['type'] = name node['version'] = self.arguments[0] - text = versionlabels[self.name] % self.arguments[0] + text = versionlabels[name] % self.arguments[0] if len(self.arguments) == 2: inodes, messages = self.parse_inline( self.arguments[1], lineno=self.lineno + 1 @@ -73,7 +80,7 @@ def run(self) -> list[Node]: messages = [] if self.content: node += self.parse_content_to_nodes() - classes = ['versionmodified', versionlabel_classes[self.name]] + classes = ['versionmodified', versionlabel_classes[name]] if len(node) > 0 and isinstance(node[0], nodes.paragraph): # the contents start with a paragraph if node[0].rawsource: @@ -121,7 +128,7 @@ class ChangeSetDomain(Domain): name = 'changeset' label = 'changeset' - initial_data: dict[str, dict[str, list[ChangeSet]]] = { + initial_data: ClassVar[dict[str, dict[str, list[ChangeSet]]]] = { 'changes': {}, # version -> list of ChangeSet } @@ -135,7 +142,7 @@ def note_changeset(self, node: addnodes.versionmodified) -> None: objname = self.env.current_document.obj_desc_name changeset = ChangeSet( node['type'], - self.env.docname, + self.env.current_document.docname, node.line, # type: ignore[arg-type] module, objname, @@ -168,9 +175,13 @@ def get_changesets_for(self, version: str) -> list[ChangeSet]: def setup(app: Sphinx) -> ExtensionMetadata: app.add_domain(ChangeSetDomain) + app.add_directive('version-deprecated', VersionChange) app.add_directive('deprecated', VersionChange) + app.add_directive('version-added', VersionChange) app.add_directive('versionadded', VersionChange) + app.add_directive('version-changed', VersionChange) app.add_directive('versionchanged', VersionChange) + app.add_directive('version-removed', VersionChange) app.add_directive('versionremoved', VersionChange) return { diff --git a/sphinx/domains/citation.py b/sphinx/domains/citation.py index 49b74cca269..da7fc6a3fdd 100644 --- a/sphinx/domains/citation.py +++ b/sphinx/domains/citation.py @@ -83,7 +83,7 @@ def note_citation(self, node: nodes.citation) -> None: def note_citation_reference(self, node: pending_xref) -> None: docnames = self.citation_refs.setdefault(node['reftarget'], set()) - docnames.add(self.env.docname) + docnames.add(self.env.current_document.docname) def check_consistency(self) -> None: for name, (docname, _labelid, lineno) in self.citations.items(): @@ -106,7 +106,7 @@ def resolve_xref( node: pending_xref, contnode: Element, ) -> nodes.reference | None: - docname, labelid, lineno = self.citations.get(target, ('', '', 0)) + docname, labelid, _lineno = self.citations.get(target, ('', '', 0)) if not docname: return None @@ -139,7 +139,7 @@ def apply(self, **kwargs: Any) -> None: domain = self.env.domains.citation_domain for node in self.document.findall(nodes.citation): # register citation node to domain - node['docname'] = self.env.docname + node['docname'] = self.env.current_document.docname domain.note_citation(node) # mark citation labels as not smartquoted diff --git a/sphinx/domains/cpp/__init__.py b/sphinx/domains/cpp/__init__.py index 75d7732a405..0ccdc106c44 100644 --- a/sphinx/domains/cpp/__init__.py +++ b/sphinx/domains/cpp/__init__.py @@ -219,7 +219,7 @@ def _add_enumerator_to_parent(self, ast: ASTDeclaration) -> None: templateParams=None, templateArgs=None, declaration=decl_clone, - docname=self.env.docname, + docname=self.env.current_document.docname, line=self.get_source_info()[1], ) @@ -374,7 +374,9 @@ def handle_signature(self, sig: str, signode: desc_signature) -> ASTDeclaration: try: symbol = parent_symbol.add_declaration( - ast, docname=self.env.docname, line=self.get_source_info()[1] + ast, + docname=self.env.current_document.docname, + line=self.get_source_info()[1], ) # append the new declaration to the sibling list assert symbol.siblingAbove is None @@ -744,7 +746,7 @@ def apply(self, **kwargs: Any) -> None: template_decls = ns.templatePrefix.templates else: template_decls = [] - symbols, fail_reason = parent_symbol.find_name( + symbols, _fail_reason = parent_symbol.find_name( nestedName=name, templateDecls=template_decls, typ='any', @@ -812,7 +814,7 @@ def run(self) -> list[Node]: The code is therefore based on the ObjectDescription version. """ if ':' in self.name: - self.domain, self.objtype = self.name.split(':', 1) + self.domain, _, self.objtype = self.name.partition(':') else: self.domain, self.objtype = '', self.name @@ -1056,6 +1058,15 @@ def merge_domaindata(self, docnames: Set[str], otherdata: dict[str, Any]) -> Non logger.debug('\tresult end') logger.debug('merge_domaindata end') + def _check_type(self, typ: str, decl_typ: str) -> bool: + if typ == 'any': + return True + objtypes = self.objtypes_for_role(typ) + if objtypes: + return decl_typ in objtypes + logger.debug(f'Type is {typ}, declaration type is {decl_typ}') # NoQA: G004 + raise AssertionError + def _resolve_xref_inner( self, env: BuildEnvironment, @@ -1150,16 +1161,7 @@ def _resolve_xref_inner( typ = typ.removeprefix('cpp:') decl_typ = s.declaration.objectType - def check_type() -> bool: - if typ == 'any': - return True - objtypes = self.objtypes_for_role(typ) - if objtypes: - return decl_typ in objtypes - logger.debug(f'Type is {typ}, declaration type is {decl_typ}') # NoQA: G004 - raise AssertionError - - if not check_type(): + if not self._check_type(typ, decl_typ): logger.warning( 'cpp:%s targets a %s (%s).', typ, @@ -1299,6 +1301,12 @@ def get_full_qualified_name(self, node: Element) -> str | None: return f'{parent_name}::{target}' +def _init_stuff(app: Sphinx) -> None: + Symbol.debug_lookup = app.config.cpp_debug_lookup + Symbol.debug_show_tree = app.config.cpp_debug_show_tree + app.config.cpp_index_common_prefix.sort(reverse=True) + + def setup(app: Sphinx) -> ExtensionMetadata: app.add_domain(CPPDomain) app.add_config_value('cpp_index_common_prefix', [], 'env', types=frozenset({list})) @@ -1318,12 +1326,7 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_config_value('cpp_debug_lookup', False, '', types=frozenset({bool})) app.add_config_value('cpp_debug_show_tree', False, '', types=frozenset({bool})) - def init_stuff(app: Sphinx) -> None: - Symbol.debug_lookup = app.config.cpp_debug_lookup - Symbol.debug_show_tree = app.config.cpp_debug_show_tree - app.config.cpp_index_common_prefix.sort(reverse=True) - - app.connect('builder-inited', init_stuff) + app.connect('builder-inited', _init_stuff) return { 'version': 'builtin', diff --git a/sphinx/domains/cpp/_parser.py b/sphinx/domains/cpp/_parser.py index d28c474795d..2055a942c68 100644 --- a/sphinx/domains/cpp/_parser.py +++ b/sphinx/domains/cpp/_parser.py @@ -365,7 +365,7 @@ def _parse_paren_expression_list(self) -> ASTParenExprList: # # expression-list # -> initializer-list - exprs, trailing_comma = self._parse_initializer_list( + exprs, _trailing_comma = self._parse_initializer_list( 'parenthesized expression-list', '(', ')' ) if exprs is None: @@ -438,9 +438,7 @@ def _parse_postfix_expression(self) -> ASTPostfixExpr: if not self.skip_string('('): self.fail("Expected '(' in '%s'." % cast) - def parser() -> ASTExpression: - return self._parse_expression() - + parser = self._parse_expression expr = self._parse_expression_fallback([')'], parser) self.skip_ws() if not self.skip_string(')'): @@ -459,10 +457,7 @@ def parser() -> ASTExpression: except DefinitionError as e_type: self.pos = pos try: - - def parser() -> ASTExpression: - return self._parse_expression() - + parser = self._parse_expression expr = self._parse_expression_fallback([')'], parser) prefix = ASTTypeId(expr, isType=False) if not self.skip_string(')'): @@ -1423,9 +1418,7 @@ def _parse_declarator_name_suffix( array_ops.append(ASTArray(None)) continue - def parser() -> ASTExpression: - return self._parse_expression() - + parser = self._parse_expression value = self._parse_expression_fallback([']'], parser) if not self.skip_string(']'): self.fail("Expected ']' in end of array operator.") diff --git a/sphinx/domains/cpp/_symbol.py b/sphinx/domains/cpp/_symbol.py index 36b965e52ae..7449e616a03 100644 --- a/sphinx/domains/cpp/_symbol.py +++ b/sphinx/domains/cpp/_symbol.py @@ -38,6 +38,10 @@ def __str__(self) -> str: return 'Internal C++ duplicate symbol error:\n%s' % self.symbol.dump(0) +class _QualifiedSymbolIsTemplateParam(Exception): + pass + + class SymbolLookupResult: __slots__ = ( 'symbols', @@ -419,53 +423,19 @@ def _find_named_symbols( if not _is_specialization(template_params, template_args): template_args = None - def matches(s: Symbol) -> bool: - if s.identOrOp != ident_or_op: - return False - if (s.templateParams is None) != (template_params is None): - if template_params is not None: - # we query with params, they must match params - return False - if not template_shorthand: - # we don't query with params, and we do care about them - return False - if template_params: - # TODO: do better comparison - if str(s.templateParams) != str(template_params): - return False - if (s.templateArgs is None) != (template_args is None): - return False - if s.templateArgs: - # TODO: do better comparison - if str(s.templateArgs) != str(template_args): - return False - return True - - def candidates() -> Iterator[Symbol]: - s = self - if Symbol.debug_lookup: - Symbol.debug_print('searching in self:') - logger.debug(s.to_string(Symbol.debug_indent + 1), end='') - while True: - if match_self: - yield s - if recurse_in_anon: - yield from s.children_recurse_anon - else: - yield from s._children - - if s.siblingAbove is None: - break - s = s.siblingAbove - if Symbol.debug_lookup: - Symbol.debug_print('searching in sibling:') - logger.debug(s.to_string(Symbol.debug_indent + 1), end='') - - for s in candidates(): + for s in self._candidates( + match_self=match_self, recurse_in_anon=recurse_in_anon + ): if Symbol.debug_lookup: Symbol.debug_print('candidate:') logger.debug(s.to_string(Symbol.debug_indent + 1), end='') - if matches(s): + if self._matches( + s, + ident_or_op=ident_or_op, + template_params=template_params, + template_args=template_args, + template_shorthand=template_shorthand, + ): if Symbol.debug_lookup: Symbol.debug_indent += 1 Symbol.debug_print('matches') @@ -476,6 +446,59 @@ def candidates() -> Iterator[Symbol]: if Symbol.debug_lookup: Symbol.debug_indent -= 2 + @staticmethod + def _matches( + s: Symbol, + /, + *, + ident_or_op: ASTIdentifier | ASTOperator, + template_params: ASTTemplateParams | ASTTemplateIntroduction, + template_args: ASTTemplateArgs, + template_shorthand: bool, + ) -> bool: + if s.identOrOp != ident_or_op: + return False + if (s.templateParams is None) != (template_params is None): + if template_params is not None: + # we query with params, they must match params + return False + if not template_shorthand: + # we don't query with params, and we do care about them + return False + if template_params: + # TODO: do better comparison + if str(s.templateParams) != str(template_params): + return False + if (s.templateArgs is None) != (template_args is None): + return False + if s.templateArgs: + # TODO: do better comparison + if str(s.templateArgs) != str(template_args): + return False + return True + + def _candidates( + self, *, match_self: bool, recurse_in_anon: bool + ) -> Iterator[Symbol]: + s = self + if Symbol.debug_lookup: + Symbol.debug_print('searching in self:') + logger.debug(s.to_string(Symbol.debug_indent + 1), end='') + while True: + if match_self: + yield s + if recurse_in_anon: + yield from s.children_recurse_anon + else: + yield from s._children + + if s.siblingAbove is None: + break + s = s.siblingAbove + if Symbol.debug_lookup: + Symbol.debug_print('searching in sibling:') + logger.debug(s.to_string(Symbol.debug_indent + 1), end='') + def _symbol_lookup( self, nested_name: ASTNestedName, @@ -661,34 +684,10 @@ def _add_symbols( Symbol.debug_print('decl: ', declaration) Symbol.debug_print(f'location: {docname}:{line}') - def on_missing_qualified_symbol( - parent_symbol: Symbol, - ident_or_op: ASTIdentifier | ASTOperator, - template_params: Any, - template_args: ASTTemplateArgs, - ) -> Symbol | None: - if Symbol.debug_lookup: - Symbol.debug_indent += 1 - Symbol.debug_print('_add_symbols, on_missing_qualified_symbol:') - Symbol.debug_indent += 1 - Symbol.debug_print('template_params:', template_params) - Symbol.debug_print('ident_or_op: ', ident_or_op) - Symbol.debug_print('template_args: ', template_args) - Symbol.debug_indent -= 2 - return Symbol( - parent=parent_symbol, - identOrOp=ident_or_op, - templateParams=template_params, - templateArgs=template_args, - declaration=None, - docname=None, - line=None, - ) - lookup_result = self._symbol_lookup( nested_name, template_decls, - on_missing_qualified_symbol, + _on_missing_qualified_symbol_fresh, strict_template_param_arg_lists=True, ancestor_lookup_type=None, template_shorthand=False, @@ -759,45 +758,18 @@ def on_missing_qualified_symbol( # First check if one of those with a declaration matches. # If it's a function, we need to compare IDs, # otherwise there should be only one symbol with a declaration. - def make_cand_symbol() -> Symbol: - if Symbol.debug_lookup: - Symbol.debug_print('begin: creating candidate symbol') - symbol = Symbol( - parent=lookup_result.parent_symbol, - identOrOp=lookup_result.ident_or_op, - templateParams=lookup_result.template_params, - templateArgs=lookup_result.template_args, - declaration=declaration, - docname=docname, - line=line, - ) - if Symbol.debug_lookup: - Symbol.debug_print('end: creating candidate symbol') - return symbol - if len(with_decl) == 0: cand_symbol = None else: - cand_symbol = make_cand_symbol() - - def handle_duplicate_declaration( - symbol: Symbol, cand_symbol: Symbol - ) -> None: - if Symbol.debug_lookup: - Symbol.debug_indent += 1 - Symbol.debug_print('redeclaration') - Symbol.debug_indent -= 1 - Symbol.debug_indent -= 2 - # Redeclaration of the same symbol. - # Let the new one be there, but raise an error to the client - # so it can use the real symbol as subscope. - # This will probably result in a duplicate id warning. - cand_symbol.isRedeclaration = True - raise _DuplicateSymbolError(symbol, declaration) + cand_symbol = self._make_cand_symbol( + lookup_result, declaration, docname, line + ) if declaration.objectType != 'function': assert len(with_decl) <= 1 - handle_duplicate_declaration(with_decl[0], cand_symbol) + self._handle_duplicate_declaration( + with_decl[0], cand_symbol, declaration + ) # (not reachable) # a function, so compare IDs @@ -808,13 +780,13 @@ def handle_duplicate_declaration( # but all existing must be functions as well, # otherwise we declare it to be a duplicate if symbol.declaration.objectType != 'function': - handle_duplicate_declaration(symbol, cand_symbol) + self._handle_duplicate_declaration(symbol, cand_symbol, declaration) # (not reachable) old_id = symbol.declaration.get_newest_id() if Symbol.debug_lookup: Symbol.debug_print('old_id: ', old_id) if cand_id == old_id: - handle_duplicate_declaration(symbol, cand_symbol) + self._handle_duplicate_declaration(symbol, cand_symbol, declaration) # (not reachable) # no candidate symbol found with matching ID # if there is an empty symbol, fill that one @@ -824,12 +796,12 @@ def handle_duplicate_declaration( if cand_symbol is not None: Symbol.debug_print('result is already created cand_symbol') else: - Symbol.debug_print('result is make_cand_symbol()') + Symbol.debug_print('result is self._make_cand_symbol()') Symbol.debug_indent -= 2 if cand_symbol is not None: return cand_symbol else: - return make_cand_symbol() + return self._make_cand_symbol(lookup_result, declaration, docname, line) else: if Symbol.debug_lookup: Symbol.debug_print( @@ -851,6 +823,44 @@ def handle_duplicate_declaration( symbol._fill_empty(declaration, docname, line) return symbol + @staticmethod + def _make_cand_symbol( + lookup_result: SymbolLookupResult, + declaration: ASTDeclaration | None, + docname: str | None, + line: int | None, + ) -> Symbol: + if Symbol.debug_lookup: + Symbol.debug_print('begin: creating candidate symbol') + symbol = Symbol( + parent=lookup_result.parent_symbol, + identOrOp=lookup_result.ident_or_op, + templateParams=lookup_result.template_params, + templateArgs=lookup_result.template_args, + declaration=declaration, + docname=docname, + line=line, + ) + if Symbol.debug_lookup: + Symbol.debug_print('end: creating candidate symbol') + return symbol + + @staticmethod + def _handle_duplicate_declaration( + symbol: Symbol, cand_symbol: Symbol, declaration: ASTDeclaration + ) -> None: + if Symbol.debug_lookup: + Symbol.debug_indent += 1 + Symbol.debug_print('redeclaration') + Symbol.debug_indent -= 1 + Symbol.debug_indent -= 2 + # Redeclaration of the same symbol. + # Let the new one be there, but raise an error to the client + # so it can use the real symbol as subscope. + # This will probably result in a duplicate id warning. + cand_symbol.isRedeclaration = True + raise _DuplicateSymbolError(symbol, declaration) + def merge_with( self, other: Symbol, docnames: list[str], env: BuildEnvironment ) -> None: @@ -859,12 +869,6 @@ def merge_with( Symbol.debug_print('merge_with:') assert other is not None - def unconditional_add(self: Symbol, other_child: Symbol) -> None: - # TODO: hmm, should we prune by docnames? - self._children.append(other_child) - other_child.parent = self - other_child._assert_invariants() - if Symbol.debug_lookup: Symbol.debug_indent += 1 for other_child in other._children: @@ -874,7 +878,7 @@ def unconditional_add(self: Symbol, other_child: Symbol) -> None: ) Symbol.debug_indent += 1 if other_child.isRedeclaration: - unconditional_add(self, other_child) + self._unconditional_add(other_child) if Symbol.debug_lookup: Symbol.debug_print('is_redeclaration') Symbol.debug_indent -= 1 @@ -898,7 +902,7 @@ def unconditional_add(self: Symbol, other_child: Symbol) -> None: Symbol.debug_print('non-duplicate candidate symbols:', len(symbols)) if len(symbols) == 0: - unconditional_add(self, other_child) + self._unconditional_add(other_child) if Symbol.debug_lookup: Symbol.debug_indent -= 1 continue @@ -929,7 +933,7 @@ def unconditional_add(self: Symbol, other_child: Symbol) -> None: if Symbol.debug_lookup: Symbol.debug_indent -= 1 if our_child is None: - unconditional_add(self, other_child) + self._unconditional_add(other_child) continue if other_child.declaration and other_child.docname in docnames: if not our_child.declaration: @@ -978,6 +982,12 @@ def unconditional_add(self: Symbol, other_child: Symbol) -> None: if Symbol.debug_lookup: Symbol.debug_indent -= 2 + def _unconditional_add(self, other_child: Symbol) -> None: + # TODO: hmm, should we prune by docnames? + self._children.append(other_child) + other_child.parent = self + other_child._assert_invariants() + def add_name( self, nestedName: ASTNestedName, @@ -1125,29 +1135,11 @@ def find_name( Symbol.debug_print('recurseInAnon: ', recurseInAnon) Symbol.debug_print('searchInSiblings: ', searchInSiblings) - class QualifiedSymbolIsTemplateParam(Exception): - pass - - def on_missing_qualified_symbol( - parent_symbol: Symbol, - ident_or_op: ASTIdentifier | ASTOperator, - template_params: Any, - template_args: ASTTemplateArgs, - ) -> Symbol | None: - # TODO: Maybe search without template args? - # Though, the correct_primary_template_args does - # that for primary templates. - # Is there another case where it would be good? - if parent_symbol.declaration is not None: - if parent_symbol.declaration.objectType == 'templateParam': - raise QualifiedSymbolIsTemplateParam - return None - try: lookup_result = self._symbol_lookup( nestedName, templateDecls, - on_missing_qualified_symbol, + _on_missing_qualified_symbol_raise, strict_template_param_arg_lists=False, ancestor_lookup_type=typ, template_shorthand=templateShorthand, @@ -1156,7 +1148,7 @@ def on_missing_qualified_symbol( correct_primary_template_args=False, search_in_siblings=searchInSiblings, ) - except QualifiedSymbolIsTemplateParam: + except _QualifiedSymbolIsTemplateParam: return None, 'templateParamInQualified' if lookup_result is None: @@ -1210,18 +1202,10 @@ def find_declaration( else: template_decls = [] - def on_missing_qualified_symbol( - parent_symbol: Symbol, - ident_or_op: ASTIdentifier | ASTOperator, - template_params: Any, - template_args: ASTTemplateArgs, - ) -> Symbol | None: - return None - lookup_result = self._symbol_lookup( nested_name, template_decls, - on_missing_qualified_symbol, + _on_missing_qualified_symbol_none, strict_template_param_arg_lists=False, ancestor_lookup_type=typ, template_shorthand=templateShorthand, @@ -1296,3 +1280,53 @@ def dump(self, indent: int) -> str: self.to_string(indent), *(c.dump(indent + 1) for c in self._children), ]) + + +def _on_missing_qualified_symbol_fresh( + parent_symbol: Symbol, + ident_or_op: ASTIdentifier | ASTOperator, + template_params: Any, + template_args: ASTTemplateArgs, +) -> Symbol | None: + if Symbol.debug_lookup: + Symbol.debug_indent += 1 + Symbol.debug_print('_add_symbols, on_missing_qualified_symbol:') + Symbol.debug_indent += 1 + Symbol.debug_print('template_params:', template_params) + Symbol.debug_print('ident_or_op: ', ident_or_op) + Symbol.debug_print('template_args: ', template_args) + Symbol.debug_indent -= 2 + return Symbol( + parent=parent_symbol, + identOrOp=ident_or_op, + templateParams=template_params, + templateArgs=template_args, + declaration=None, + docname=None, + line=None, + ) + + +def _on_missing_qualified_symbol_raise( + parent_symbol: Symbol, + ident_or_op: ASTIdentifier | ASTOperator, + template_params: Any, + template_args: ASTTemplateArgs, +) -> Symbol | None: + # TODO: Maybe search without template args? + # Though, the correct_primary_template_args does + # that for primary templates. + # Is there another case where it would be good? + if parent_symbol.declaration is not None: + if parent_symbol.declaration.objectType == 'templateParam': + raise _QualifiedSymbolIsTemplateParam + return None + + +def _on_missing_qualified_symbol_none( + parent_symbol: Symbol, + ident_or_op: ASTIdentifier | ASTOperator, + template_params: Any, + template_args: ASTTemplateArgs, +) -> Symbol | None: + return None diff --git a/sphinx/domains/index.py b/sphinx/domains/index.py index 09a18d0180e..cefa64a8d5f 100644 --- a/sphinx/domains/index.py +++ b/sphinx/domains/index.py @@ -47,7 +47,7 @@ def merge_domaindata(self, docnames: Set[str], otherdata: dict[str, Any]) -> Non def process_doc(self, env: BuildEnvironment, docname: str, document: Node) -> None: """Process a document after it is read by the environment.""" - entries = self.entries.setdefault(env.docname, []) + entries = self.entries.setdefault(env.current_document.docname, []) for node in list(document.findall(addnodes.index)): node_entries = node['entries'] try: diff --git a/sphinx/domains/javascript.py b/sphinx/domains/javascript.py index 968f73aa3d3..6ebd1dec3fd 100644 --- a/sphinx/domains/javascript.py +++ b/sphinx/domains/javascript.py @@ -70,7 +70,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] """ sig = sig.strip() if '(' in sig and sig[-1:] == ')': - member, arglist = sig.split('(', 1) + member, _, arglist = sig.partition('(') member = member.strip() arglist = arglist[:-1].strip() else: @@ -137,10 +137,11 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] _pseudo_parse_arglist( signode, arglist, - multi_line_parameter_list, - trailing_comma, + multi_line_parameter_list=multi_line_parameter_list, + trailing_comma=trailing_comma, + env=self.env, ) - return fullname, prefix + return fullname, prefix # type: ignore[return-value] def _object_hierarchy_parts(self, sig_node: desc_signature) -> tuple[str, ...]: if 'fullname' not in sig_node: @@ -362,7 +363,10 @@ def run(self) -> list[Node]: # Make a duplicate entry in 'objects' to facilitate searching for # the module in JavaScriptDomain.find_obj() domain.note_object( - mod_name, 'module', node_id, location=(self.env.docname, self.lineno) + mod_name, + 'module', + node_id, + location=(self.env.current_document.docname, self.lineno), ) # The node order is: index node first, then target node @@ -435,7 +439,7 @@ class JavaScriptDomain(Domain): 'attr': JSXRefRole(), 'mod': JSXRefRole(), } - initial_data: dict[str, dict[str, tuple[str, str]]] = { + initial_data: ClassVar[dict[str, dict[str, tuple[str, str]]]] = { 'objects': {}, # fullname -> docname, node_id, objtype 'modules': {}, # modname -> docname, node_id } @@ -458,14 +462,14 @@ def note_object( docname, location=location, ) - self.objects[fullname] = (self.env.docname, node_id, objtype) + self.objects[fullname] = (self.env.current_document.docname, node_id, objtype) @property def modules(self) -> dict[str, tuple[str, str]]: return self.data.setdefault('modules', {}) # modname -> docname, node_id def note_module(self, modname: str, node_id: str) -> None: - self.modules[modname] = (self.env.docname, node_id) + self.modules[modname] = (self.env.current_document.docname, node_id) def clear_doc(self, docname: str) -> None: for fullname, (pkg_docname, _node_id, _l) in list(self.objects.items()): diff --git a/sphinx/domains/math.py b/sphinx/domains/math.py index 56e543917ad..433e35b7a2f 100644 --- a/sphinx/domains/math.py +++ b/sphinx/domains/math.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Set - from typing import Any + from typing import Any, ClassVar from docutils.nodes import Element, Node, system_message @@ -47,7 +47,7 @@ class MathDomain(Domain): name = 'math' label = 'mathematics' - initial_data: dict[str, Any] = { + initial_data: ClassVar[dict[str, Any]] = { 'objects': {}, # labelid -> (docname, eqno) # backwards compatibility 'has_equations': {}, # https://github.com/sphinx-doc/sphinx/issues/13346 @@ -74,6 +74,8 @@ def note_equation(self, docname: str, labelid: str, location: Any = None) -> Non labelid, other, location=location, + type='ref', + subtype='equation', ) self.equations[labelid] = (docname, self.env.new_serialno('eqno') + 1) diff --git a/sphinx/domains/python/__init__.py b/sphinx/domains/python/__init__.py index 97519ee028e..3cca270abf6 100644 --- a/sphinx/domains/python/__init__.py +++ b/sphinx/domains/python/__init__.py @@ -29,7 +29,7 @@ from collections.abc import Iterable, Iterator, Sequence, Set from typing import Any, ClassVar - from docutils.nodes import Element, Node, TextElement + from docutils.nodes import Element, Node from sphinx.addnodes import desc_signature, pending_xref from sphinx.application import Sphinx @@ -52,6 +52,8 @@ py_sig_re, ) +_TYPING_ALL = frozenset(typing.__all__) + logger = logging.getLogger(__name__) pairindextypes = { @@ -108,7 +110,7 @@ def add_target_and_index( modname = self.options.get('module', self.env.ref_context.get('py:module')) node_id = signode['ids'][0] - name, cls = name_cls + name, _cls = name_cls if modname: text = _('%s() (in module %s)') % (name, modname) self.indexnode['entries'].append(('single', text, node_id, '', None)) @@ -175,7 +177,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] return fullname, prefix def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str: - name, cls = name_cls + name, _cls = name_cls if modname: return _('%s (in module %s)') % (name, modname) else: @@ -268,7 +270,7 @@ def get_signature_prefix(self, sig: str) -> Sequence[nodes.Node]: return prefix def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str: - name, cls = name_cls + name, _cls = name_cls try: clsname, methname = name.rsplit('.', 1) if modname and self.config.add_module_names: @@ -364,7 +366,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] return fullname, prefix def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str: - name, cls = name_cls + name, _cls = name_cls try: clsname, attrname = name.rsplit('.', 1) if modname and self.config.add_module_names: @@ -424,7 +426,7 @@ def get_signature_prefix(self, sig: str) -> Sequence[nodes.Node]: return prefix def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str: - name, cls = name_cls + name, _cls = name_cls try: clsname, attrname = name.rsplit('.', 1) if modname and self.config.add_module_names: @@ -464,7 +466,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] return fullname, prefix def get_index_text(self, modname: str, name_cls: tuple[str, str]) -> str: - name, cls = name_cls + name, _cls = name_cls try: clsname, attrname = name.rsplit('.', 1) if modname and self.config.add_module_names: @@ -594,23 +596,17 @@ def process_link( class _PyDecoXRefRole(PyXRefRole): - def __init__( + def process_link( self, - fix_parens: bool = False, - lowercase: bool = False, - nodeclass: type[Element] | None = None, - innernodeclass: type[TextElement] | None = None, - warn_dangling: bool = False, - ) -> None: - super().__init__( - fix_parens=True, - lowercase=lowercase, - nodeclass=nodeclass, - innernodeclass=innernodeclass, - warn_dangling=warn_dangling, + env: BuildEnvironment, + refnode: Element, + has_explicit_title: bool, + title: str, + target: str, + ) -> tuple[str, str]: + title, target = super().process_link( + env, refnode, has_explicit_title, title, target ) - - def update_title_and_target(self, title: str, target: str) -> tuple[str, str]: return f'@{title}', target @@ -675,7 +671,7 @@ def generate( entries = content.setdefault(modname[0].lower(), []) - package = modname.split('.', maxsplit=1)[0] + package = modname.partition('.')[0] if package != modname: # it's a submodule if prev_modname == package: @@ -736,7 +732,7 @@ class PythonDomain(Domain): name = 'py' label = 'Python' - object_types: dict[str, ObjType] = { + object_types = { 'function': ObjType(_('function'), 'func', 'obj'), 'data': ObjType(_('data'), 'data', 'obj'), 'class': ObjType(_('class'), 'class', 'exc', 'obj'), @@ -746,7 +742,7 @@ class PythonDomain(Domain): 'staticmethod': ObjType(_('static method'), 'meth', 'obj'), 'attribute': ObjType(_('attribute'), 'attr', 'obj'), 'property': ObjType(_('property'), 'attr', '_prop', 'obj'), - 'type': ObjType(_('type alias'), 'type', 'obj'), + 'type': ObjType(_('type alias'), 'type', 'class', 'obj'), 'module': ObjType(_('module'), 'mod', 'obj'), } @@ -779,7 +775,7 @@ class PythonDomain(Domain): 'mod': PyXRefRole(), 'obj': PyXRefRole(), } - initial_data: dict[str, dict[str, tuple[Any]]] = { + initial_data: ClassVar[dict[str, dict[str, tuple[Any]]]] = { 'objects': {}, # fullname -> docname, objtype 'modules': {}, # modname -> docname, synopsis, platform, deprecated } @@ -822,7 +818,9 @@ def note_object( other.docname, location=location, ) - self.objects[name] = ObjectEntry(self.env.docname, node_id, objtype, aliased) + self.objects[name] = ObjectEntry( + self.env.current_document.docname, node_id, objtype, aliased + ) @property def modules(self) -> dict[str, ModuleEntry]: @@ -836,7 +834,7 @@ def note_module( .. versionadded:: 2.1 """ self.modules[name] = ModuleEntry( - docname=self.env.docname, + docname=self.env.current_document.docname, node_id=node_id, synopsis=synopsis, platform=platform, @@ -954,6 +952,14 @@ def resolve_xref( searchmode = 1 if node.hasattr('refspecific') else 0 matches = self.find_obj(env, modname, clsname, target, type, searchmode) + if not matches and type == 'class': + # fallback to data/attr (for type aliases) + # type aliases are documented as data/attr but referenced as class + matches = self.find_obj(env, modname, clsname, target, 'data', searchmode) + if not matches: + matches = self.find_obj( + env, modname, clsname, target, 'attr', searchmode + ) if not matches and type == 'attr': # fallback to meth (for property; Sphinx 2.4.x) # this ensures that `:attr:` role continues to refer to the old property entry @@ -1082,13 +1088,6 @@ def builtin_resolver( app: Sphinx, env: BuildEnvironment, node: pending_xref, contnode: Element ) -> Element | None: """Do not emit nitpicky warnings for built-in types.""" - - def istyping(s: str) -> bool: - if s.startswith('typing.'): - s = s.split('.', 1)[1] - - return s in typing.__all__ - if node.get('refdomain') != 'py': return None elif node.get('reftype') in {'class', 'obj'} and node.get('reftarget') == 'None': @@ -1098,13 +1097,17 @@ def istyping(s: str) -> bool: if inspect.isclass(getattr(builtins, reftarget, None)): # built-in class return contnode - if istyping(reftarget): + if _is_typing(reftarget): # typing class return contnode return None +def _is_typing(s: str, /) -> bool: + return s.removeprefix('typing.') in _TYPING_ALL + + def setup(app: Sphinx) -> ExtensionMetadata: app.setup_extension('sphinx.directives') diff --git a/sphinx/domains/python/_annotations.py b/sphinx/domains/python/_annotations.py index 823aac01316..f476ff22fd4 100644 --- a/sphinx/domains/python/_annotations.py +++ b/sphinx/domains/python/_annotations.py @@ -6,6 +6,7 @@ import token from collections import deque from inspect import Parameter +from itertools import chain, islice from typing import TYPE_CHECKING from docutils import nodes @@ -124,6 +125,10 @@ def unparse(node: ast.AST) -> list[Node]: return [nodes.Text(repr(node.value))] if isinstance(node, ast.Expr): return unparse(node.value) + if isinstance(node, ast.Starred): + result = [addnodes.desc_sig_operator('', '*')] + result.extend(unparse(node.value)) + return result if isinstance(node, ast.Invert): return [addnodes.desc_sig_punctuation('', '~')] if isinstance(node, ast.USub): @@ -312,18 +317,6 @@ def parse(self) -> None: self.type_params.append(type_param) def _build_identifier(self, tokens: list[Token]) -> str: - from itertools import chain, islice - - def triplewise(iterable: Iterable[Token]) -> Iterator[tuple[Token, ...]]: - # sliding_window('ABCDEFG', 4) --> ABCD BCDE CDEF DEFG - it = iter(iterable) - window = deque(islice(it, 3), maxlen=3) - if len(window) == 3: - yield tuple(window) - for x in it: - window.append(x) - yield tuple(window) - idents: list[str] = [] tokens: Iterable[Token] = iter(tokens) # type: ignore[no-redef] # do not format opening brackets @@ -338,7 +331,7 @@ def triplewise(iterable: Iterable[Token]) -> Iterator[tuple[Token, ...]]: # check the remaining tokens stop = Token(token.ENDMARKER, '', (-1, -1), (-1, -1), '') is_unpack_operator = False - for tok, op, after in triplewise(chain(tokens, [stop, stop])): + for tok, op, after in _triplewise(chain(tokens, [stop, stop])): ident = self._pformat_token(tok, native=is_unpack_operator) idents.append(ident) # determine if the next token is an unpack operator depending @@ -548,8 +541,10 @@ def _keyword_only_separator() -> addnodes.desc_parameter: def _pseudo_parse_arglist( signode: desc_signature, arglist: str, + *, multi_line_parameter_list: bool = False, trailing_comma: bool = True, + env: BuildEnvironment, ) -> None: """'Parse' a list of arguments separated by commas. @@ -557,6 +552,7 @@ def _pseudo_parse_arglist( brackets. Currently, this will split at any comma, even if it's inside a string literal (e.g. default argument value). """ + # TODO: decompose 'env' parameter into only the required bits paramlist = addnodes.desc_parameterlist() paramlist['multi_line_parameter_list'] = multi_line_parameter_list paramlist['multi_line_trailing_comma'] = trailing_comma @@ -579,9 +575,30 @@ def _pseudo_parse_arglist( ends_open += 1 argument = argument[:-1].strip() if argument: - stack[-1] += addnodes.desc_parameter( - '', '', addnodes.desc_sig_name(argument, argument) - ) + param_with_annotation, _, default_value = argument.partition('=') + param_name, _, annotation = param_with_annotation.partition(':') + del param_with_annotation + + node = addnodes.desc_parameter() + node += addnodes.desc_sig_name('', param_name.strip()) + if annotation: + children = _parse_annotation(annotation.strip(), env=env) + node += addnodes.desc_sig_punctuation('', ':') + node += addnodes.desc_sig_space() + node += addnodes.desc_sig_name('', '', *children) # type: ignore[arg-type] + if default_value: + if annotation: + node += addnodes.desc_sig_space() + node += addnodes.desc_sig_operator('', '=') + if annotation: + node += addnodes.desc_sig_space() + node += nodes.inline( + '', + default_value.strip(), + classes=['default_value'], + support_smartquotes=False, + ) + stack[-1] += node while ends_open: stack.append(addnodes.desc_optional()) stack[-2] += stack[-1] @@ -600,3 +617,14 @@ def _pseudo_parse_arglist( signode += paramlist else: signode += paramlist + + +def _triplewise(iterable: Iterable[Token]) -> Iterator[tuple[Token, ...]]: + # sliding_window('ABCDEFG', 4) --> ABCD BCDE CDEF DEFG + it = iter(iterable) + window = deque(islice(it, 3), maxlen=3) + if len(window) == 3: + yield tuple(window) + for x in it: + window.append(x) + yield tuple(window) diff --git a/sphinx/domains/python/_object.py b/sphinx/domains/python/_object.py index a858afe8a3e..6a0f0ff7334 100644 --- a/sphinx/domains/python/_object.py +++ b/sphinx/domains/python/_object.py @@ -93,7 +93,7 @@ def make_xref( children = result.children result.clear() - shortname = target.split('.')[-1] + shortname = target.rpartition('.')[-1] textnode = innernode('', shortname) # type: ignore[call-arg] contnodes = [ pending_xref_condition('', '', textnode, condition='resolved'), @@ -363,8 +363,9 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] _pseudo_parse_arglist( signode, arglist, - multi_line_parameter_list, - trailing_comma, + multi_line_parameter_list=multi_line_parameter_list, + trailing_comma=trailing_comma, + env=self.env, ) except (NotImplementedError, ValueError) as exc: # duplicated parameter names raise ValueError and not a SyntaxError @@ -374,8 +375,9 @@ def handle_signature(self, sig: str, signode: desc_signature) -> tuple[str, str] _pseudo_parse_arglist( signode, arglist, - multi_line_parameter_list, - trailing_comma, + multi_line_parameter_list=multi_line_parameter_list, + trailing_comma=trailing_comma, + env=self.env, ) else: if self.needs_arglist(): @@ -422,14 +424,20 @@ def add_target_and_index( domain = self.env.domains.python_domain domain.note_object(fullname, self.objtype, node_id, location=signode) - canonical_name = self.options.get('canonical') - if canonical_name: - domain.note_object( - canonical_name, self.objtype, node_id, aliased=True, location=signode - ) + if self.objtype != 'type': + # py:type directive uses `canonical` option for a different meaning + canonical_name = self.options.get('canonical') + if canonical_name: + domain.note_object( + canonical_name, + self.objtype, + node_id, + aliased=True, + location=signode, + ) if 'no-index-entry' not in self.options: - if index_text := self.get_index_text(mod_name, name_cls): + if index_text := self.get_index_text(mod_name, name_cls): # type: ignore[arg-type] self.indexnode['entries'].append(( 'single', index_text, diff --git a/sphinx/domains/rst.py b/sphinx/domains/rst.py index cd5d8312d4a..64aff25a015 100644 --- a/sphinx/domains/rst.py +++ b/sphinx/domains/rst.py @@ -83,7 +83,7 @@ def _toc_entry_name(self, sig_node: desc_signature) -> str: return '' objtype = sig_node.parent.get('objtype') - *parents, name = sig_node['_toc_parts'] + *_parents, name = sig_node['_toc_parts'] if objtype == 'directive:option': return f':{name}:' if self.config.toc_object_entries_show_parents in {'domain', 'all'}: @@ -244,7 +244,7 @@ class ReSTDomain(Domain): 'dir': XRefRole(), 'role': XRefRole(), } - initial_data: dict[str, dict[tuple[str, str], str]] = { + initial_data: ClassVar[dict[str, dict[tuple[str, str], str]]] = { 'objects': {}, # fullname -> docname, objtype } @@ -266,7 +266,7 @@ def note_object( location=location, ) - self.objects[objtype, name] = (self.env.docname, node_id) + self.objects[objtype, name] = (self.env.current_document.docname, node_id) def clear_doc(self, docname: str) -> None: for (typ, name), (doc, _node_id) in list(self.objects.items()): diff --git a/sphinx/domains/std/__init__.py b/sphinx/domains/std/__init__.py index e123ce85786..52ccef67c24 100644 --- a/sphinx/domains/std/__init__.py +++ b/sphinx/domains/std/__init__.py @@ -27,7 +27,6 @@ from typing import Any, ClassVar, Final from docutils.nodes import Element, Node, system_message - from docutils.parsers.rst import Directive from sphinx.addnodes import desc_signature from sphinx.application import Sphinx @@ -36,8 +35,6 @@ from sphinx.util.typing import ( ExtensionMetadata, OptionSpec, - RoleFunction, - TitleGetter, ) logger = logging.getLogger(__name__) @@ -218,7 +215,7 @@ def run(self) -> list[Node]: ret.insert(0, inode) name = self.name if ':' in self.name: - _, name = self.name.split(':', 1) + name = self.name.partition(':')[-1] std = self.env.domains.standard_domain std.note_object(name, fullname, node_id, location=node) @@ -311,7 +308,10 @@ def add_target_and_index( domain = self.env.domains.standard_domain for optname in signode.get('allnames', ()): domain.add_program_option( - currprogram, optname, self.env.docname, signode['ids'][0] + currprogram, + optname, + self.env.current_document.docname, + signode['ids'][0], ) # create an index entry @@ -725,7 +725,7 @@ class StandardDomain(Domain): name = 'std' label = 'Default' - object_types: dict[str, ObjType] = { + object_types = { 'term': ObjType(_('glossary term'), 'term', searchprio=-1), 'token': ObjType(_('grammar token'), 'token', searchprio=-1), 'label': ObjType(_('reference label'), 'ref', 'keyword', searchprio=-1), @@ -735,7 +735,7 @@ class StandardDomain(Domain): 'doc': ObjType(_('document'), 'doc', searchprio=-1), } - directives: dict[str, type[Directive]] = { + directives = { 'program': Program, 'cmdoption': Cmdoption, # old name for backwards compatibility 'option': Cmdoption, @@ -744,7 +744,7 @@ class StandardDomain(Domain): 'glossary': Glossary, 'productionlist': ProductionList, } - roles: dict[str, RoleFunction | XRefRole] = { + roles = { 'option': OptionXRefRole(warn_dangling=True), 'confval': XRefRole(warn_dangling=True), 'envvar': EnvVarXRefRole(), @@ -780,7 +780,7 @@ class StandardDomain(Domain): } # labelname -> docname, sectionname - _virtual_doc_names: dict[str, tuple[str, str]] = { + _virtual_doc_names: Final = { 'genindex': ('genindex', _('Index')), 'modindex': ('py-modindex', _('Module Index')), 'search': ('search', _('Search Page')), @@ -795,7 +795,7 @@ class StandardDomain(Domain): } # node_class -> (figtype, title_getter) - enumerable_nodes: dict[type[Node], tuple[str, TitleGetter | None]] = { + enumerable_nodes = { nodes.figure: ('figure', None), nodes.table: ('table', None), nodes.container: ('code-block', None), @@ -805,9 +805,9 @@ def __init__(self, env: BuildEnvironment) -> None: super().__init__(env) # set up enumerable nodes - self.enumerable_nodes = copy( - self.enumerable_nodes - ) # create a copy for this instance + + # create a copy for this instance + self.enumerable_nodes = copy(self.enumerable_nodes) # type: ignore[misc] for node, settings in env._registry.enumerable_nodes.items(): self.enumerable_nodes[node] = settings @@ -860,7 +860,7 @@ def note_object( docname, location=location, ) - self.objects[objtype, name] = (self.env.docname, labelid) + self.objects[objtype, name] = (self.env.current_document.docname, labelid) @property def _terms(self) -> dict[str, tuple[str, str]]: @@ -874,7 +874,7 @@ def _note_term(self, term: str, labelid: str, location: Any = None) -> None: """ self.note_object('term', term, labelid, location) - self._terms[term.lower()] = (self.env.docname, labelid) + self._terms[term.lower()] = (self.env.current_document.docname, labelid) @property def progoptions(self) -> dict[tuple[str | None, str], tuple[str, str]]: @@ -974,13 +974,13 @@ def process_doc( continue else: if ( - isinstance(node, nodes.definition_list | nodes.field_list) + isinstance(node, (nodes.definition_list, nodes.field_list)) and node.children ): node = cast('nodes.Element', node.children[0]) - if isinstance(node, nodes.field | nodes.definition_list_item): + if isinstance(node, (nodes.field, nodes.definition_list_item)): node = cast('nodes.Element', node.children[0]) - if isinstance(node, nodes.term | nodes.field_name): + if isinstance(node, (nodes.term, nodes.field_name)): sectname = clean_astext(node) else: toctree = next(node.findall(addnodes.toctree), None) @@ -1235,7 +1235,7 @@ def _resolve_option_xref( if not docname: commands = [] while ws_re.search(target): - subcommand, target = ws_re.split(target, 1) + subcommand, target = ws_re.split(target, maxsplit=1) commands.append(subcommand) progname = '-'.join(commands) @@ -1371,23 +1371,19 @@ def get_numfig_title(self, node: Node) -> str | None: return title_getter(elem) else: for subnode in elem: - if isinstance(subnode, nodes.caption | nodes.title): + if isinstance(subnode, (nodes.caption, nodes.title)): return clean_astext(subnode) return None def get_enumerable_node_type(self, node: Node) -> str | None: """Get type of enumerable nodes.""" - - def has_child(node: Element, cls: type) -> bool: - return any(isinstance(child, cls) for child in node) - if isinstance(node, nodes.section): return 'section' elif ( isinstance(node, nodes.container) and 'literal_block' in node - and has_child(node, nodes.literal_block) + and _has_child(node, nodes.literal_block) ): # given node is a code-block having caption return 'code-block' @@ -1440,6 +1436,10 @@ def get_full_qualified_name(self, node: Element) -> str | None: return None +def _has_child(node: Element, cls: type) -> bool: + return any(isinstance(child, cls) for child in node) + + def warn_missing_reference( app: Sphinx, domain: Domain, diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index 79fa6278549..fa7d17d7800 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING from sphinx import addnodes +from sphinx.deprecation import _deprecation_warning from sphinx.domains._domains_container import _DomainsContainer from sphinx.environment.adapters import toctree as toctree_adapters from sphinx.errors import ( @@ -23,7 +24,7 @@ from sphinx.transforms import SphinxTransformer from sphinx.util import logging from sphinx.util._files import DownloadFiles, FilenameUniqDict -from sphinx.util._pathlib import _StrPath, _StrPathProperty +from sphinx.util._pathlib import _StrPathProperty from sphinx.util._serialise import stable_str from sphinx.util._timestamps import _format_rfc3339_microseconds from sphinx.util.docutils import LoggingReporter @@ -32,7 +33,7 @@ from sphinx.util.osutil import _last_modified_time, _relative_path if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Iterator, Mapping + from collections.abc import Callable, Iterable, Iterator, Mapping, Set from typing import Any, Final, Literal from docutils import nodes @@ -49,6 +50,7 @@ from sphinx.extension import Extension from sphinx.project import Project from sphinx.registry import SphinxComponentRegistry + from sphinx.util._pathlib import _StrPath from sphinx.util.tags import Tags logger = logging.getLogger(__name__) @@ -73,7 +75,7 @@ # This is increased every time an environment attribute is added # or changed to properly invalidate pickle files. -ENV_VERSION = 65 +ENV_VERSION = 66 # config status CONFIG_UNSET = -1 @@ -106,8 +108,11 @@ class BuildEnvironment: srcdir = _StrPathProperty() doctreedir = _StrPathProperty() + # builder is created after the environment. + _builder_cls: type[Builder] + def __init__(self, app: Sphinx) -> None: - self.app: Sphinx = app + self._app: Sphinx = app self.doctreedir = app.doctreedir self.srcdir = app.srcdir self.config: Config = None # type: ignore[assignment] @@ -237,7 +242,7 @@ def __getstate__(self) -> dict[str, Any]: """Obtains serializable data for pickling.""" __dict__ = self.__dict__.copy() # clear unpickleable attributes - __dict__.update(app=None, domains=None, events=None) + __dict__.update(_app=None, domains=None, events=None) # clear in-memory doctree caches, to reduce memory consumption and # ensure that, upon restoring the state, the most recent pickled files # on the disk are used instead of those from a possibly outdated state @@ -257,7 +262,7 @@ def setup(self, app: Sphinx) -> None: if self.project: app.project.restore(self.project) - self.app = app + self._app = app self.doctreedir = app.doctreedir self.events = app.events self.srcdir = app.srcdir @@ -277,20 +282,37 @@ def setup(self, app: Sphinx) -> None: # The old config is self.config, restored from the pickled environment. # The new config is app.config, always recreated from ``conf.py`` self.config_status, self.config_status_extra = self._config_status( - old_config=self.config, new_config=app.config, verbosity=app.verbosity + old_config=self.config, + new_config=app.config, + verbosity=app.config.verbosity, ) self.config = app.config # initialize settings self._update_settings(app.config) + @property + def app(self) -> Sphinx: + _deprecation_warning(__name__, 'BuildEnvironment.app', remove=(10, 0)) + return self._app + + @app.setter + def app(self, app: Sphinx) -> None: + _deprecation_warning(__name__, 'BuildEnvironment.app', remove=(10, 0)) + self._app = app + + @app.deleter + def app(self) -> None: + _deprecation_warning(__name__, 'BuildEnvironment.app', remove=(10, 0)) + del self._app + @property def _registry(self) -> SphinxComponentRegistry: - return self.app.registry + return self._app.registry @property def _tags(self) -> Tags: - return self.app.tags + return self._app.tags @staticmethod def _config_status( @@ -498,7 +520,7 @@ def get_outdated_files( ) -> tuple[set[str], set[str], set[str]]: """Return (added, changed, removed) sets.""" # clear all files no longer present - removed = set(self.all_docs) - self.found_docs + removed = self.all_docs.keys() - self.found_docs added: set[str] = set() changed: set[str] = set() @@ -506,65 +528,25 @@ def get_outdated_files( if config_changed: # config values affect e.g. substitutions added = self.found_docs - else: - for docname in self.found_docs: - if docname not in self.all_docs: - logger.debug('[build target] added %r', docname) - added.add(docname) - continue - # if the doctree file is not there, rebuild - filename = self.doctreedir / f'{docname}.doctree' - if not filename.is_file(): - logger.debug('[build target] changed %r', docname) - changed.add(docname) - continue - # check the "reread always" list - if docname in self.reread_always: - logger.debug('[build target] changed %r', docname) - changed.add(docname) - continue - # check the mtime of the document - mtime = self.all_docs[docname] - newmtime = _last_modified_time(self.doc2path(docname)) - if newmtime > mtime: - logger.debug( - '[build target] outdated %r: %s -> %s', - docname, - _format_rfc3339_microseconds(mtime), - _format_rfc3339_microseconds(newmtime), - ) - changed.add(docname) - continue - # finally, check the mtime of dependencies - if docname not in self.dependencies: - continue - for dep in self.dependencies[docname]: - try: - # this will do the right thing when dep is absolute too - dep_path = self.srcdir / dep - if not dep_path.is_file(): - logger.debug( - '[build target] changed %r missing dependency %r', - docname, - dep_path, - ) - changed.add(docname) - break - depmtime = _last_modified_time(dep_path) - if depmtime > mtime: - logger.debug( - '[build target] outdated %r from dependency %r: %s -> %s', - docname, - dep_path, - _format_rfc3339_microseconds(mtime), - _format_rfc3339_microseconds(depmtime), - ) - changed.add(docname) - break - except OSError: - # give it another chance - changed.add(docname) - break + return added, changed, removed + + for docname in self.found_docs: + if docname not in self.all_docs: + logger.debug('[build target] added %r', docname) + added.add(docname) + continue + + # if the document has changed, rebuild + if _has_doc_changed( + docname, + filename=self.doc2path(docname), + reread_always=self.reread_always, + doctreedir=self.doctreedir, + all_docs=self.all_docs, + dependencies=self.dependencies, + ): + changed.add(docname) + continue return added, changed, removed @@ -628,7 +610,9 @@ def note_dependency( """ if docname is None: docname = self.docname - self.dependencies.setdefault(docname, set()).add(_StrPath(filename)) + # this will do the right thing when *filename* is absolute too + filename = self.srcdir / filename + self.dependencies.setdefault(docname, set()).add(filename) def note_included(self, filename: str | os.PathLike[str]) -> None: """Add *filename* as a included from other document. @@ -682,6 +666,8 @@ def get_and_resolve_doctree( self, docname: str, builder: Builder, + *, + tags: Tags, doctree: nodes.document | None = None, prune_toctrees: bool = True, includehidden: bool = False, @@ -701,6 +687,7 @@ def get_and_resolve_doctree( self.apply_post_transforms(doctree, docname) # now, resolve all toctree nodes + tags = builder.tags for toctreenode in doctree.findall(addnodes.toctree): result = toctree_adapters._resolve_toctree( self, @@ -709,7 +696,7 @@ def get_and_resolve_doctree( toctreenode, prune=prune_toctrees, includehidden=includehidden, - tags=builder.tags, + tags=tags, ) if result is None: toctreenode.parent.replace(toctreenode, []) @@ -750,7 +737,7 @@ def resolve_toctree( titles_only=titles_only, collapse=collapse, includehidden=includehidden, - tags=builder.tags, + tags=self._tags, ) def resolve_references( @@ -764,7 +751,7 @@ def apply_post_transforms(self, doctree: nodes.document, docname: str) -> None: new = deepcopy(backup) new.docname = docname try: - # set env.docname during applying post-transforms + # set env.current_document.docname during applying post-transforms self.current_document = new transformer = SphinxTransformer(doctree) @@ -848,6 +835,71 @@ def _differing_config_keys(old: Config, new: Config) -> frozenset[str]: return frozenset(not_in_both | different_values) +def _has_doc_changed( + docname: str, + *, + filename: Path, + reread_always: Set[str], + doctreedir: Path, + all_docs: Mapping[str, int], + dependencies: Mapping[str, Set[Path]], +) -> bool: + # check the "reread always" list + if docname in reread_always: + logger.debug('[build target] changed %r: re-read forced', docname) + return True + + # if the doctree file is not there, rebuild + doctree_path = doctreedir / f'{docname}.doctree' + if not doctree_path.is_file(): + logger.debug('[build target] changed %r: doctree file does not exist', docname) + return True + + # check the mtime of the document + mtime = all_docs[docname] + new_mtime = _last_modified_time(filename) + if new_mtime > mtime: + logger.debug( + '[build target] changed: %r is outdated (%s -> %s)', + docname, + _format_rfc3339_microseconds(mtime), + _format_rfc3339_microseconds(new_mtime), + ) + return True + + # finally, check the mtime of dependencies + if docname not in dependencies: + return False + for dep_path in dependencies[docname]: + try: + dep_path_is_file = dep_path.is_file() + except OSError: + return True # give it another chance + if not dep_path_is_file: + logger.debug( + '[build target] changed: %r is missing dependency %r', + docname, + dep_path, + ) + return True + + try: + dep_mtime = _last_modified_time(dep_path) + except OSError: + return True # give it another chance + if dep_mtime > mtime: + logger.debug( + '[build target] changed: %r is outdated due to dependency %r (%s -> %s)', + docname, + dep_path, + _format_rfc3339_microseconds(mtime), + _format_rfc3339_microseconds(dep_mtime), + ) + return True + + return False + + def _traverse_toctree( traversed: set[str], parent: str | None, diff --git a/sphinx/environment/adapters/indexentries.py b/sphinx/environment/adapters/indexentries.py index c19628515b6..0428e488308 100644 --- a/sphinx/environment/adapters/indexentries.py +++ b/sphinx/environment/adapters/indexentries.py @@ -50,7 +50,6 @@ class IndexEntries: def __init__(self, env: BuildEnvironment) -> None: self.env = env - self.builder: Builder def create_index( self, @@ -253,7 +252,7 @@ def _key_func_2(entry: tuple[str, _IndexEntryTargets]) -> str: def _group_by_func(entry: tuple[str, _IndexEntry]) -> str: """Group the entries by letter or category key.""" - key, (targets, sub_items, category_key) = entry + key, (_targets, _sub_items, category_key) = entry if category_key is not None: return category_key diff --git a/sphinx/environment/adapters/toctree.py b/sphinx/environment/adapters/toctree.py index bedeca2f299..1435c069492 100644 --- a/sphinx/environment/adapters/toctree.py +++ b/sphinx/environment/adapters/toctree.py @@ -2,12 +2,14 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, TypeVar from docutils import nodes from docutils.nodes import Element from sphinx import addnodes +from sphinx.deprecation import RemovedInSphinx10Warning from sphinx.locale import __ from sphinx.util import logging, url_re from sphinx.util.matching import Matcher @@ -69,6 +71,8 @@ def global_toctree_for_doc( env: BuildEnvironment, docname: str, builder: Builder, + *, + tags: Tags = ..., # type: ignore[assignment] collapse: bool = False, includehidden: bool = True, maxdepth: int = 0, @@ -78,6 +82,15 @@ def global_toctree_for_doc( This gives the global ToC, with all ancestors and their siblings. """ + if tags is ...: + warnings.warn( + "'tags' will become a required keyword argument " + 'for global_toctree_for_doc() in Sphinx 10.0.', + RemovedInSphinx10Warning, + stacklevel=2, + ) + tags = builder.tags + resolved = ( _resolve_toctree( env, @@ -89,7 +102,7 @@ def global_toctree_for_doc( titles_only=titles_only, collapse=collapse, includehidden=includehidden, - tags=builder.tags, + tags=tags, ) for toctree_node in env.master_doctree.findall(addnodes.toctree) ) @@ -191,9 +204,7 @@ def _resolve_toctree( # prune the tree to maxdepth, also set toc depth and current classes _toctree_add_classes(newnode, 1, docname) - newnode = _toctree_copy( - newnode, 1, maxdepth if prune else 0, collapse, builder.tags - ) + newnode = _toctree_copy(newnode, 1, maxdepth if prune else 0, collapse, tags) if ( isinstance(newnode[-1], nodes.Element) and len(newnode[-1]) == 0 @@ -444,7 +455,7 @@ def _toctree_standard_entry( def _toctree_add_classes(node: Element, depth: int, docname: str) -> None: """Add 'toctree-l%d' and 'current' classes to the toctree.""" for subnode in node.children: - if isinstance(subnode, addnodes.compact_paragraph | nodes.list_item): + if isinstance(subnode, (addnodes.compact_paragraph, nodes.list_item)): # for

and

  • , indicate the depth level and recurse subnode['classes'].append(f'toctree-l{depth - 1}') _toctree_add_classes(subnode, depth, docname) @@ -471,56 +482,84 @@ def _toctree_add_classes(node: Element, depth: int, docname: str) -> None: subnode = subnode.parent -ET = TypeVar('ET', bound=Element) +_ET = TypeVar('_ET', bound=Element) def _toctree_copy( - node: ET, depth: int, maxdepth: int, collapse: bool, tags: Tags -) -> ET: + node: _ET, depth: int, maxdepth: int, collapse: bool, tags: Tags +) -> _ET: """Utility: Cut and deep-copy a TOC at a specified depth.""" - keep_bullet_list_sub_nodes = depth <= 1 or ( - (depth <= maxdepth or maxdepth <= 0) and (not collapse or 'iscurrent' in node) - ) + assert not isinstance(node, addnodes.only) + depth = max(depth - 1, 1) + copied = _toctree_copy_seq(node, depth, maxdepth, collapse, tags, initial_call=True) + assert len(copied) == 1 + return copied[0] # type: ignore[return-value] - copy = node.copy() - for subnode in node.children: - if isinstance(subnode, addnodes.compact_paragraph | nodes.list_item): - # for

    and

  • , just recurse - copy.append(_toctree_copy(subnode, depth, maxdepth, collapse, tags)) - elif isinstance(subnode, nodes.bullet_list): - # for