From e0abb107929df0d2e97daa7cddeda1e7b4763d60 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 13 Apr 2019 20:14:09 +0900 Subject: [PATCH 01/15] Fix #4777: Add :async: option to py:function and py:method directives --- CHANGES | 5 ++-- doc/usage/restructuredtext/domains.rst | 12 +++++++++- sphinx/domains/python.py | 25 ++++++++++++++++--- tests/test_domain_py.py | 33 ++++++++++++++++++++++---- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/CHANGES b/CHANGES index e07b67059..013b0733f 100644 --- a/CHANGES +++ b/CHANGES @@ -78,8 +78,9 @@ Features added * #6212 autosummary: Add :confval:`autosummary_imported_members` to display imported members on autosummary * #6271: ``make clean`` is catastrophically broken if building into '.' -* Add ``:classmethod:`` and ``:staticmethod:`` options to :rst:dir:`py:method` - directive +* #4777: py domain: Add ``:async:`` option to :rst:dir:`py:function` directive +* py domain: Add ``:async:``, ``:classmethod:`` and ``:staticmethod:`` options + to :rst:dir:`py:method` directive Bugs fixed ---------- diff --git a/doc/usage/restructuredtext/domains.rst b/doc/usage/restructuredtext/domains.rst index 10dc93a07..10fbf6f6f 100644 --- a/doc/usage/restructuredtext/domains.rst +++ b/doc/usage/restructuredtext/domains.rst @@ -169,6 +169,13 @@ The following directives are provided for module and class contents: This information can (in any ``py`` directive) optionally be given in a structured form, see :ref:`info-field-lists`. + The ``async`` option can be given (with no value) to indicate the function is + an async method. + + .. versionchanged:: 2.1 + + ``:async:`` option added. + .. rst:directive:: .. py:data:: name Describes global data in a module, including both variables and values used @@ -216,12 +223,15 @@ The following directives are provided for module and class contents: described for ``function``. See also :ref:`signatures` and :ref:`info-field-lists`. + The ``async`` option can be given (with no value) to indicate the method is + an async method. + The ``classmethod`` option and ``staticmethod`` option can be given (with no value) to indicate the method is a class method (or a static method). .. versionchanged:: 2.1 - ``:classmethod:`` and ``:staticmethod:`` options added. + ``:async:``, ``:classmethod:`` and ``:staticmethod:`` options added. .. rst:directive:: .. py:staticmethod:: name(parameters) diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index e268023a5..c1ef3f990 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -438,6 +438,18 @@ class PyModulelevel(PyObject): class PyFunction(PyObject): """Description of a function.""" + option_spec = PyObject.option_spec.copy() + option_spec.update({ + 'async': directives.flag, + }) + + def get_signature_prefix(self, sig): + # type: (str) -> str + if 'async' in self.options: + return 'async ' + else: + return '' + def needs_arglist(self): # type: () -> bool return True @@ -573,6 +585,7 @@ class PyMethod(PyObject): option_spec = PyObject.option_spec.copy() option_spec.update({ + 'async': directives.flag, 'classmethod': directives.flag, 'staticmethod': directives.flag, }) @@ -583,10 +596,16 @@ class PyMethod(PyObject): def get_signature_prefix(self, sig): # type: (str) -> str + prefix = [] + if 'async' in self.options: + prefix.append('async') if 'staticmethod' in self.options: - return 'static ' - elif 'classmethod' in self.options: - return 'classmethod ' + prefix.append('static') + if 'classmethod' in self.options: + prefix.append('classmethod') + + if prefix: + return ' '.join(prefix) + ' ' else: return '' diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index 5a4db3299..d3c685388 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -304,15 +304,24 @@ def test_pydata(app): def test_pyfunction(app): - text = ".. py:function:: func\n" + text = (".. py:function:: func1\n" + ".. py:function:: func2\n" + " :async:\n") domain = app.env.get_domain('py') doctree = restructuredtext.parse(app, text) assert_node(doctree, (addnodes.index, - [desc, ([desc_signature, ([desc_name, "func"], + [desc, ([desc_signature, ([desc_name, "func1"], + [desc_parameterlist, ()])], + [desc_content, ()])], + addnodes.index, + [desc, ([desc_signature, ([desc_annotation, "async "], + [desc_name, "func2"], [desc_parameterlist, ()])], [desc_content, ()])])) - assert 'func' in domain.objects - assert domain.objects['func'] == ('index', 'function') + assert 'func1' in domain.objects + assert domain.objects['func1'] == ('index', 'function') + assert 'func2' in domain.objects + assert domain.objects['func2'] == ('index', 'function') def test_pymethod_options(app): @@ -322,7 +331,9 @@ def test_pymethod_options(app): " .. py:method:: meth2\n" " :classmethod:\n" " .. py:method:: meth3\n" - " :staticmethod:\n") + " :staticmethod:\n" + " .. py:method:: meth4\n" + " :async:\n") domain = app.env.get_domain('py') doctree = restructuredtext.parse(app, text) assert_node(doctree, (addnodes.index, @@ -333,6 +344,8 @@ def test_pymethod_options(app): addnodes.index, desc, addnodes.index, + desc, + addnodes.index, desc)])])) # method @@ -364,6 +377,16 @@ def test_pymethod_options(app): assert 'Class.meth3' in domain.objects assert domain.objects['Class.meth3'] == ('index', 'method') + # :async: + assert_node(doctree[1][1][6], addnodes.index, + entries=[('single', 'meth4() (Class method)', 'Class.meth4', '', None)]) + assert_node(doctree[1][1][7], ([desc_signature, ([desc_annotation, "async "], + [desc_name, "meth4"], + [desc_parameterlist, ()])], + [desc_content, ()])) + assert 'Class.meth4' in domain.objects + assert domain.objects['Class.meth4'] == ('index', 'method') + def test_pyclassmethod(app): text = (".. py:class:: Class\n" From a77613fcfaf1323a46ba5645cc5569da7457d2fa Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 13 Apr 2019 23:08:52 +0900 Subject: [PATCH 02/15] pycode: Support "async" syntax --- sphinx/pycode/parser.py | 4 ++++ tests/test_pycode_parser.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/sphinx/pycode/parser.py b/sphinx/pycode/parser.py index bf80f4367..9f9f7dd29 100644 --- a/sphinx/pycode/parser.py +++ b/sphinx/pycode/parser.py @@ -381,6 +381,10 @@ class VariableCommentPicker(ast.NodeVisitor): self.context.pop() self.current_function = None + def visit_AsyncFunctionDef(self, node): + # type: (ast.AsyncFunctionDef) -> None + self.visit_FunctionDef(node) # type: ignore + class DefinitionFinder(TokenProcessor): def __init__(self, lines): diff --git a/tests/test_pycode_parser.py b/tests/test_pycode_parser.py index 403c918dc..ba9778b80 100644 --- a/tests/test_pycode_parser.py +++ b/tests/test_pycode_parser.py @@ -314,6 +314,21 @@ def test_decorators(): 'Foo.method': ('def', 13, 15)} +def test_async_function_and_method(): + source = ('async def some_function():\n' + ' """docstring"""\n' + ' a = 1 + 1 #: comment1\n' + '\n' + 'class Foo:\n' + ' async def method(self):\n' + ' pass\n') + parser = Parser(source) + parser.parse() + assert parser.definitions == {'some_function': ('def', 1, 3), + 'Foo': ('class', 5, 7), + 'Foo.method': ('def', 6, 7)} + + def test_formfeed_char(): source = ('class Foo:\n' '\f\n' From a765c2e4ab13e10dd613711a8da93940fd124d43 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 13 Apr 2019 23:01:55 +0900 Subject: [PATCH 03/15] Add sphinx.util.inspect:iscoroutinefunction() --- sphinx/util/inspect.py | 16 ++++++++++++++-- tests/roots/test-ext-autodoc/target/functions.py | 4 ++++ tests/roots/test-ext-autodoc/target/methods.py | 5 +++++ tests/test_util_inspect.py | 16 ++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index 877f727d4..a05110496 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -15,7 +15,7 @@ import re import sys import typing import warnings -from functools import partial +from functools import partial, partialmethod from inspect import ( # NOQA isclass, ismethod, ismethoddescriptor, isroutine ) @@ -129,7 +129,7 @@ def isenumattribute(x): def ispartial(obj): # type: (Any) -> bool """Check if the object is partial.""" - return isinstance(obj, partial) + return isinstance(obj, (partial, partialmethod)) def isclassmethod(obj): @@ -212,6 +212,18 @@ def isbuiltin(obj): return inspect.isbuiltin(obj) or ispartial(obj) and inspect.isbuiltin(obj.func) +def iscoroutinefunction(obj): + # type: (Any) -> bool + """Check if the object is coroutine-function.""" + if inspect.iscoroutinefunction(obj): + return True + elif ispartial(obj) and inspect.iscoroutinefunction(obj.func): + # partialed + return True + else: + return False + + def safe_getattr(obj, name, *defargs): # type: (Any, str, str) -> object """A getattr() that turns all exceptions into AttributeErrors.""" diff --git a/tests/roots/test-ext-autodoc/target/functions.py b/tests/roots/test-ext-autodoc/target/functions.py index 7c79188d9..8ff00f734 100644 --- a/tests/roots/test-ext-autodoc/target/functions.py +++ b/tests/roots/test-ext-autodoc/target/functions.py @@ -5,7 +5,11 @@ def func(): pass +async def coroutinefunc(): + pass + partial_func = partial(func) +partial_coroutinefunc = partial(coroutinefunc) builtin_func = print partial_builtin_func = partial(print) diff --git a/tests/roots/test-ext-autodoc/target/methods.py b/tests/roots/test-ext-autodoc/target/methods.py index 49122eb4c..ad5a6a952 100644 --- a/tests/roots/test-ext-autodoc/target/methods.py +++ b/tests/roots/test-ext-autodoc/target/methods.py @@ -19,6 +19,11 @@ class Base(): partialmeth = partialmethod(meth) + async def coroutinemeth(self): + pass + + partial_coroutinemeth = partialmethod(coroutinemeth) + class Inherited(Base): pass diff --git a/tests/test_util_inspect.py b/tests/test_util_inspect.py index 275206526..c298e2c64 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -397,6 +397,22 @@ def test_isstaticmethod(app): assert inspect.isstaticmethod(Inherited.meth, Inherited, 'meth') is False +@pytest.mark.sphinx(testroot='ext-autodoc') +def test_iscoroutinefunction(app): + from target.functions import coroutinefunc, func, partial_coroutinefunc + from target.methods import Base + + assert inspect.iscoroutinefunction(func) is False # function + assert inspect.iscoroutinefunction(coroutinefunc) is True # coroutine + assert inspect.iscoroutinefunction(partial_coroutinefunc) is True # partial-ed coroutine + assert inspect.iscoroutinefunction(Base.meth) is False # method + assert inspect.iscoroutinefunction(Base.coroutinemeth) is True # coroutine-method + + # partial-ed coroutine-method + partial_coroutinemeth = Base.__dict__['partial_coroutinemeth'] + assert inspect.iscoroutinefunction(partial_coroutinemeth) is True + + @pytest.mark.sphinx(testroot='ext-autodoc') def test_isfunction(app): from target.functions import builtin_func, partial_builtin_func From 435ef05b99a73a8b1da1393219d3c660be1b9516 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 13 Apr 2019 23:37:16 +0900 Subject: [PATCH 04/15] Close #4777: autodoc: Support coroutine --- CHANGES | 1 + sphinx/ext/autodoc/__init__.py | 12 +++++++++++- tests/test_autodoc.py | 10 ++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 013b0733f..9cc8cab22 100644 --- a/CHANGES +++ b/CHANGES @@ -75,6 +75,7 @@ Features added functions * #6289: autodoc: :confval:`autodoc_default_options` now supports ``imported-members`` option +* #4777: autodoc: Support coroutine * #6212 autosummary: Add :confval:`autosummary_imported_members` to display imported members on autosummary * #6271: ``make clean`` is catastrophically broken if building into '.' diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index 61f728ed3..2a4df2159 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -1034,6 +1034,14 @@ class FunctionDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # typ # type: (bool) -> None pass + def add_directive_header(self, sig): + # type: (str) -> None + sourcename = self.get_sourcename() + super().add_directive_header(sig) + + if inspect.iscoroutinefunction(self.object): + self.add_line(' :async:', sourcename) + class DecoratorDocumenter(FunctionDocumenter): """ @@ -1318,9 +1326,11 @@ class MethodDocumenter(DocstringSignatureMixin, ClassLevelDocumenter): # type: sourcename = self.get_sourcename() obj = self.parent.__dict__.get(self.object_name, self.object) + if inspect.iscoroutinefunction(obj): + self.add_line(' :async:', sourcename) if inspect.isclassmethod(obj): self.add_line(' :classmethod:', sourcename) - elif inspect.isstaticmethod(obj, cls=self.parent, name=self.object_name): + if inspect.isstaticmethod(obj, cls=self.parent, name=self.object_name): self.add_line(' :staticmethod:', sourcename) def document_members(self, all_members=False): diff --git a/tests/test_autodoc.py b/tests/test_autodoc.py index 49a02cfb4..5f616b791 100644 --- a/tests/test_autodoc.py +++ b/tests/test_autodoc.py @@ -1523,6 +1523,15 @@ def test_bound_method(): @pytest.mark.usefixtures('setup_test') def test_coroutine(): + actual = do_autodoc(app, 'function', 'target.functions.coroutinefunc') + assert list(actual) == [ + '', + '.. py:function:: coroutinefunc()', + ' :module: target.functions', + ' :async:', + '', + ] + options = {"members": None} actual = do_autodoc(app, 'class', 'target.coroutine.AsyncClass', options) assert list(actual) == [ @@ -1533,6 +1542,7 @@ def test_coroutine(): ' ', ' .. py:method:: AsyncClass.do_coroutine()', ' :module: target.coroutine', + ' :async:', ' ', ' A documented coroutine function', ' ' From 877ddd795e3b3994f6183e372c30dbf2f43f041f Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Wed, 24 Apr 2019 01:35:10 +0900 Subject: [PATCH 05/15] Add docstring to pycode.parser --- sphinx/pycode/parser.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sphinx/pycode/parser.py b/sphinx/pycode/parser.py index 9f9f7dd29..f9489e91a 100644 --- a/sphinx/pycode/parser.py +++ b/sphinx/pycode/parser.py @@ -383,10 +383,15 @@ class VariableCommentPicker(ast.NodeVisitor): def visit_AsyncFunctionDef(self, node): # type: (ast.AsyncFunctionDef) -> None + """Handles AsyncFunctionDef node and set context.""" self.visit_FunctionDef(node) # type: ignore class DefinitionFinder(TokenProcessor): + """Python source code parser to detect location of functions, + classes and methods. + """ + def __init__(self, lines): # type: (List[str]) -> None super().__init__(lines) @@ -397,6 +402,7 @@ class DefinitionFinder(TokenProcessor): def add_definition(self, name, entry): # type: (str, Tuple[str, int, int]) -> None + """Add a location of definition.""" if self.indents and self.indents[-1][0] == 'def' and entry[0] == 'def': # ignore definition of inner function pass @@ -405,6 +411,7 @@ class DefinitionFinder(TokenProcessor): def parse(self): # type: () -> None + """Parse the code to obtain location of definitions.""" while True: token = self.fetch_token() if token is None: @@ -426,6 +433,7 @@ class DefinitionFinder(TokenProcessor): def parse_definition(self, typ): # type: (str) -> None + """Parse AST of definition.""" name = self.fetch_token() self.context.append(name.value) funcname = '.'.join(self.context) @@ -447,6 +455,7 @@ class DefinitionFinder(TokenProcessor): def finalize_block(self): # type: () -> None + """Finalize definition block.""" definition = self.indents.pop() if definition[0] != 'other': typ, funcname, start_pos = definition From ee23a29b3c9cd133bfacceaaccc73f960d1f318f Mon Sep 17 00:00:00 2001 From: Jon Dufresne Date: Tue, 23 Apr 2019 18:01:03 -0700 Subject: [PATCH 06/15] Deprecate EpubBuilder.esc() in favor of stdlib html.escape() Available since Python 3.2. https://docs.python.org/3/library/html.html#html.escape --- CHANGES | 1 + doc/extdev/deprecated.rst | 5 +++ sphinx/builders/_epub_base.py | 77 ++++++++++++++++++----------------- sphinx/builders/epub3.py | 13 +++--- 4 files changed, 53 insertions(+), 43 deletions(-) diff --git a/CHANGES b/CHANGES index 9cc8cab22..8a7c6647b 100644 --- a/CHANGES +++ b/CHANGES @@ -16,6 +16,7 @@ Deprecated ---------- * ``sphinx.builders.latex.LaTeXBuilder.apply_transforms()`` +* ``sphinx.builders._epub_base.EpubBuilder.esc()`` * ``sphinx.directives.Acks`` * ``sphinx.directives.Author`` * ``sphinx.directives.Centered`` diff --git a/doc/extdev/deprecated.rst b/doc/extdev/deprecated.rst index 52fdf9f7a..6e9ce34f0 100644 --- a/doc/extdev/deprecated.rst +++ b/doc/extdev/deprecated.rst @@ -31,6 +31,11 @@ The following is a list of deprecated interfaces. - 4.0 - N/A + * - ``sphinx.builders._epub_base.EpubBuilder.esc()`` + - 2.1 + - 4.0 + - ``html.escape()`` + * - ``sphinx.directives.Acks`` - 2.1 - 4.0 diff --git a/sphinx/builders/_epub_base.py b/sphinx/builders/_epub_base.py index 90ab6c12d..140f2748d 100644 --- a/sphinx/builders/_epub_base.py +++ b/sphinx/builders/_epub_base.py @@ -8,6 +8,7 @@ :license: BSD, see LICENSE for details. """ +import html import os import re import warnings @@ -178,7 +179,9 @@ class EpubBuilder(StandaloneHTMLBuilder): def esc(self, name): # type: (str) -> str """Replace all characters not allowed in text an attribute values.""" - # Like cgi.escape, but also replace apostrophe + warnings.warn( + '%s.esc() is deprecated. Use html.escape() instead.' % self.__class__.__name__, + RemovedInSphinx40Warning) name = name.replace('&', '&') name = name.replace('<', '<') name = name.replace('>', '>') @@ -201,8 +204,8 @@ class EpubBuilder(StandaloneHTMLBuilder): if (self.toctree_template % level) in classes: result.append({ 'level': level, - 'refuri': self.esc(refuri), - 'text': ssp(self.esc(doctree.astext())) + 'refuri': html.escape(refuri), + 'text': ssp(html.escape(doctree.astext())) }) break elif isinstance(doctree, nodes.Element): @@ -241,21 +244,21 @@ class EpubBuilder(StandaloneHTMLBuilder): """ refnodes.insert(0, { 'level': 1, - 'refuri': self.esc(self.config.master_doc + self.out_suffix), - 'text': ssp(self.esc( + 'refuri': html.escape(self.config.master_doc + self.out_suffix), + 'text': ssp(html.escape( self.env.titles[self.config.master_doc].astext())) }) for file, text in reversed(self.config.epub_pre_files): refnodes.insert(0, { 'level': 1, - 'refuri': self.esc(file), - 'text': ssp(self.esc(text)) + 'refuri': html.escape(file), + 'text': ssp(html.escape(text)) }) for file, text in self.config.epub_post_files: refnodes.append({ 'level': 1, - 'refuri': self.esc(file), - 'text': ssp(self.esc(text)) + 'refuri': html.escape(file), + 'text': ssp(html.escape(text)) }) def fix_fragment(self, prefix, fragment): @@ -511,15 +514,15 @@ class EpubBuilder(StandaloneHTMLBuilder): file properly escaped. """ metadata = {} # type: Dict[str, Any] - metadata['title'] = self.esc(self.config.epub_title) - metadata['author'] = self.esc(self.config.epub_author) - metadata['uid'] = self.esc(self.config.epub_uid) - metadata['lang'] = self.esc(self.config.epub_language) - metadata['publisher'] = self.esc(self.config.epub_publisher) - metadata['copyright'] = self.esc(self.config.epub_copyright) - metadata['scheme'] = self.esc(self.config.epub_scheme) - metadata['id'] = self.esc(self.config.epub_identifier) - metadata['date'] = self.esc(format_date("%Y-%m-%d")) + metadata['title'] = html.escape(self.config.epub_title) + metadata['author'] = html.escape(self.config.epub_author) + metadata['uid'] = html.escape(self.config.epub_uid) + metadata['lang'] = html.escape(self.config.epub_language) + metadata['publisher'] = html.escape(self.config.epub_publisher) + metadata['copyright'] = html.escape(self.config.epub_copyright) + metadata['scheme'] = html.escape(self.config.epub_scheme) + metadata['id'] = html.escape(self.config.epub_identifier) + metadata['date'] = html.escape(format_date("%Y-%m-%d")) metadata['manifest_items'] = [] metadata['spines'] = [] metadata['guides'] = [] @@ -566,9 +569,9 @@ class EpubBuilder(StandaloneHTMLBuilder): type='epub', subtype='unknown_project_files') continue filename = filename.replace(os.sep, '/') - item = ManifestItem(self.esc(filename), - self.esc(self.make_id(filename)), - self.esc(self.media_types[ext])) + item = ManifestItem(html.escape(filename), + html.escape(self.make_id(filename)), + html.escape(self.media_types[ext])) metadata['manifest_items'].append(item) self.files.append(filename) @@ -579,21 +582,21 @@ class EpubBuilder(StandaloneHTMLBuilder): continue if refnode['refuri'] in self.ignored_files: continue - spine = Spine(self.esc(self.make_id(refnode['refuri'])), True) + spine = Spine(html.escape(self.make_id(refnode['refuri'])), True) metadata['spines'].append(spine) spinefiles.add(refnode['refuri']) for info in self.domain_indices: - spine = Spine(self.esc(self.make_id(info[0] + self.out_suffix)), True) + spine = Spine(html.escape(self.make_id(info[0] + self.out_suffix)), True) metadata['spines'].append(spine) spinefiles.add(info[0] + self.out_suffix) if self.use_index: - spine = Spine(self.esc(self.make_id('genindex' + self.out_suffix)), True) + spine = Spine(html.escape(self.make_id('genindex' + self.out_suffix)), True) metadata['spines'].append(spine) spinefiles.add('genindex' + self.out_suffix) # add auto generated files for name in self.files: if name not in spinefiles and name.endswith(self.out_suffix): - spine = Spine(self.esc(self.make_id(name)), False) + spine = Spine(html.escape(self.make_id(name)), False) metadata['spines'].append(spine) # add the optional cover @@ -601,18 +604,18 @@ class EpubBuilder(StandaloneHTMLBuilder): if self.config.epub_cover: image, html_tmpl = self.config.epub_cover image = image.replace(os.sep, '/') - metadata['cover'] = self.esc(self.make_id(image)) + metadata['cover'] = html.escape(self.make_id(image)) if html_tmpl: - spine = Spine(self.esc(self.make_id(self.coverpage_name)), True) + spine = Spine(html.escape(self.make_id(self.coverpage_name)), True) metadata['spines'].insert(0, spine) if self.coverpage_name not in self.files: ext = path.splitext(self.coverpage_name)[-1] self.files.append(self.coverpage_name) - item = ManifestItem(self.esc(self.coverpage_name), - self.esc(self.make_id(self.coverpage_name)), - self.esc(self.media_types[ext])) + item = ManifestItem(html.escape(self.coverpage_name), + html.escape(self.make_id(self.coverpage_name)), + html.escape(self.media_types[ext])) metadata['manifest_items'].append(item) - ctx = {'image': self.esc(image), 'title': self.config.project} + ctx = {'image': html.escape(image), 'title': self.config.project} self.handle_page( path.splitext(self.coverpage_name)[0], ctx, html_tmpl) spinefiles.add(self.coverpage_name) @@ -628,17 +631,17 @@ class EpubBuilder(StandaloneHTMLBuilder): auto_add_cover = False if type == 'toc': auto_add_toc = False - metadata['guides'].append(Guide(self.esc(type), - self.esc(title), - self.esc(uri))) + metadata['guides'].append(Guide(html.escape(type), + html.escape(title), + html.escape(uri))) if auto_add_cover and html_tmpl: metadata['guides'].append(Guide('cover', self.guide_titles['cover'], - self.esc(self.coverpage_name))) + html.escape(self.coverpage_name))) if auto_add_toc and self.refnodes: metadata['guides'].append(Guide('toc', self.guide_titles['toc'], - self.esc(self.refnodes[0]['refuri']))) + html.escape(self.refnodes[0]['refuri']))) # write the project file copy_asset_file(path.join(self.template_dir, 'content.opf_t'), @@ -707,7 +710,7 @@ class EpubBuilder(StandaloneHTMLBuilder): """ metadata = {} # type: Dict[str, Any] metadata['uid'] = self.config.epub_uid - metadata['title'] = self.esc(self.config.epub_title) + metadata['title'] = html.escape(self.config.epub_title) metadata['level'] = level metadata['navpoints'] = navpoints return metadata diff --git a/sphinx/builders/epub3.py b/sphinx/builders/epub3.py index 3116cd493..9b3f58d7a 100644 --- a/sphinx/builders/epub3.py +++ b/sphinx/builders/epub3.py @@ -9,6 +9,7 @@ :license: BSD, see LICENSE for details. """ +import html import warnings from collections import namedtuple from os import path @@ -98,12 +99,12 @@ class Epub3Builder(_epub_base.EpubBuilder): writing_mode = self.config.epub_writing_mode metadata = super().content_metadata() - metadata['description'] = self.esc(self.config.epub_description) - metadata['contributor'] = self.esc(self.config.epub_contributor) + metadata['description'] = html.escape(self.config.epub_description) + metadata['contributor'] = html.escape(self.config.epub_contributor) metadata['page_progression_direction'] = PAGE_PROGRESSION_DIRECTIONS.get(writing_mode) metadata['ibook_scroll_axis'] = IBOOK_SCROLL_AXIS.get(writing_mode) - metadata['date'] = self.esc(format_date("%Y-%m-%dT%H:%M:%SZ")) - metadata['version'] = self.esc(self.config.version) + metadata['date'] = html.escape(format_date("%Y-%m-%dT%H:%M:%SZ")) + metadata['version'] = html.escape(self.config.version) metadata['epub_version'] = self.config.epub_version return metadata @@ -166,8 +167,8 @@ class Epub3Builder(_epub_base.EpubBuilder): properly escaped. """ metadata = {} # type: Dict - metadata['lang'] = self.esc(self.config.epub_language) - metadata['toc_locale'] = self.esc(self.guide_titles['toc']) + metadata['lang'] = html.escape(self.config.epub_language) + metadata['toc_locale'] = html.escape(self.guide_titles['toc']) metadata['navlist'] = navlist return metadata From 662389b8b08e2c0dccba36b69590bb0cc4dc424d Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 2 May 2019 13:46:10 +0900 Subject: [PATCH 07/15] Add rst.heading() --- sphinx/util/rst.py | 35 ++++++++++++++++++++++++++++++++++- tests/test_util_rst.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/sphinx/util/rst.py b/sphinx/util/rst.py index c3d32feb7..c897b075a 100644 --- a/sphinx/util/rst.py +++ b/sphinx/util/rst.py @@ -9,11 +9,14 @@ """ import re +from collections import defaultdict from contextlib import contextmanager +from unicodedata import east_asian_width from docutils.parsers.rst import roles from docutils.parsers.rst.languages import en as english from docutils.utils import Reporter +from jinja2 import environmentfilter from sphinx.locale import __ from sphinx.util import docutils @@ -21,13 +24,20 @@ from sphinx.util import logging if False: # For type annotation - from typing import Generator # NOQA + from typing import Callable, Dict, Generator # NOQA from docutils.statemachine import StringList # NOQA + from jinja2 import Environment # NOQA logger = logging.getLogger(__name__) docinfo_re = re.compile(':\\w+:.*?') symbols_re = re.compile(r'([!-\-/:-@\[-`{-~])') # symbols without dot(0x2e) +SECTIONING_CHARS = ['=', '-', '~'] + +# width of characters +WIDECHARS = defaultdict(lambda: "WF") # type: Dict[str, str] + # WF: Wide + Full-width +WIDECHARS["ja"] = "WFA" # In Japanese, Ambiguous characters also have double width def escape(text): @@ -37,6 +47,29 @@ def escape(text): return text +def textwidth(text, widechars='WF'): + # type: (str, str) -> int + """Get width of text.""" + def charwidth(char, widechars): + # type: (str, str) -> int + if east_asian_width(char) in widechars: + return 2 + else: + return 1 + + return sum(charwidth(c, widechars) for c in text) + + +@environmentfilter +def heading(env, text, level=1): + # type: (Environment, str, int) -> str + """Create a heading for *level*.""" + assert level <= 3 + width = textwidth(text, WIDECHARS[env.language]) # type: ignore + sectioning_char = SECTIONING_CHARS[level - 1] + return '%s\n%s' % (text, sectioning_char * width) + + @contextmanager def default_role(docname, name): # type: (str, str) -> Generator diff --git a/tests/test_util_rst.py b/tests/test_util_rst.py index ba836ff1e..1e72eda45 100644 --- a/tests/test_util_rst.py +++ b/tests/test_util_rst.py @@ -9,8 +9,11 @@ """ from docutils.statemachine import StringList +from jinja2 import Environment -from sphinx.util.rst import append_epilog, escape, prepend_prolog +from sphinx.util.rst import ( + append_epilog, escape, heading, prepend_prolog, textwidth +) def test_escape(): @@ -83,3 +86,34 @@ def test_prepend_prolog_without_CR(app): ('', 0, ''), ('dummy.rst', 0, 'hello Sphinx world'), ('dummy.rst', 1, 'Sphinx is a document generator')] + + +def test_textwidth(): + assert textwidth('Hello') == 5 + assert textwidth('русский язык') == 12 + assert textwidth('русский язык', 'WFA') == 23 # Cyrillic are ambiguous chars + + +def test_heading(): + env = Environment() + env.extend(language=None) + + assert heading(env, 'Hello') == ('Hello\n' + '=====') + assert heading(env, 'Hello', 1) == ('Hello\n' + '=====') + assert heading(env, 'Hello', 2) == ('Hello\n' + '-----') + assert heading(env, 'Hello', 3) == ('Hello\n' + '~~~~~') + assert heading(env, 'русский язык', 1) == ( + 'русский язык\n' + '============' + ) + + # language=ja: ambiguous + env.language = 'ja' + assert heading(env, 'русский язык', 1) == ( + 'русский язык\n' + '=======================' + ) From d960f2265d7a035459f0b0200c26d07dd999c4b5 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 2 May 2019 13:46:22 +0900 Subject: [PATCH 08/15] Add ReSTRenderer --- sphinx/util/template.py | 16 +++++++++++++++- tests/test_util_template.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/test_util_template.py diff --git a/sphinx/util/template.py b/sphinx/util/template.py index c33e16819..b521c5c79 100644 --- a/sphinx/util/template.py +++ b/sphinx/util/template.py @@ -15,7 +15,7 @@ from jinja2.sandbox import SandboxedEnvironment from sphinx import package_dir from sphinx.jinja2glue import SphinxFileSystemLoader from sphinx.locale import get_translator -from sphinx.util import texescape +from sphinx.util import rst, texescape if False: # For type annotation @@ -84,3 +84,17 @@ class LaTeXRenderer(SphinxRenderer): self.env.variable_end_string = '%>' self.env.block_start_string = '<%' self.env.block_end_string = '%>' + + +class ReSTRenderer(SphinxRenderer): + def __init__(self, template_path=None, language=None): + # type: (str, str) -> None + super().__init__(template_path) + + # add language to environment + self.env.extend(language=language) + + # use texescape as escape filter + self.env.filters['e'] = rst.escape + self.env.filters['escape'] = rst.escape + self.env.filters['heading'] = rst.heading diff --git a/tests/test_util_template.py b/tests/test_util_template.py new file mode 100644 index 000000000..b25e9dc87 --- /dev/null +++ b/tests/test_util_template.py @@ -0,0 +1,37 @@ +""" + test_util_template + ~~~~~~~~~~~~~~~~~~ + + Tests sphinx.util.template functions. + + :copyright: Copyright 2007-2019 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" + +from sphinx.util.template import ReSTRenderer + + +def test_ReSTRenderer_escape(): + r = ReSTRenderer() + template = '{{ "*hello*" | e }}' + assert r.render_string(template, {}) == r'\*hello\*' + + +def test_ReSTRenderer_heading(): + r = ReSTRenderer() + + template = '{{ "hello" | heading }}' + assert r.render_string(template, {}) == 'hello\n=====' + + template = '{{ "hello" | heading(1) }}' + assert r.render_string(template, {}) == 'hello\n=====' + + template = '{{ "русский язык" | heading(2) }}' + assert r.render_string(template, {}) == ('русский язык\n' + '------------') + + # language: ja + r.env.language = 'ja' + template = '{{ "русский язык" | heading }}' + assert r.render_string(template, {}) == ('русский язык\n' + '=======================') From 00a5aa73012eeeece82c689f2a44c99287267406 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 4 May 2019 12:42:17 +0900 Subject: [PATCH 09/15] Add extra requirements for docs (refs: #6336) --- setup.py | 3 +++ tox.ini | 5 ++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 30f8625c8..91b3e12cc 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,9 @@ extras_require = { ':sys_platform=="win32"': [ 'colorama>=0.3.5', ], + 'docs': [ + 'sphinxcontrib-websupport', + ], 'test': [ 'pytest', 'pytest-cov', diff --git a/tox.ini b/tox.ini index ea404b4a8..64bebf2fb 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,6 @@ deps = du14: docutils==0.14 extras = test - websupport setenv = PYTHONWARNINGS = all,ignore::ImportWarning:pkgutil,ignore::ImportWarning:importlib._bootstrap,ignore::ImportWarning:importlib._bootstrap_external,ignore::ImportWarning:pytest_cov.plugin,ignore::DeprecationWarning:site,ignore::DeprecationWarning:_pytest.assertion.rewrite,ignore::DeprecationWarning:_pytest.fixtures,ignore::DeprecationWarning:distutils commands= @@ -62,8 +61,8 @@ commands= basepython = python3 description = Build documentation. -deps = - sphinxcontrib-websupport +extras = + docs commands = python setup.py build_sphinx {posargs} From b482e38ca2e81b241b3c16f72d5df9296a267389 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 2 May 2019 19:31:28 +0900 Subject: [PATCH 10/15] apidoc: Use a template for generating module file --- sphinx/ext/apidoc.py | 21 +++++++++++++-------- sphinx/templates/apidoc/module.rst | 9 +++++++++ tests/test_ext_apidoc.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 sphinx/templates/apidoc/module.rst diff --git a/sphinx/ext/apidoc.py b/sphinx/ext/apidoc.py index 2d9a771d1..00c5fe8cc 100644 --- a/sphinx/ext/apidoc.py +++ b/sphinx/ext/apidoc.py @@ -28,6 +28,7 @@ from sphinx.cmd.quickstart import EXTENSIONS from sphinx.locale import __ from sphinx.util import rst from sphinx.util.osutil import FileAvoidWrite, ensuredir +from sphinx.util.template import ReSTRenderer if False: # For type annotation @@ -47,6 +48,8 @@ else: INITPY = '__init__.py' PY_SUFFIXES = {'.py', '.pyx'} +template_dir = path.join(package_dir, 'templates', 'apidoc') + def makename(package, module): # type: (str, str) -> str @@ -94,16 +97,18 @@ def format_directive(module, package=None): return directive -def create_module_file(package, module, opts): +def create_module_file(package, basename, opts): # type: (str, str, Any) -> None """Build the text of the file and write the file.""" - if not opts.noheadings: - text = format_heading(1, '%s module' % module) - else: - text = '' - # text += format_heading(2, ':mod:`%s` Module' % module) - text += format_directive(module, package) - write_file(makename(package, module), text, opts) + qualname = makename(package, basename) + context = { + 'show_headings': not opts.noheadings, + 'basename': basename, + 'qualname': qualname, + 'automodule_options': OPTIONS, + } + text = ReSTRenderer(template_dir).render('module.rst', context) + write_file(qualname, text, opts) def create_package_file(root, master_package, subroot, py_files, opts, subs, is_namespace, excludes=[]): # NOQA diff --git a/sphinx/templates/apidoc/module.rst b/sphinx/templates/apidoc/module.rst new file mode 100644 index 000000000..249027855 --- /dev/null +++ b/sphinx/templates/apidoc/module.rst @@ -0,0 +1,9 @@ +{%- if show_headings %} +{{- [basename, "module"] | join(' ') | e | heading }} + +{% endif -%} +.. automodule:: {{ qualname }} +{%- for option in automodule_options %} + :{{ option }}: +{%- endfor %} + diff --git a/tests/test_ext_apidoc.py b/tests/test_ext_apidoc.py index 3f5f367c4..365ac7f59 100644 --- a/tests/test_ext_apidoc.py +++ b/tests/test_ext_apidoc.py @@ -13,6 +13,7 @@ from collections import namedtuple import pytest from sphinx.ext.apidoc import main as apidoc_main +from sphinx.testing.path import path @pytest.fixture() @@ -398,3 +399,32 @@ def test_subpackage_in_toc(make_app, apidoc): assert 'parent.child.foo' in parent_child assert (outdir / 'parent.child.foo.rst').isfile() + + +def test_module_file(tempdir): + outdir = path(tempdir) + (outdir / 'example.py').write_text('') + apidoc_main(['-o', tempdir, tempdir]) + assert (outdir / 'example.rst').exists() + + content = (outdir / 'example.rst').text() + assert content == ("example module\n" + "==============\n" + "\n" + ".. automodule:: example\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n") + + +def test_module_file_noheadings(tempdir): + outdir = path(tempdir) + (outdir / 'example.py').write_text('') + apidoc_main(['--no-headings', '-o', tempdir, tempdir]) + assert (outdir / 'example.rst').exists() + + content = (outdir / 'example.rst').text() + assert content == (".. automodule:: example\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n") From e41d6f3651416ca6ce31d637b572895264661f00 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Thu, 2 May 2019 22:40:36 +0900 Subject: [PATCH 11/15] apidoc: Use a template for generating toc file --- sphinx/ext/apidoc.py | 18 ++++++++++-------- sphinx/templates/apidoc/toc.rst | 9 +++++++++ tests/test_ext_apidoc.py | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 sphinx/templates/apidoc/toc.rst diff --git a/sphinx/ext/apidoc.py b/sphinx/ext/apidoc.py index 00c5fe8cc..8c8101307 100644 --- a/sphinx/ext/apidoc.py +++ b/sphinx/ext/apidoc.py @@ -174,19 +174,21 @@ def create_package_file(root, master_package, subroot, py_files, opts, subs, is_ def create_modules_toc_file(modules, opts, name='modules'): # type: (List[str], Any, str) -> None """Create the module's index.""" - text = format_heading(1, '%s' % opts.header, escape=False) - text += '.. toctree::\n' - text += ' :maxdepth: %s\n\n' % opts.maxdepth - modules.sort() prev_module = '' - for module in modules: + for module in modules[:]: # look if the module is a subpackage and, if yes, ignore it if module.startswith(prev_module + '.'): - continue - prev_module = module - text += ' %s\n' % module + modules.remove(module) + else: + prev_module = module + context = { + 'header': opts.header, + 'maxdepth': opts.maxdepth, + 'docnames': modules, + } + text = ReSTRenderer(template_dir).render('toc.rst', context) write_file(name, text, opts) diff --git a/sphinx/templates/apidoc/toc.rst b/sphinx/templates/apidoc/toc.rst new file mode 100644 index 000000000..291a19402 --- /dev/null +++ b/sphinx/templates/apidoc/toc.rst @@ -0,0 +1,9 @@ +{{ header | heading }} + +.. toctree:: + :maxdepth: {{ maxdepth }} + +{% for docname in docnames %} + {{ docname }} +{%- endfor %} + diff --git a/tests/test_ext_apidoc.py b/tests/test_ext_apidoc.py index 365ac7f59..6b04ac3e7 100644 --- a/tests/test_ext_apidoc.py +++ b/tests/test_ext_apidoc.py @@ -401,6 +401,24 @@ def test_subpackage_in_toc(make_app, apidoc): assert (outdir / 'parent.child.foo.rst').isfile() +def test_toc_file(tempdir): + outdir = path(tempdir) + (outdir / 'module').makedirs() + (outdir / 'example.py').write_text('') + (outdir / 'module' / 'example.py').write_text('') + apidoc_main(['-o', tempdir, tempdir]) + assert (outdir / 'modules.rst').exists() + + content = (outdir / 'modules.rst').text() + assert content == ("test_toc_file0\n" + "==============\n" + "\n" + ".. toctree::\n" + " :maxdepth: 4\n" + "\n" + " example\n") + + def test_module_file(tempdir): outdir = path(tempdir) (outdir / 'example.py').write_text('') From b93f53c7e4c84922a77f63cc0e1dca0a60a72201 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 4 May 2019 01:16:07 +0900 Subject: [PATCH 12/15] apidoc: Use a template for generating package file --- sphinx/ext/apidoc.py | 79 +++++-------- sphinx/templates/apidoc/package.rst | 52 +++++++++ sphinx/templates/apidoc/toc.rst | 1 - tests/test_ext_apidoc.py | 166 ++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 54 deletions(-) create mode 100644 sphinx/templates/apidoc/package.rst diff --git a/sphinx/ext/apidoc.py b/sphinx/ext/apidoc.py index 8c8101307..af77133b2 100644 --- a/sphinx/ext/apidoc.py +++ b/sphinx/ext/apidoc.py @@ -114,62 +114,35 @@ def create_module_file(package, basename, opts): def create_package_file(root, master_package, subroot, py_files, opts, subs, is_namespace, excludes=[]): # NOQA # type: (str, str, str, List[str], Any, List[str], bool, List[str]) -> None """Build the text of the file and write the file.""" - text = format_heading(1, ('%s package' if not is_namespace else "%s namespace") - % makename(master_package, subroot)) - - if opts.modulefirst and not is_namespace: - text += format_directive(subroot, master_package) - text += '\n' - - # build a list of directories that are szvpackages (contain an INITPY file) - # and also checks the INITPY file is not empty, or there are other python - # source files in that folder. - # (depending on settings - but shall_skip() takes care of that) - subs = [sub for sub in subs if not - shall_skip(path.join(root, sub, INITPY), opts, excludes)] - # if there are some package directories, add a TOC for theses subpackages - if subs: - text += format_heading(2, 'Subpackages') - text += '.. toctree::\n\n' - for sub in subs: - text += ' %s.%s\n' % (makename(master_package, subroot), sub) - text += '\n' - - submods = [path.splitext(sub)[0] for sub in py_files - if not shall_skip(path.join(root, sub), opts, excludes) and - sub != INITPY] - if submods: - text += format_heading(2, 'Submodules') - if opts.separatemodules: - text += '.. toctree::\n\n' - for submod in submods: - modfile = makename(master_package, makename(subroot, submod)) - text += ' %s\n' % modfile - - # generate separate file for this module - if not opts.noheadings: - filetext = format_heading(1, '%s module' % modfile) - else: - filetext = '' - filetext += format_directive(makename(subroot, submod), - master_package) - write_file(modfile, filetext, opts) - else: - for submod in submods: - modfile = makename(master_package, makename(subroot, submod)) - if not opts.noheadings: - text += format_heading(2, '%s module' % modfile) - text += format_directive(makename(subroot, submod), - master_package) - text += '\n' - text += '\n' - - if not opts.modulefirst and not is_namespace: - text += format_heading(2, 'Module contents') - text += format_directive(subroot, master_package) + # build a list of sub packages (directories containing an INITPY file) + subpackages = [sub for sub in subs if not + shall_skip(path.join(root, sub, INITPY), opts, excludes)] + subpackages = [makename(makename(master_package, subroot), pkgname) + for pkgname in subpackages] + # build a list of sub modules + submodules = [path.splitext(sub)[0] for sub in py_files + if not shall_skip(path.join(root, sub), opts, excludes) and + sub != INITPY] + submodules = [makename(master_package, makename(subroot, modname)) + for modname in submodules] + context = { + 'pkgname': makename(master_package, subroot), + 'subpackages': subpackages, + 'submodules': submodules, + 'is_namespace': is_namespace, + 'modulefirst': opts.modulefirst, + 'separatemodules': opts.separatemodules, + 'automodule_options': OPTIONS, + 'show_headings': not opts.noheadings, + } + text = ReSTRenderer(template_dir).render('package.rst', context) write_file(makename(master_package, subroot), text, opts) + if submodules and opts.separatemodules: + for submodule in submodules: + create_module_file(None, submodule, opts) + def create_modules_toc_file(modules, opts, name='modules'): # type: (List[str], Any, str) -> None diff --git a/sphinx/templates/apidoc/package.rst b/sphinx/templates/apidoc/package.rst new file mode 100644 index 000000000..0026af34c --- /dev/null +++ b/sphinx/templates/apidoc/package.rst @@ -0,0 +1,52 @@ +{%- macro automodule(modname, options) -%} +.. automodule:: {{ modname }} +{%- for option in options %} + :{{ option }}: +{%- endfor %} +{%- endmacro %} + +{%- macro toctree(docnames) -%} +.. toctree:: +{% for docname in docnames %} + {{ docname }} +{%- endfor %} +{%- endmacro %} + +{%- if is_namespace %} +{{- [pkgname, "namespace"] | join(" ") | e | heading }} +{% else %} +{{- [pkgname, "package"] | join(" ") | e | heading }} +{% endif %} + +{%- if modulefirst and not is_namespace %} +{{ automodule(pkgname, automodule_options) }} +{% endif %} + +{%- if subpackages %} +Subpackages +----------- + +{{ toctree(subpackages) }} +{% endif %} + +{%- if submodules %} +Submodules +---------- +{% if separatemodules %} +{{ toctree(submodules) }} +{%- else %} +{%- for submodule in submodules %} +{% if show_headings %} +{{- [submodule, "module"] | join(" ") | e | heading(2) }} +{% endif %} +{{ automodule(submodule, automodule_options) }} +{%- endfor %} +{% endif %} +{% endif %} + +{%- if not modulefirst and not is_namespace %} +Module contents +--------------- + +{{ automodule(pkgname, automodule_options) }} +{% endif %} diff --git a/sphinx/templates/apidoc/toc.rst b/sphinx/templates/apidoc/toc.rst index 291a19402..f0877eeb2 100644 --- a/sphinx/templates/apidoc/toc.rst +++ b/sphinx/templates/apidoc/toc.rst @@ -2,7 +2,6 @@ .. toctree:: :maxdepth: {{ maxdepth }} - {% for docname in docnames %} {{ docname }} {%- endfor %} diff --git a/tests/test_ext_apidoc.py b/tests/test_ext_apidoc.py index 6b04ac3e7..9fefb4ce9 100644 --- a/tests/test_ext_apidoc.py +++ b/tests/test_ext_apidoc.py @@ -446,3 +446,169 @@ def test_module_file_noheadings(tempdir): " :members:\n" " :undoc-members:\n" " :show-inheritance:\n") + + +def test_package_file(tempdir): + outdir = path(tempdir) + (outdir / 'testpkg').makedirs() + (outdir / 'testpkg' / '__init__.py').write_text('') + (outdir / 'testpkg' / 'example.py').write_text('') + (outdir / 'testpkg' / 'subpkg').makedirs() + (outdir / 'testpkg' / 'subpkg' / '__init__.py').write_text('') + apidoc_main(['-o', tempdir, tempdir / 'testpkg']) + assert (outdir / 'testpkg.rst').exists() + assert (outdir / 'testpkg.subpkg.rst').exists() + + content = (outdir / 'testpkg.rst').text() + assert content == ("testpkg package\n" + "===============\n" + "\n" + "Subpackages\n" + "-----------\n" + "\n" + ".. toctree::\n" + "\n" + " testpkg.subpkg\n" + "\n" + "Submodules\n" + "----------\n" + "\n" + "testpkg.example module\n" + "----------------------\n" + "\n" + ".. automodule:: testpkg.example\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n" + "\n" + "\n" + "Module contents\n" + "---------------\n" + "\n" + ".. automodule:: testpkg\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n") + + content = (outdir / 'testpkg.subpkg.rst').text() + assert content == ("testpkg.subpkg package\n" + "======================\n" + "\n" + "Module contents\n" + "---------------\n" + "\n" + ".. automodule:: testpkg.subpkg\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n") + + +def test_package_file_separate(tempdir): + outdir = path(tempdir) + (outdir / 'testpkg').makedirs() + (outdir / 'testpkg' / '__init__.py').write_text('') + (outdir / 'testpkg' / 'example.py').write_text('') + apidoc_main(['--separate', '-o', tempdir, tempdir / 'testpkg']) + assert (outdir / 'testpkg.rst').exists() + assert (outdir / 'testpkg.example.rst').exists() + + content = (outdir / 'testpkg.rst').text() + assert content == ("testpkg package\n" + "===============\n" + "\n" + "Submodules\n" + "----------\n" + "\n" + ".. toctree::\n" + "\n" + " testpkg.example\n" + "\n" + "Module contents\n" + "---------------\n" + "\n" + ".. automodule:: testpkg\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n") + + content = (outdir / 'testpkg.example.rst').text() + assert content == ("testpkg.example module\n" + "======================\n" + "\n" + ".. automodule:: testpkg.example\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n") + + +def test_package_file_module_first(tempdir): + outdir = path(tempdir) + (outdir / 'testpkg').makedirs() + (outdir / 'testpkg' / '__init__.py').write_text('') + (outdir / 'testpkg' / 'example.py').write_text('') + apidoc_main(['--module-first', '-o', tempdir, tempdir]) + + content = (outdir / 'testpkg.rst').text() + assert content == ("testpkg package\n" + "===============\n" + "\n" + ".. automodule:: testpkg\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n" + "\n" + "Submodules\n" + "----------\n" + "\n" + "testpkg.example module\n" + "----------------------\n" + "\n" + ".. automodule:: testpkg.example\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n" + "\n") + + +def test_package_file_without_submodules(tempdir): + outdir = path(tempdir) + (outdir / 'testpkg').makedirs() + (outdir / 'testpkg' / '__init__.py').write_text('') + apidoc_main(['-o', tempdir, tempdir / 'testpkg']) + assert (outdir / 'testpkg.rst').exists() + + content = (outdir / 'testpkg.rst').text() + assert content == ("testpkg package\n" + "===============\n" + "\n" + "Module contents\n" + "---------------\n" + "\n" + ".. automodule:: testpkg\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n") + + +def test_namespace_package_file(tempdir): + outdir = path(tempdir) + (outdir / 'testpkg').makedirs() + (outdir / 'testpkg' / 'example.py').write_text('') + apidoc_main(['--implicit-namespace', '-o', tempdir, tempdir / 'testpkg']) + assert (outdir / 'testpkg.rst').exists() + + content = (outdir / 'testpkg.rst').text() + assert content == ("testpkg namespace\n" + "=================\n" + "\n" + "Submodules\n" + "----------\n" + "\n" + "testpkg.example module\n" + "----------------------\n" + "\n" + ".. automodule:: testpkg.example\n" + " :members:\n" + " :undoc-members:\n" + " :show-inheritance:\n" + "\n") From fa560ebf958e0319353db1da04e36ccef45c76c4 Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Sat, 4 May 2019 17:26:26 +0900 Subject: [PATCH 13/15] apidoc: Deprecate unused functions --- CHANGES | 2 ++ doc/extdev/deprecated.rst | 9 +++++++++ sphinx/ext/apidoc.py | 6 ++++++ 3 files changed, 17 insertions(+) diff --git a/CHANGES b/CHANGES index 8a7c6647b..e9ba00d96 100644 --- a/CHANGES +++ b/CHANGES @@ -41,6 +41,8 @@ Deprecated * ``sphinx.domains.std.StandardDomain.note_citation_refs()`` * ``sphinx.domains.std.StandardDomain.note_labels()`` * ``sphinx.environment.NoUri`` +* ``sphinx.ext.apidoc.format_directive()`` +* ``sphinx.ext.apidoc.format_heading()`` * ``sphinx.ext.autodoc.importer.MockFinder`` * ``sphinx.ext.autodoc.importer.MockLoader`` * ``sphinx.ext.autodoc.importer.mock()`` diff --git a/doc/extdev/deprecated.rst b/doc/extdev/deprecated.rst index 6e9ce34f0..2ecb1e2e7 100644 --- a/doc/extdev/deprecated.rst +++ b/doc/extdev/deprecated.rst @@ -161,6 +161,15 @@ The following is a list of deprecated interfaces. - 2.1 - 4.0 - ``sphinx.errors.NoUri`` + * - ``sphinx.ext.apidoc.format_directive()`` + - 2.1 + - 4.0 + - N/A + + * - ``sphinx.ext.apidoc.format_heading()`` + - 2.1 + - 4.0 + - N/A * - ``sphinx.ext.autodoc.importer.MockFinder`` - 2.1 diff --git a/sphinx/ext/apidoc.py b/sphinx/ext/apidoc.py index af77133b2..2243e0644 100644 --- a/sphinx/ext/apidoc.py +++ b/sphinx/ext/apidoc.py @@ -19,12 +19,14 @@ import glob import locale import os import sys +import warnings from fnmatch import fnmatch from os import path import sphinx.locale from sphinx import __display_version__, package_dir from sphinx.cmd.quickstart import EXTENSIONS +from sphinx.deprecation import RemovedInSphinx40Warning from sphinx.locale import __ from sphinx.util import rst from sphinx.util.osutil import FileAvoidWrite, ensuredir @@ -82,6 +84,8 @@ def write_file(name, text, opts): def format_heading(level, text, escape=True): # type: (int, str, bool) -> str """Create a heading of [1, 2 or 3 supported].""" + warnings.warn('format_warning() is deprecated.', + RemovedInSphinx40Warning) if escape: text = rst.escape(text) underlining = ['=', '-', '~', ][level - 1] * len(text) @@ -91,6 +95,8 @@ def format_heading(level, text, escape=True): def format_directive(module, package=None): # type: (str, str) -> str """Create the automodule directive and add the options.""" + warnings.warn('format_directive() is deprecated.', + RemovedInSphinx40Warning) directive = '.. automodule:: %s\n' % makename(package, module) for option in OPTIONS: directive += ' :%s:\n' % option From f8c3c74638ebe5ffd21a6055e343f3738607b229 Mon Sep 17 00:00:00 2001 From: jfbu Date: Sat, 4 May 2019 11:45:59 +0200 Subject: [PATCH 14/15] imgmath: Replace utf8x by utf8 in templates for latex snippets --- sphinx/templates/imgmath/preview.tex_t | 2 +- sphinx/templates/imgmath/template.tex_t | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sphinx/templates/imgmath/preview.tex_t b/sphinx/templates/imgmath/preview.tex_t index deb5eddfa..8dd24c108 100644 --- a/sphinx/templates/imgmath/preview.tex_t +++ b/sphinx/templates/imgmath/preview.tex_t @@ -1,5 +1,5 @@ \documentclass[12pt]{article} -\usepackage[utf8x]{inputenc} +\usepackage[utf8]{inputenc} \usepackage{amsmath} \usepackage{amsthm} \usepackage{amssymb} diff --git a/sphinx/templates/imgmath/template.tex_t b/sphinx/templates/imgmath/template.tex_t index 19834bdc8..92fa8b021 100644 --- a/sphinx/templates/imgmath/template.tex_t +++ b/sphinx/templates/imgmath/template.tex_t @@ -1,5 +1,5 @@ \documentclass[12pt]{article} -\usepackage[utf8x]{inputenc} +\usepackage[utf8]{inputenc} \usepackage{amsmath} \usepackage{amsthm} \usepackage{amssymb} From 8c37f1f4826628abbd07ec277d34f8661521016f Mon Sep 17 00:00:00 2001 From: Takeshi KOMIYA Date: Mon, 6 May 2019 20:22:06 +0900 Subject: [PATCH 15/15] Merge test_ext_inheritance.py to test_ext_inheritance_diagram.py --- tests/test_ext_inheritance.py | 129 -------------------------- tests/test_ext_inheritance_diagram.py | 119 +++++++++++++++++++++++- 2 files changed, 118 insertions(+), 130 deletions(-) delete mode 100644 tests/test_ext_inheritance.py diff --git a/tests/test_ext_inheritance.py b/tests/test_ext_inheritance.py deleted file mode 100644 index e8787427d..000000000 --- a/tests/test_ext_inheritance.py +++ /dev/null @@ -1,129 +0,0 @@ -""" - test_inheritance - ~~~~~~~~~~~~~~~~ - - Tests for :mod:`sphinx.ext.inheritance_diagram` module. - - :copyright: Copyright 2015 by the Sphinx team, see AUTHORS. - :license: BSD, see LICENSE for details. -""" - -import os - -import pytest - -from sphinx.ext.inheritance_diagram import InheritanceDiagram - - -@pytest.mark.sphinx(buildername="html", testroot="inheritance") -@pytest.mark.usefixtures('if_graphviz_found') -def test_inheritance_diagram(app, status, warning): - # monkey-patch InheritaceDiagram.run() so we can get access to its - # results. - orig_run = InheritanceDiagram.run - graphs = {} - - def new_run(self): - result = orig_run(self) - node = result[0] - source = os.path.basename(node.document.current_source).replace(".rst", "") - graphs[source] = node['graph'] - return result - - InheritanceDiagram.run = new_run - - try: - app.builder.build_all() - finally: - InheritanceDiagram.run = orig_run - - assert app.statuscode == 0 - - html_warnings = warning.getvalue() - assert html_warnings == "" - - # note: it is better to split these asserts into separate test functions - # but I can't figure out how to build only a specific .rst file - - # basic inheritance diagram showing all classes - for cls in graphs['basic_diagram'].class_info: - # use in b/c traversing order is different sometimes - assert cls in [ - ('dummy.test.A', 'dummy.test.A', [], None), - ('dummy.test.F', 'dummy.test.F', ['dummy.test.C'], None), - ('dummy.test.C', 'dummy.test.C', ['dummy.test.A'], None), - ('dummy.test.E', 'dummy.test.E', ['dummy.test.B'], None), - ('dummy.test.D', 'dummy.test.D', ['dummy.test.B', 'dummy.test.C'], None), - ('dummy.test.B', 'dummy.test.B', ['dummy.test.A'], None) - ] - - # inheritance diagram using :parts: 1 option - for cls in graphs['diagram_w_parts'].class_info: - assert cls in [ - ('A', 'dummy.test.A', [], None), - ('F', 'dummy.test.F', ['C'], None), - ('C', 'dummy.test.C', ['A'], None), - ('E', 'dummy.test.E', ['B'], None), - ('D', 'dummy.test.D', ['B', 'C'], None), - ('B', 'dummy.test.B', ['A'], None) - ] - - # inheritance diagram with 1 top class - # :top-classes: dummy.test.B - # rendering should be - # A - # \ - # B C - # / \ / \ - # E D F - # - for cls in graphs['diagram_w_1_top_class'].class_info: - assert cls in [ - ('dummy.test.A', 'dummy.test.A', [], None), - ('dummy.test.F', 'dummy.test.F', ['dummy.test.C'], None), - ('dummy.test.C', 'dummy.test.C', ['dummy.test.A'], None), - ('dummy.test.E', 'dummy.test.E', ['dummy.test.B'], None), - ('dummy.test.D', 'dummy.test.D', ['dummy.test.B', 'dummy.test.C'], None), - ('dummy.test.B', 'dummy.test.B', [], None) - ] - - # inheritance diagram with 2 top classes - # :top-classes: dummy.test.B, dummy.test.C - # Note: we're specifying separate classes, not the entire module here - # rendering should be - # - # B C - # / \ / \ - # E D F - # - for cls in graphs['diagram_w_2_top_classes'].class_info: - assert cls in [ - ('dummy.test.F', 'dummy.test.F', ['dummy.test.C'], None), - ('dummy.test.C', 'dummy.test.C', [], None), - ('dummy.test.E', 'dummy.test.E', ['dummy.test.B'], None), - ('dummy.test.D', 'dummy.test.D', ['dummy.test.B', 'dummy.test.C'], None), - ('dummy.test.B', 'dummy.test.B', [], None) - ] - - # inheritance diagram with 2 top classes and specifiying the entire module - # rendering should be - # - # A - # B C - # / \ / \ - # E D F - # - # Note: dummy.test.A is included in the graph before its descendants are even processed - # b/c we've specified to load the entire module. The way InheritanceGraph works it is very - # hard to exclude parent classes once after they have been included in the graph. - # If you'd like to not show class A in the graph don't specify the entire module. - # this is a known issue. - for cls in graphs['diagram_module_w_2_top_classes'].class_info: - assert cls in [ - ('dummy.test.F', 'dummy.test.F', ['dummy.test.C'], None), - ('dummy.test.C', 'dummy.test.C', [], None), - ('dummy.test.E', 'dummy.test.E', ['dummy.test.B'], None), - ('dummy.test.D', 'dummy.test.D', ['dummy.test.B', 'dummy.test.C'], None), - ('dummy.test.B', 'dummy.test.B', [], None), - ('dummy.test.A', 'dummy.test.A', [], None), - ] diff --git a/tests/test_ext_inheritance_diagram.py b/tests/test_ext_inheritance_diagram.py index 30ad625aa..efdace893 100644 --- a/tests/test_ext_inheritance_diagram.py +++ b/tests/test_ext_inheritance_diagram.py @@ -9,11 +9,128 @@ """ import re +import os import sys import pytest -from sphinx.ext.inheritance_diagram import InheritanceException, import_classes +from sphinx.ext.inheritance_diagram import ( + InheritanceDiagram, InheritanceException, import_classes +) + + +@pytest.mark.sphinx(buildername="html", testroot="inheritance") +@pytest.mark.usefixtures('if_graphviz_found') +def test_inheritance_diagram(app, status, warning): + # monkey-patch InheritaceDiagram.run() so we can get access to its + # results. + orig_run = InheritanceDiagram.run + graphs = {} + + def new_run(self): + result = orig_run(self) + node = result[0] + source = os.path.basename(node.document.current_source).replace(".rst", "") + graphs[source] = node['graph'] + return result + + InheritanceDiagram.run = new_run + + try: + app.builder.build_all() + finally: + InheritanceDiagram.run = orig_run + + assert app.statuscode == 0 + + html_warnings = warning.getvalue() + assert html_warnings == "" + + # note: it is better to split these asserts into separate test functions + # but I can't figure out how to build only a specific .rst file + + # basic inheritance diagram showing all classes + for cls in graphs['basic_diagram'].class_info: + # use in b/c traversing order is different sometimes + assert cls in [ + ('dummy.test.A', 'dummy.test.A', [], None), + ('dummy.test.F', 'dummy.test.F', ['dummy.test.C'], None), + ('dummy.test.C', 'dummy.test.C', ['dummy.test.A'], None), + ('dummy.test.E', 'dummy.test.E', ['dummy.test.B'], None), + ('dummy.test.D', 'dummy.test.D', ['dummy.test.B', 'dummy.test.C'], None), + ('dummy.test.B', 'dummy.test.B', ['dummy.test.A'], None) + ] + + # inheritance diagram using :parts: 1 option + for cls in graphs['diagram_w_parts'].class_info: + assert cls in [ + ('A', 'dummy.test.A', [], None), + ('F', 'dummy.test.F', ['C'], None), + ('C', 'dummy.test.C', ['A'], None), + ('E', 'dummy.test.E', ['B'], None), + ('D', 'dummy.test.D', ['B', 'C'], None), + ('B', 'dummy.test.B', ['A'], None) + ] + + # inheritance diagram with 1 top class + # :top-classes: dummy.test.B + # rendering should be + # A + # \ + # B C + # / \ / \ + # E D F + # + for cls in graphs['diagram_w_1_top_class'].class_info: + assert cls in [ + ('dummy.test.A', 'dummy.test.A', [], None), + ('dummy.test.F', 'dummy.test.F', ['dummy.test.C'], None), + ('dummy.test.C', 'dummy.test.C', ['dummy.test.A'], None), + ('dummy.test.E', 'dummy.test.E', ['dummy.test.B'], None), + ('dummy.test.D', 'dummy.test.D', ['dummy.test.B', 'dummy.test.C'], None), + ('dummy.test.B', 'dummy.test.B', [], None) + ] + + # inheritance diagram with 2 top classes + # :top-classes: dummy.test.B, dummy.test.C + # Note: we're specifying separate classes, not the entire module here + # rendering should be + # + # B C + # / \ / \ + # E D F + # + for cls in graphs['diagram_w_2_top_classes'].class_info: + assert cls in [ + ('dummy.test.F', 'dummy.test.F', ['dummy.test.C'], None), + ('dummy.test.C', 'dummy.test.C', [], None), + ('dummy.test.E', 'dummy.test.E', ['dummy.test.B'], None), + ('dummy.test.D', 'dummy.test.D', ['dummy.test.B', 'dummy.test.C'], None), + ('dummy.test.B', 'dummy.test.B', [], None) + ] + + # inheritance diagram with 2 top classes and specifiying the entire module + # rendering should be + # + # A + # B C + # / \ / \ + # E D F + # + # Note: dummy.test.A is included in the graph before its descendants are even processed + # b/c we've specified to load the entire module. The way InheritanceGraph works it is very + # hard to exclude parent classes once after they have been included in the graph. + # If you'd like to not show class A in the graph don't specify the entire module. + # this is a known issue. + for cls in graphs['diagram_module_w_2_top_classes'].class_info: + assert cls in [ + ('dummy.test.F', 'dummy.test.F', ['dummy.test.C'], None), + ('dummy.test.C', 'dummy.test.C', [], None), + ('dummy.test.E', 'dummy.test.E', ['dummy.test.B'], None), + ('dummy.test.D', 'dummy.test.D', ['dummy.test.B', 'dummy.test.C'], None), + ('dummy.test.B', 'dummy.test.B', [], None), + ('dummy.test.A', 'dummy.test.A', [], None), + ] @pytest.mark.sphinx('html', testroot='ext-inheritance_diagram')