diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1fd07c..87419e5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## Unreleased: pdoc next +- The `.. include:` rST directive now supports start-line, end-line, start-after, end-before options. + ([#684](https://github.com/mitmproxy/pdoc/pull/684), @frankharkins) - Fix image embedding in included rST files. ([#692](https://github.com/mitmproxy/pdoc/pull/692), @meghprkh) - Support type-hints from stub-only packages. E.g: `scipy-stubs` diff --git a/pdoc/__init__.py b/pdoc/__init__.py index 914db125..3e7bd155 100644 --- a/pdoc/__init__.py +++ b/pdoc/__init__.py @@ -283,7 +283,16 @@ class GoldenRetriever(Dog): """ ``` -Since version 11, pdoc processes such reStructuredText elements by default. +You can also include only parts of a file with the +[`start-line`, `end-line`, `start-after`, and `end-after` options](https://docutils.sourceforge.io/docs/ref/rst/directives.html#including-an-external-document-fragment): + +```python +""" +.. include:: ../README.md + :start-line: 1 + :end-before: Changelog +""" +``` ## ...add a title page? diff --git a/pdoc/docstrings.py b/pdoc/docstrings.py index 33320fb5..b0430504 100644 --- a/pdoc/docstrings.py +++ b/pdoc/docstrings.py @@ -360,6 +360,38 @@ def replace_link(m: re.Match[str]) -> str: return contents +def _rst_extract_options(contents: str) -> tuple[str, dict[str, str]]: + """ + Extract options from the beginning of reStructuredText directives. + + Return the trimmed content and a dict of options. + """ + options = {} + while match := re.match(r"^\s*:(.+?):(.*)([\s\S]*)", contents): + key, value, contents = match.groups() + options[key] = value.strip() + + return contents, options + + +def _rst_include_trim(contents: str, options: dict[str, str]) -> str: + """ + + """ + if "end-line" in options or "start-line" in options: + lines = contents.splitlines() + if i := options.get("end-line"): + lines = lines[: int(i)] + if i := options.get("start-line"): + lines = lines[int(i) :] + contents = "\n".join(lines) + if x := options.get("end-before"): + contents = contents[: contents.index(x)] + if x := options.get("start-after"): + contents = contents[contents.index(x) + len(x) :] + return contents + + def _rst_admonitions(contents: str, source_file: Path | None) -> str: """ Convert reStructuredText admonitions - a bit tricky because they may already be indented themselves. @@ -371,6 +403,7 @@ def _rst_admonition(m: re.Match[str]) -> str: type = m.group("type") val = m.group("val").strip() contents = dedent(m.group("contents")).strip() + contents, options = _rst_extract_options(contents) if type == "include": loc = source_file or Path(".") @@ -379,6 +412,10 @@ def _rst_admonition(m: re.Match[str]) -> str: except OSError as e: warnings.warn(f"Cannot include {val!r}: {e}") included = "\n" + try: + included = _rst_include_trim(included, options) + "\n" + except ValueError as e: + warnings.warn(f"Failed to process include options for {val!r}: {e}") included = _rst_admonitions(included, loc.parent / val) included = embed_images(included, loc.parent / val) return indent(included, ind) diff --git a/test/test_docstrings.py b/test/test_docstrings.py index f9a2f247..68ec70be 100644 --- a/test/test_docstrings.py +++ b/test/test_docstrings.py @@ -1,3 +1,5 @@ +from pathlib import Path + from hypothesis import given from hypothesis.strategies import text import pytest @@ -5,7 +7,10 @@ from pdoc import docstrings # The important tests are in test_snapshot.py (and, by extension, testdata/) -# only some fuzzing here. +# mostly some fuzzing here. + + +here = Path(__file__).parent.absolute() @given(text()) @@ -26,6 +31,59 @@ def test_rst(s): assert not s or ret +@given(text()) +def test_rst_extract_options_fuzz(s): + content, options = docstrings._rst_extract_options(s) + assert not s or content or options + + +def test_rst_extract_options(): + content = ( + ":alpha: beta\n" + ":charlie:delta:foxtrot\n" + "rest of content\n" + ":option ignored: as follows content\n" + ) + content, options = docstrings._rst_extract_options(content) + assert options == { + "alpha": "beta", + "charlie": "delta:foxtrot", + } + assert content == ("\nrest of content\n" ":option ignored: as follows content\n") + + +def test_rst_include_trim_lines(): + content = "alpha\nbeta\ncharlie\ndelta\necho" + trimmed = docstrings._rst_include_trim( + content, {"start-line": "2", "end-line": "4"} + ) + assert trimmed == "charlie\ndelta" + + +def test_rst_include_trim_pattern(): + content = "alpha\nbeta\ncharlie\ndelta\necho" + trimmed = docstrings._rst_include_trim( + content, {"start-after": "beta", "end-before": "echo"} + ) + assert trimmed == "\ncharlie\ndelta\n" + + +def test_rst_include_trim_mixture(): + content = "alpha\nbeta\ncharlie\ndelta\necho" + trimmed = docstrings._rst_include_trim( + content, {"start-after": "beta", "end-line": "4"} + ) + assert trimmed == "\ncharlie\ndelta" + + def test_rst_include_nonexistent(): with pytest.warns(UserWarning, match="Cannot include 'nonexistent.txt'"): docstrings.rst(".. include:: nonexistent.txt", None) + + +def test_rst_include_invalid_options(): + with pytest.warns(UserWarning, match="Failed to process include options"): + docstrings.rst( + ".. include:: ../README.md\n :start-line: invalid", + here / "test_docstrings.py", + ) diff --git a/test/testdata/flavors_rst.html b/test/testdata/flavors_rst.html index 58fa07f2..c35c1414 100644 --- a/test/testdata/flavors_rst.html +++ b/test/testdata/flavors_rst.html @@ -56,6 +56,9 @@

API Documentation

  • include
  • +
  • + include_options +
  • fields
  • @@ -227,45 +230,62 @@

    136 """ 137 138 -139def fields(foo: str = "foo", bar: bool = True) -> str: -140 """This method has field descriptions. -141 -142 :param foo: A string, -143 defaults to None -144 :type foo: string, optional -145 :param bar: Another -146 boolean. -147 :return: Another string, -148 or maybe `None`. -149 :rtype: A string. -150 """ -151 raise NotImplementedError -152 -153 -154def fields_text_after_param(foo): -155 """This method has text after the `:param` fields. -156 -157 :param foo: Some text. +139def include_options(): +140 """ +141 Included from another file: +142 +143 .. include:: flavors_rst_include/include_2.md +144 :start-line: 2 +145 :end-line: 5 +146 +147 +148 Also included: +149 +150 .. include:: flavors_rst_include/include_2.md +151 :start-after: <!-- start here --> +152 :end-before: <!-- end here --> +153 """ +154 +155 +156def fields(foo: str = "foo", bar: bool = True) -> str: +157 """This method has field descriptions. 158 -159 Here's some more text. -160 """ -161 -162 -163def fields_invalid(foo: str = "foo") -> str: -164 """This method has invalid `:param` definitions. -165 -166 :param: What is this for? -167 -168 :unknown: This is an unknown field name. -169 """ -170 raise NotImplementedError -171 -172 -173def fields_exception(): -174 """ -175 :raises RuntimeError: Some multi-line -176 exception description. +159 :param foo: A string, +160 defaults to None +161 :type foo: string, optional +162 :param bar: Another +163 boolean. +164 :return: Another string, +165 or maybe `None`. +166 :rtype: A string. +167 """ +168 raise NotImplementedError +169 +170 +171def fields_text_after_param(foo): +172 """This method has text after the `:param` fields. +173 +174 :param foo: Some text. +175 +176 Here's some more text. 177 """ +178 +179 +180def fields_invalid(foo: str = "foo") -> str: +181 """This method has invalid `:param` definitions. +182 +183 :param: What is this for? +184 +185 :unknown: This is an unknown field name. +186 """ +187 raise NotImplementedError +188 +189 +190def fields_exception(): +191 """ +192 :raises RuntimeError: Some multi-line +193 exception description. +194 """ @@ -722,6 +742,53 @@

    This warning has a title only.
    + +
    + +
    + + def + include_options(): + + + +
    + +
    140def include_options():
    +141    """
    +142    Included from another file:
    +143
    +144    .. include:: flavors_rst_include/include_2.md
    +145       :start-line: 2
    +146       :end-line: 5
    +147
    +148
    +149    Also included:
    +150
    +151    .. include:: flavors_rst_include/include_2.md
    +152       :start-after: <!-- start here -->
    +153       :end-before: <!-- end here -->
    +154    """
    +
    + + +

    Included from another file:

    + +

    This paragraph should be included. Lorem ipsum dolor sit amet, consectetur adipiscing +elit. Donec semper vitae elit ac condimentum. Aenean consequat, massa sed +consequat imperdiet, risus sagittis libero eros.

    + +

    Also included:

    + +

    This paragraph should be included. Vestibulum fringilla metus nec +lectus tincidunt tristique. Vivamus erat risus, commodo ut aliquet ut, +imperdiet ac dui. Nulla dolor orci, mollis ac sagittis in, tristique nec ipsum. +Ut sagittis nibh eu ex imperdiet, at aliquet ipsum elementum. Donec vehicula +sem nec ante vulputate feugiat. Nulla facilisi. Phasellus viverra velit id +faucibus commodo.

    +
    + +
    @@ -734,19 +801,19 @@
    This warning has a title only.
    -
    140def fields(foo: str = "foo", bar: bool = True) -> str:
    -141    """This method has field descriptions.
    -142
    -143    :param foo: A string,
    -144        defaults to None
    -145    :type foo: string, optional
    -146    :param bar: Another
    -147     boolean.
    -148    :return: Another string,
    -149        or maybe `None`.
    -150    :rtype: A string.
    -151    """
    -152    raise NotImplementedError
    +            
    157def fields(foo: str = "foo", bar: bool = True) -> str:
    +158    """This method has field descriptions.
    +159
    +160    :param foo: A string,
    +161        defaults to None
    +162    :type foo: string, optional
    +163    :param bar: Another
    +164     boolean.
    +165    :return: Another string,
    +166        or maybe `None`.
    +167    :rtype: A string.
    +168    """
    +169    raise NotImplementedError
     
    @@ -782,13 +849,13 @@
    Returns
    -
    155def fields_text_after_param(foo):
    -156    """This method has text after the `:param` fields.
    -157
    -158    :param foo: Some text.
    -159
    -160    Here's some more text.
    -161    """
    +            
    172def fields_text_after_param(foo):
    +173    """This method has text after the `:param` fields.
    +174
    +175    :param foo: Some text.
    +176
    +177    Here's some more text.
    +178    """
     
    @@ -816,14 +883,14 @@
    Parameters
    -
    164def fields_invalid(foo: str = "foo") -> str:
    -165    """This method has invalid `:param` definitions.
    -166
    -167    :param: What is this for?
    -168
    -169    :unknown: This is an unknown field name.
    -170    """
    -171    raise NotImplementedError
    +            
    181def fields_invalid(foo: str = "foo") -> str:
    +182    """This method has invalid `:param` definitions.
    +183
    +184    :param: What is this for?
    +185
    +186    :unknown: This is an unknown field name.
    +187    """
    +188    raise NotImplementedError
     
    @@ -851,11 +918,11 @@
    Parameters
    -
    174def fields_exception():
    -175    """
    -176    :raises RuntimeError: Some multi-line
    -177        exception description.
    -178    """
    +            
    191def fields_exception():
    +192    """
    +193    :raises RuntimeError: Some multi-line
    +194        exception description.
    +195    """
     
    diff --git a/test/testdata/flavors_rst.py b/test/testdata/flavors_rst.py index 3fc9f2d5..f1f16b12 100644 --- a/test/testdata/flavors_rst.py +++ b/test/testdata/flavors_rst.py @@ -136,6 +136,23 @@ def include(): """ +def include_options(): + """ + Included from another file: + + .. include:: flavors_rst_include/include_2.md + :start-line: 2 + :end-line: 5 + + + Also included: + + .. include:: flavors_rst_include/include_2.md + :start-after: + :end-before: + """ + + def fields(foo: str = "foo", bar: bool = True) -> str: """This method has field descriptions. diff --git a/test/testdata/flavors_rst.txt b/test/testdata/flavors_rst.txt index aae5ee7b..1908a0b3 100644 --- a/test/testdata/flavors_rst.txt +++ b/test/testdata/flavors_rst.txt @@ -14,6 +14,7 @@ + str: ... # This method has fiel…> str: ... # This method has inva…> diff --git a/test/testdata/flavors_rst_include/include_2.md b/test/testdata/flavors_rst_include/include_2.md new file mode 100644 index 00000000..3ac3e0df --- /dev/null +++ b/test/testdata/flavors_rst_include/include_2.md @@ -0,0 +1,13 @@ +# Should not be included + +This paragraph should be included. Lorem ipsum dolor sit amet, consectetur adipiscing +elit. Donec semper vitae elit ac condimentum. Aenean consequat, massa sed +consequat imperdiet, risus sagittis libero eros. +This line should not be included. + +This paragraph should be included. Vestibulum fringilla metus nec +lectus tincidunt tristique. Vivamus erat risus, commodo ut aliquet ut, +imperdiet ac dui. Nulla dolor orci, mollis ac sagittis in, tristique nec ipsum. +Ut sagittis nibh eu ex imperdiet, at aliquet ipsum elementum. Donec vehicula +sem nec ante vulputate feugiat. Nulla facilisi. Phasellus viverra velit id +faucibus commodo.This sentence should not be included.