Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 24 additions & 77 deletions .docs/extensions/interface_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,94 +12,41 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Include interface reference docs in docs site."""
"""Sphinx fallback extension for interface reference docs.

The ``interface_preprocessor.py`` script (run by ``just docs``) generates the
interface reference pages under ``reference/interfaces/``.

This extension provides a fallback: if no interface reference pages exist —
because the preprocessor hasn't run — it writes a placeholder page so that the
glob toctree in ``reference/interfaces.md`` matches at least one document. If
the preprocessor has generated pages, it removes any stale placeholder instead.
"""

from __future__ import annotations

import json
import pathlib
import re
import subprocess
import typing

####################
# Sphinx extension #
####################

if typing.TYPE_CHECKING:
import sphinx.application


def setup(app: sphinx.application.Sphinx) -> dict[str, str | bool]:
"""Entrypoint for Sphinx extensions, connects generation code to Sphinx event."""
app.connect('builder-inited', _interface_docs)
return {'version': '1.0.0', 'parallel_read_safe': False, 'parallel_write_safe': False}


def _interface_docs(app: sphinx.application.Sphinx) -> None:
if app.config.package is not None:
# For efficiency, don't write interface docs during the package reference docs passes.
# But we need to make sure something is there so that the TOC glob doesn't fail.
ref_dir = pathlib.Path(app.confdir, 'reference', 'interfaces')
ref_dir.mkdir(parents=True, exist_ok=True)
_write_if_needed(path=ref_dir / 'placeholder.md', content='# Temporary TOC placeholder')
return
_main(docs_dir=pathlib.Path(app.confdir))
_PLACEHOLDER_NAME = 'placeholder.md'


####################
# generation logic #
####################

INDEX_TEMPLATE = """
({label})=
# {interface_name}

```{{toctree}}
:glob:
:reversed:
:maxdepth: 1

{interface_name}/*
```
""".strip()
REPO_MAIN_URL = 'https://github.com/canonical/charmlibs/blob/main'
def setup(app: sphinx.application.Sphinx) -> dict[str, str | bool]:
"""Sphinx extension entrypoint — registers the fallback hook."""
app.connect('builder-inited', _fallback)
return {'version': '2.0.0', 'parallel_read_safe': False, 'parallel_write_safe': False}


def _main(docs_dir: pathlib.Path) -> None:
"""Write automodule file for package and placeholders rst files for all other packages."""
root = docs_dir.parent
ref_dir = docs_dir / 'reference' / 'interfaces'
def _fallback(app: sphinx.application.Sphinx) -> None:
ref_dir = pathlib.Path(app.confdir, 'reference', 'interfaces')
ref_dir.mkdir(parents=True, exist_ok=True)
(ref_dir / 'placeholder.md').unlink(missing_ok=True)
cmd = [root / '.scripts/ls.py', 'interfaces', '--exclude-examples', '--exclude-placeholders']
interfaces = json.loads(subprocess.check_output(cmd, text=True))
for path_str in interfaces:
interface_dir = root / path_str
interface_name = interface_dir.name
interface_ref_dir = ref_dir / interface_name
interface_ref_dir.mkdir(exist_ok=True)
label = f'interfaces-{interface_name.replace("_", "-")}'
index = INDEX_TEMPLATE.format(label=label, interface_name=interface_name)
_write_if_needed(path=ref_dir / f'{interface_name}.md', content=index)
for v in (interface_dir / 'interface').glob('v[0-9]*'):
readme_raw = (v / 'README.md').read_text()
base_url = f'{REPO_MAIN_URL}/interfaces/{interface_name}/interface/{v.name}'
# match all non-http(s) markdown links and prepend base_url to matching links
readme = re.sub(
r'\[(.+)\]\((?!https?://)([^)]+)\)',
lambda m: f'[{m.group(1)}]({base_url}/{m.group(2)})', # noqa: B023
readme_raw,
)
content = f'({label}-{v.name})=\n' + readme
_write_if_needed(path=interface_ref_dir / f'{v.name}.md', content=content)


def _write_if_needed(path: pathlib.Path, content: str) -> None:
"""Write to path only if contents are different.

This allows sphinx-build to skip rebuilding pages that depend on the output of this extension
if the output hasn't actually changed.
"""
if not path.exists() or path.read_text() != content:
path.write_text(content)
placeholder = ref_dir / _PLACEHOLDER_NAME
if any(p.name != _PLACEHOLDER_NAME for p in ref_dir.glob('*.md')):
# The preprocessor has generated the interface reference pages.
# Remove any stale placeholder so it isn't picked up by the glob toctree.
placeholder.unlink(missing_ok=True)
elif not placeholder.exists():
placeholder.write_text('# Temporary TOC placeholder')
119 changes: 119 additions & 0 deletions .docs/scripts/interface_preprocessor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env -S uv run --script --no-project

# /// script
# requires-python = ">=3.12"
# ///

# Copyright 2025 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Generate interface reference docs in the Sphinx source tree.

Walks every interface returned by ``.scripts/ls.py interfaces``, and for each
one writes an index stub with a glob toctree under ``reference/interfaces/``,
plus one page per ``interface/v[0-9]*/README.md`` with relative links
rewritten to absolute GitHub URLs.

Run from ``just docs``; see ``docs.just`` for the invocation.

This is a standalone preprocessor script rather than a Sphinx extension,
following the same pattern as ``diataxis_preprocessor.py``. Unlike the
package reference docs, interface docs don't use autodoc, so nothing here
needs to run inside a Sphinx build. Generating the pages once up front —
instead of in a ``builder-inited`` hook on every Sphinx pass — keeps the
per-package intermediate passes cheaper and the extension machinery simpler.
The companion ``interface_docs`` extension is now only a fallback: it writes
a placeholder page when this script hasn't run, so the glob toctree in
``reference/interfaces.md`` still matches at least one document.
Comment on lines +30 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might cut this down bit. How about:

Suggested change
This is a standalone preprocessor script rather than a Sphinx extension,
following the same pattern as ``diataxis_preprocessor.py``. Unlike the
package reference docs, interface docs don't use autodoc, so nothing here
needs to run inside a Sphinx build. Generating the pages once up front
instead of in a ``builder-inited`` hook on every Sphinx passkeeps the
per-package intermediate passes cheaper and the extension machinery simpler.
The companion ``interface_docs`` extension is now only a fallback: it writes
a placeholder page when this script hasn't run, so the glob toctree in
``reference/interfaces.md`` still matches at least one document.
This is a standalone preprocessor script and nothing here needs to be run
inside a Sphinx build. Generating the pages once up frontinstead of in a
``builder-inited`` hook on every Sphinx passkeeps the per-package
intermediate passes cheap and the extension machinery simple. The companion
``interface_docs`` extension is a fallback: it writes a placeholder page when
this script hasn't run, so the glob toctree in ``reference/interfaces.md``
still matches at least one document.

"""

from __future__ import annotations

import json
import pathlib
import re
import subprocess

_DOCS_DIR = pathlib.Path(__file__).parent.parent.resolve()
_REPO_ROOT = _DOCS_DIR.parent
_REPO_MAIN_URL = 'https://github.com/canonical/charmlibs/blob/main'

_INDEX_TEMPLATE = """
({label})=
# {interface_name}

```{{toctree}}
:glob:
:reversed:
:maxdepth: 1

{interface_name}/*
```
""".strip()


def _main() -> None:
"""Write an index stub and rewritten version READMEs for every interface."""
ref_dir = _DOCS_DIR / 'reference' / 'interfaces'
ref_dir.mkdir(parents=True, exist_ok=True)
(ref_dir / 'placeholder.md').unlink(missing_ok=True)
cmd = [
_REPO_ROOT / '.scripts' / 'ls.py',
'interfaces',
'--exclude-examples',
'--exclude-placeholders',
]
interfaces: list[str] = json.loads(subprocess.check_output(cmd, text=True))
for path_str in interfaces:
_generate_interface_docs(interface_dir=_REPO_ROOT / path_str, ref_dir=ref_dir)


def _generate_interface_docs(interface_dir: pathlib.Path, ref_dir: pathlib.Path) -> None:
"""Write the index stub and rewritten version READMEs for one interface."""
interface_name = interface_dir.name
interface_ref_dir = ref_dir / interface_name
interface_ref_dir.mkdir(exist_ok=True)
label = f'interfaces-{interface_name.replace("_", "-")}'
index = _INDEX_TEMPLATE.format(label=label, interface_name=interface_name)
_write_if_needed(path=ref_dir / f'{interface_name}.md', content=index)
for v in (interface_dir / 'interface').glob('v[0-9]*'):
readme_raw = (v / 'README.md').read_text()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the interface version doesn't have a README.md, should we fail earlier with a helpful message?

base_url = f'{_REPO_MAIN_URL}/interfaces/{interface_name}/interface/{v.name}'
readme = _rewrite_links(readme_raw, base_url)
content = f'({label}-{v.name})=\n' + readme
_write_if_needed(path=interface_ref_dir / f'{v.name}.md', content=content)


def _rewrite_links(content: str, base_url: str) -> str:
"""Rewrite relative markdown links to absolute GitHub URLs under ``base_url``."""
return re.sub(
# match all non-http(s) markdown links and prepend base_url to matching links
r'\[(.+)\]\((?!https?://)([^)]+)\)',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The (.+) will be too greedy if there's more than one link on a line. My agent says we should change this to (.+?), similarly to _rewrite_links in diataxis_preprocessor.py. I guess we should also add a test for multiple links on a line.

lambda m: f'[{m.group(1)}]({base_url}/{m.group(2)})',
content,
)


def _write_if_needed(path: pathlib.Path, content: str) -> None:
"""Write to path only if contents are different.

This allows sphinx-build to skip rebuilding pages that depend on the output of this script
if the output hasn't actually changed.
"""
if not path.exists() or path.read_text() != content:
path.write_text(content)


if __name__ == '__main__':
_main()
78 changes: 78 additions & 0 deletions .docs/tests/test_interface_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Copyright 2025 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# ruff: noqa: D103 (function docstrings)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need this in this particular test file. But perhaps you'd prefer to keep it for consistency across test files?


"""Unit tests for the interface_docs Sphinx fallback extension."""

from __future__ import annotations

import types
import typing

import interface_docs

if typing.TYPE_CHECKING:
import pathlib

import sphinx.application


def _app(confdir: pathlib.Path) -> sphinx.application.Sphinx:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching _fake_ls?

Suggested change
def _app(confdir: pathlib.Path) -> sphinx.application.Sphinx:
def _fake_app(confdir: pathlib.Path) -> sphinx.application.Sphinx:

"""A minimal stand-in for the Sphinx app: ``_fallback`` only reads ``confdir``."""
return typing.cast('sphinx.application.Sphinx', types.SimpleNamespace(confdir=str(confdir)))


def test_fallback_writes_placeholder_when_dir_missing(tmp_path: pathlib.Path):
"""The reference/interfaces directory and placeholder are created if missing."""
interface_docs._fallback(_app(tmp_path))

placeholder = tmp_path / 'reference' / 'interfaces' / 'placeholder.md'
assert placeholder.exists()
assert placeholder.read_text() == '# Temporary TOC placeholder'


def test_fallback_keeps_existing_placeholder(tmp_path: pathlib.Path):
"""An existing placeholder is not overwritten."""
ref_dir = tmp_path / 'reference' / 'interfaces'
ref_dir.mkdir(parents=True)
(ref_dir / 'placeholder.md').write_text('existing')

interface_docs._fallback(_app(tmp_path))

assert (ref_dir / 'placeholder.md').read_text() == 'existing'


def test_fallback_skips_placeholder_when_content_exists(tmp_path: pathlib.Path):
"""No placeholder is written when the preprocessor has generated pages."""
ref_dir = tmp_path / 'reference' / 'interfaces'
ref_dir.mkdir(parents=True)
(ref_dir / 'foo.md').write_text('generated')

interface_docs._fallback(_app(tmp_path))

assert not (ref_dir / 'placeholder.md').exists()


def test_fallback_removes_stale_placeholder(tmp_path: pathlib.Path):
"""A leftover placeholder is removed when generated pages exist."""
ref_dir = tmp_path / 'reference' / 'interfaces'
ref_dir.mkdir(parents=True)
(ref_dir / 'foo.md').write_text('generated')
(ref_dir / 'placeholder.md').write_text('stale')

interface_docs._fallback(_app(tmp_path))

assert not (ref_dir / 'placeholder.md').exists()
assert (ref_dir / 'foo.md').read_text() == 'generated'
Loading
Loading