diff options
65 files changed, 992 insertions, 253 deletions
@@ -7,6 +7,7 @@ Dependencies * LaTeX: drop dependency on :program:`extractbb` for image inclusion in Japanese documents as ``.xbb`` files are unneeded by :program:`dvipdfmx` since TeXLive2015 (refs: #6189) +* babel-2.0 or above is available (Unpinned) Incompatible changes -------------------- @@ -27,9 +28,12 @@ Deprecated ---------- * ``desc_signature['first']`` +* ``sphinx.directives.DescDirective`` * ``sphinx.domains.std.StandardDomain.add_object()`` +* ``sphinx.parsers.Parser.app`` * ``sphinx.testing.path.Path.text()`` * ``sphinx.testing.path.Path.bytes()`` +* ``sphinx.util.inspect.getargspec()`` Features added -------------- @@ -43,11 +47,15 @@ Features added * #6558: glossary: emit a warning for duplicated glossary entry * #6558: std domain: emit a warning for duplicated generic objects * #6830: py domain: Add new event: :event:`object-description-transform` +* py domain: Support lambda functions in function signature * Support priority of event handlers. For more detail, see :py:meth:`.Sphinx.connect()` * #3077: Implement the scoping for :rst:dir:`productionlist` as indicated in the documentation. * #1027: Support backslash line continuation in :rst:dir:`productionlist`. +* #7108: config: Allow to show an error message from conf.py via ``ConfigError`` +* #7032: html: :confval:`html_scaled_image_link` will be disabled for images having + ``no-scaled-link`` class Bugs fixed ---------- @@ -58,11 +66,14 @@ Bugs fixed declarations. * C++, suppress warnings for directly dependent typenames in cross references generated automatically in signatures. +* #5637: autodoc: Incorrect handling of nested class names on show-inheritance +* #5637: inheritance_diagram: Incorrect handling of nested class names +* #7139: ``code-block:: guess`` does not work Testing -------- -Release 2.4.0 (in development) +Release 2.4.3 (in development) ============================== Dependencies @@ -74,11 +85,53 @@ Incompatible changes Deprecated ---------- +Features added +-------------- + +Bugs fixed +---------- + +Testing +-------- + +Release 2.4.2 (released Feb 19, 2020) +===================================== + +Bugs fixed +---------- + +* #7138: autodoc: ``autodoc.typehints`` crashed when variable has unbound object + as a value +* #7156: autodoc: separator for keyword only arguments is not shown +* #7146: autodoc: IndexError is raised on suppressed type_comment found +* #7161: autodoc: typehints extension does not support parallel build +* #7178: autodoc: TypeError is raised on fetching type annotations +* #7151: crashed when extension assigns a value to ``env.indexentries`` +* #7170: text: Remove debug print +* #7137: viewcode: Avoid to crash when non-python code given + +Release 2.4.1 (released Feb 11, 2020) +===================================== + +Bugs fixed +---------- + +* #7120: html: crashed when on scaling SVG images which have float dimentions +* #7126: autodoc: TypeError: 'getset_descriptor' object is not iterable + +Release 2.4.0 (released Feb 09, 2020) +===================================== + +Deprecated +---------- + * The ``decode`` argument of ``sphinx.pycode.ModuleAnalyzer()`` * ``sphinx.directives.other.Index`` * ``sphinx.environment.temp_data['gloss_entries']`` * ``sphinx.environment.BuildEnvironment.indexentries`` * ``sphinx.environment.collectors.indexentries.IndexEntriesCollector`` +* ``sphinx.ext.apidoc.INITPY`` +* ``sphinx.ext.apidoc.shall_skip()`` * ``sphinx.io.FiletypeNotFoundError`` * ``sphinx.io.get_filetype()`` * ``sphinx.pycode.ModuleAnalyzer.encoding`` @@ -106,6 +159,8 @@ Features added * #6446: duration: Add ``sphinx.ext.durations`` to inspect which documents slow down the build * #6837: LaTeX: Support a nested table +* #7115: LaTeX: Allow to override LATEXOPTS and LATEXMKOPTS via environment + variable * #6966: graphviz: Support ``:class:`` option * #6696: html: ``:scale:`` option of image/figure directive not working for SVG images (imagesize-1.2.0 or above is required) @@ -133,6 +188,7 @@ Bugs fixed ---------- * #6925: html: Remove redundant type="text/javascript" from <script> elements +* #7112: html: SVG image is not layouted as float even if aligned * #6906, #6907: autodoc: failed to read the source codes encoeded in cp1251 * #6961: latex: warning for babel shown twice * #7059: latex: LaTeX compilation falls into infinite loop (wrapfig issue) @@ -140,6 +196,7 @@ Bugs fixed * #6559: Wrong node-ids are generated in glossary directive * #6986: apidoc: misdetects module name for .so file inside module * #6899: apidoc: private members are not shown even if ``--private`` given +* #6327: apidoc: Support a python package consisted of __init__.so file * #6999: napoleon: fails to parse tilde in :exc: role * #7019: gettext: Absolute path used in message catalogs * #7023: autodoc: nested partial functions are not listed @@ -153,34 +210,19 @@ Bugs fixed modifier keys are ignored, which means the feature can interfere with browser features * #7090: std domain: Can't assign numfig-numbers for custom container nodes +* #7106: std domain: enumerated nodes are marked as duplicated when extensions + call ``note_explicit_target()`` +* #7095: dirhtml: Cross references are broken via intersphinx and ``:doc:`` role +* C++: -Testing --------- - -Release 2.3.2 (in development) -============================== - -Dependencies ------------- - -Incompatible changes --------------------- - -Deprecated ----------- - -Features added --------------- - -Bugs fixed ----------- - -* C++, don't crash when using the ``struct`` role in some cases. -* C++, don't warn when using the ``var``/``member`` role for function - parameters. - -Testing --------- + - Don't crash when using the ``struct`` role in some cases. + - Don't warn when using the ``var``/``member`` role for function + parameters. + - Render call and braced-init expressions correctly. +* #7097: Filenames of images generated by + ``sphinx.transforms.post_transforms.images.ImageConverter`` + or its subclasses (used for latex build) are now sanitized, + to prevent broken paths Release 2.3.1 (released Dec 22, 2019) ===================================== diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 8c74b184a..6fd56fb9d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -61,7 +61,7 @@ of the core developers before it is merged into the main repository. #. If you feel uncomfortable or uncertain about an issue or your changes, feel free to email the *sphinx-dev* mailing list. #. Fork `the repository`_ on GitHub to start making your changes to the - ``master`` branch for next MAJOR version, or ``X.Y`` branch for next + ``master`` branch for next MAJOR version, or ``A.x`` branch for next MINOR version (see `Branch Model`_). #. Write a test which shows that the bug was fixed or that the feature works as expected. @@ -94,10 +94,10 @@ These are the basic steps needed to start developing on Sphinx. Sphinx adopts Semantic Versioning 2.0.0 (refs: https://semver.org/ ). For changes that preserves backwards-compatibility of API and features, - they should be included in the next MINOR release, use the ``X.Y`` branch. + they should be included in the next MINOR release, use the ``A.x`` branch. :: - git checkout X.Y + git checkout A.x For incompatible or other substantial changes that should wait until the next MAJOR release, use the ``master`` branch. @@ -199,7 +199,7 @@ These are the basic steps needed to start developing on Sphinx. git push origin feature-xyz #. Submit a pull request from your branch to the respective branch (``master`` - or ``X.Y``). + or ``A.x``). #. Wait for a core developer to review your changes. @@ -325,8 +325,8 @@ Versioning 2.0.0 (refs: https://semver.org/ ). All changes including incompatible behaviors and public API updates are allowed. -``X.Y`` - Where ``X.Y`` is the ``MAJOR.MINOR`` release. Used to maintain current +``A.x`` (ex. ``2.x``) + Where ``A.x`` is the ``MAJOR.MINOR`` release. Used to maintain current MINOR release. All changes are allowed if the change preserves backwards-compatibility of API and features. @@ -334,8 +334,8 @@ Versioning 2.0.0 (refs: https://semver.org/ ). new MAJOR version is released, the old ``MAJOR.MINOR`` branch will be deleted and replaced by an equivalent tag. -``X.Y.Z`` - Where ``X.Y.Z`` is the ``MAJOR.MINOR.PATCH`` release. Only +``A.B.x`` (ex. ``2.4.x``) + Where ``A.B.x`` is the ``MAJOR.MINOR.PATCH`` release. Only backwards-compatible bug fixes are allowed. In Sphinx project, PATCH version is used for urgent bug fix. diff --git a/doc/conf.py b/doc/conf.py index aa513edf8..9951aed2d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -14,7 +14,7 @@ templates_path = ['_templates'] exclude_patterns = ['_build'] project = 'Sphinx' -copyright = '2007-2019, Georg Brandl and the Sphinx team' +copyright = '2007-2020, Georg Brandl and the Sphinx team' version = sphinx.__display_version__ release = version show_authors = True diff --git a/doc/development/tutorials/recipe.rst b/doc/development/tutorials/recipe.rst index 2a3aa6408..474888cb0 100644 --- a/doc/development/tutorials/recipe.rst +++ b/doc/development/tutorials/recipe.rst @@ -75,7 +75,7 @@ The first thing to examine is the ``RecipeDirective`` directive: .. literalinclude:: examples/recipe.py :language: python :linenos: - :lines: 17-37 + :pyobject: RecipeDirective Unlike :doc:`helloworld` and :doc:`todo`, this directive doesn't derive from :class:`docutils.parsers.rst.Directive` and doesn't define a ``run`` method. @@ -103,7 +103,12 @@ reStructuredText in the body. .. literalinclude:: examples/recipe.py :language: python :linenos: - :lines: 40-102 + :pyobject: IngredientIndex + +.. literalinclude:: examples/recipe.py + :language: python + :linenos: + :pyobject: RecipeIndex Both ``IngredientIndex`` and ``RecipeIndex`` are derived from :class:`Index`. They implement custom logic to generate a tuple of values that define the @@ -126,7 +131,7 @@ creating here. .. literalinclude:: examples/recipe.py :language: python :linenos: - :lines: 105-155 + :pyobject: RecipeDomain There are some interesting things to note about this ``recipe`` domain and domains in general. Firstly, we actually register our directives, roles and indices @@ -164,7 +169,7 @@ hook the various parts of our extension into Sphinx. Let's look at the .. literalinclude:: examples/recipe.py :language: python :linenos: - :lines: 158- + :pyobject: setup This looks a little different to what we're used to seeing. There are no calls to :meth:`~Sphinx.add_directive` or even :meth:`~Sphinx.add_role`. Instead, we diff --git a/doc/extdev/deprecated.rst b/doc/extdev/deprecated.rst index be33c26a5..484c76aba 100644 --- a/doc/extdev/deprecated.rst +++ b/doc/extdev/deprecated.rst @@ -31,11 +31,21 @@ The following is a list of deprecated interfaces. - 3.0 - N/A + * - ``sphinx.directives.DescDirective`` + - 3.0 + - 5.0 + - ``sphinx.directives.ObjectDescription`` + * - ``sphinx.domains.std.StandardDomain.add_object()`` - 3.0 - 5.0 - ``sphinx.domains.std.StandardDomain.note_object()`` + * - ``sphinx.parsers.Parser.app`` + - 3.0 + - 5.0 + - N/A + * - ``sphinx.testing.path.Path.text()`` - 3.0 - 5.0 @@ -46,6 +56,11 @@ The following is a list of deprecated interfaces. - 5.0 - ``sphinx.testing.path.Path.read_bytes()`` + * - ``sphinx.util.inspect.getargspec()`` + - 3.0 + - 5.0 + - ``inspect.getargspec()`` + * - ``decode`` argument of ``sphinx.pycode.ModuleAnalyzer()`` - 2.4 - 4.0 @@ -76,6 +91,16 @@ The following is a list of deprecated interfaces. - 4.0 - ``sphinx.errors.FiletypeNotFoundError`` + * - ``sphinx.ext.apidoc.INITPY`` + - 2.4 + - 4.0 + - N/A + + * - ``sphinx.ext.apidoc.shall_skip()`` + - 2.4 + - 4.0 + - ``sphinx.ext.apidoc.is_skipped_package`` + * - ``sphinx.io.get_filetype()`` - 2.4 - 4.0 diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 7fc09c037..cfc5db065 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -1357,8 +1357,21 @@ that use Sphinx's HTMLWriter class. 'target' option or scale related options: 'scale', 'width', 'height'. The default is ``True``. + Document authors can this feature manually with giving ``no-scaled-link`` + class to the image: + + .. code-block:: rst + + .. image:: sphinx.png + :scale: 50% + :class: no-scaled-link + .. versionadded:: 1.3 + .. versionchanged:: 2.4 + + It is disabled for images having ``no-scaled-link`` class + .. confval:: html_math_renderer The name of math_renderer extension for HTML output. The default is diff --git a/doc/usage/extensions/doctest.rst b/doc/usage/extensions/doctest.rst index 79a75536e..62d8577eb 100644 --- a/doc/usage/extensions/doctest.rst +++ b/doc/usage/extensions/doctest.rst @@ -11,11 +11,15 @@ pair: testing; snippets -This extension allows you to test snippets in the documentation in a natural -way. It works by collecting specially-marked up code blocks and running them as -doctest tests. +It is often helpful to include snippets of code in your documentation and +demonstrate the results of executing them. But it is important to ensure that +the documentation stays up-to-date with the code. -Within one document, test code is partitioned in *groups*, where each group +This extension allows you to test such code snippets in the documentation in +a natural way. If you mark the code blocks as shown here, the ``doctest`` +builder will collect them and run them as doctest tests. + +Within each document, you can assign each snippet to a *group*. Each group consists of: * zero or more *setup code* blocks (e.g. importing the module to test) diff --git a/doc/usage/quickstart.rst b/doc/usage/quickstart.rst index 8566552e0..b5462a388 100644 --- a/doc/usage/quickstart.rst +++ b/doc/usage/quickstart.rst @@ -26,9 +26,6 @@ configuration values from a few questions it asks you. To use this, run: $ sphinx-quickstart -Answer each question asked. Be sure to say "yes" to the ``autodoc`` extension, as -we will use this later. - There is also an automatic "API documentation" generator called :program:`sphinx-apidoc`; see :doc:`/man/sphinx-apidoc` for details. @@ -37,12 +34,11 @@ Defining document structure --------------------------- Let's assume you've run :program:`sphinx-quickstart`. It created a source -directory with :file:`conf.py` and a master document, :file:`index.rst` (if you -accepted the defaults). The main function of the :term:`master document` is to -serve as a welcome page, and to contain the root of the "table of contents -tree" (or *toctree*). This is one of the main things that Sphinx adds to -reStructuredText, a way to connect multiple files to a single hierarchy of -documents. +directory with :file:`conf.py` and a master document, :file:`index.rst`. The +main function of the :term:`master document` is to serve as a welcome page, and +to contain the root of the "table of contents tree" (or *toctree*). This is one +of the main things that Sphinx adds to reStructuredText, a way to connect +multiple files to a single hierarchy of documents. .. sidebar:: reStructuredText directives @@ -233,8 +229,7 @@ customize a config value that is not automatically added by Keep in mind that the file uses Python syntax for strings, numbers, lists and so on. The file is saved in UTF-8 by default, as indicated by the encoding -declaration in the first line. If you use non-ASCII characters in any string -value, you need to use Python Unicode strings (like ``project = u'Exposé'``). +declaration in the first line. |more| See :doc:`/usage/configuration` for documentation of all available config values. @@ -252,10 +247,12 @@ module that provides additional features for Sphinx projects) called *autodoc*. In order to use *autodoc*, you need to activate it in :file:`conf.py` by putting the string ``'sphinx.ext.autodoc'`` into the list assigned to the -:confval:`extensions` config value. Then, you have a few additional directives -at your disposal. +:confval:`extensions` config value:: + + extensions = ['sphinx.ext.autodoc'] -For example, to document the function ``io.open()``, reading its signature and +Then, you have a few additional directives at your disposal. For example, to +document the function ``io.open()``, reading its signature and docstring from the source file, you'd write this:: .. autofunction:: io.open @@ -25,7 +25,7 @@ install_requires = [ 'Pygments>=2.0', 'docutils>=0.12', 'snowballstemmer>=1.1', - 'babel>=1.3,!=2.0', + 'babel>=1.3', 'alabaster>=0.7,<0.8', 'imagesize', 'requests>=2.5.0', diff --git a/sphinx/builders/html.py b/sphinx/builders/html/__init__.py index 80c99d3b8..a0f6a9e55 100644 --- a/sphinx/builders/html.py +++ b/sphinx/builders/html/__init__.py @@ -807,13 +807,17 @@ class StandaloneHTMLBuilder(Builder): if self.config.html_scaled_image_link and self.html_scaled_image_link: for node in doctree.traverse(nodes.image): - scale_keys = ('scale', 'width', 'height') - if not any((key in node) for key in scale_keys) or \ - isinstance(node.parent, nodes.reference): - # docutils does unfortunately not preserve the - # ``target`` attribute on images, so we need to check - # the parent node here. + if not any((key in node) for key in ['scale', 'width', 'height']): + # resizing options are not given. scaled image link is available + # only for resized images. continue + elif isinstance(node.parent, nodes.reference): + # A image having hyperlink target + continue + elif 'no-scaled-link' in node['classes']: + # scaled image link is disabled for this node + continue + uri = node['uri'] reference = nodes.reference('', '', internal=True) if uri in self.images: diff --git a/sphinx/config.py b/sphinx/config.py index ca99fd5b7..87007c33d 100644 --- a/sphinx/config.py +++ b/sphinx/config.py @@ -324,6 +324,9 @@ def eval_config_file(filename: str, tags: Tags) -> Dict[str, Any]: msg = __("The configuration file (or one of the modules it imports) " "called sys.exit()") raise ConfigError(msg) + except ConfigError: + # pass through ConfigError from conf.py as is. It will be shown in console. + raise except Exception: msg = __("There is a programmable error in your configuration file:\n\n%s") raise ConfigError(msg % traceback.format_exc()) diff --git a/sphinx/directives/__init__.py b/sphinx/directives/__init__.py index 6b9e5b828..fd447b551 100644 --- a/sphinx/directives/__init__.py +++ b/sphinx/directives/__init__.py @@ -18,7 +18,9 @@ from docutils.parsers.rst import directives, roles from sphinx import addnodes from sphinx.addnodes import desc_signature -from sphinx.deprecation import RemovedInSphinx40Warning, deprecated_alias +from sphinx.deprecation import ( + RemovedInSphinx40Warning, RemovedInSphinx50Warning, deprecated_alias +) from sphinx.util import docutils from sphinx.util.docfields import DocFieldTransformer, Field, TypedField from sphinx.util.docutils import SphinxDirective @@ -284,9 +286,11 @@ deprecated_alias('sphinx.directives', }, RemovedInSphinx40Warning) - -# backwards compatible old name (will be marked deprecated in 3.0) -DescDirective = ObjectDescription +deprecated_alias('sphinx.directives', + { + 'DescDirective': ObjectDescription, + }, + RemovedInSphinx50Warning) def setup(app: "Sphinx") -> Dict[str, Any]: diff --git a/sphinx/domains/cpp.py b/sphinx/domains/cpp.py index c6ca546cc..3a23ff15a 100644 --- a/sphinx/domains/cpp.py +++ b/sphinx/domains/cpp.py @@ -3007,7 +3007,7 @@ class ASTParenExprList(ASTBase): signode.append(nodes.Text(', ')) else: first = False - e.describe_signature(signode, mode, env, symbol) + e.describe_signature(signode, mode, env, symbol) signode.append(nodes.Text(')')) @@ -3034,7 +3034,7 @@ class ASTBracedInitList(ASTBase): signode.append(nodes.Text(', ')) else: first = False - e.describe_signature(signode, mode, env, symbol) + e.describe_signature(signode, mode, env, symbol) if self.trailingComma: signode.append(nodes.Text(',')) signode.append(nodes.Text('}')) diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index 6732ec166..9353dcb4c 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -10,6 +10,7 @@ import re import warnings +from inspect import Parameter from typing import Any, Dict, Iterable, Iterator, List, Tuple from typing import cast @@ -30,6 +31,7 @@ from sphinx.roles import XRefRole from sphinx.util import logging from sphinx.util.docfields import Field, GroupedField, TypedField from sphinx.util.docutils import SphinxDirective +from sphinx.util.inspect import signature_from_str from sphinx.util.nodes import make_refnode from sphinx.util.typing import TextlikeNode @@ -62,6 +64,47 @@ pairindextypes = { } +def _parse_arglist(arglist: str) -> addnodes.desc_parameterlist: + """Parse a list of arguments using AST parser""" + params = addnodes.desc_parameterlist(arglist) + sig = signature_from_str('(%s)' % arglist) + last_kind = None + for param in sig.parameters.values(): + if param.kind != param.POSITIONAL_ONLY and last_kind == param.POSITIONAL_ONLY: + # PEP-570: Separator for Positional Only Parameter: / + params += nodes.Text('/') + if param.kind == param.KEYWORD_ONLY and last_kind in (param.POSITIONAL_OR_KEYWORD, + param.POSITIONAL_ONLY, + None): + # PEP-3102: Separator for Keyword Only Parameter: * + params += nodes.Text('*') + + node = addnodes.desc_parameter() + if param.kind == param.VAR_POSITIONAL: + node += nodes.Text('*' + param.name) + elif param.kind == param.VAR_KEYWORD: + node += nodes.Text('**' + param.name) + else: + node += nodes.Text(param.name) + + if param.annotation is not param.empty: + node += nodes.Text(': ' + param.annotation) + if param.default is not param.empty: + if param.annotation is not param.empty: + node += nodes.Text(' = ' + str(param.default)) + else: + node += nodes.Text('=' + str(param.default)) + + params += node + last_kind = param.kind + + if last_kind == Parameter.POSITIONAL_ONLY: + # PEP-570: Separator for Positional Only Parameter: / + params += nodes.Text('/') + + return params + + def _pseudo_parse_arglist(signode: desc_signature, arglist: str) -> None: """"Parse" a list of arguments separated by commas. @@ -284,7 +327,15 @@ class PyObject(ObjectDescription): signode += addnodes.desc_name(name, name) if arglist: - _pseudo_parse_arglist(signode, arglist) + try: + signode += _parse_arglist(arglist) + except SyntaxError: + # fallback to parse arglist original parser. + # it supports to represent optional arguments (ex. "func(foo [, bar])") + _pseudo_parse_arglist(signode, arglist) + except NotImplementedError as exc: + logger.warning(exc) + _pseudo_parse_arglist(signode, arglist) else: if self.needs_arglist(): # for callables, add an empty parameter list diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index 341f22a27..d7c74a2fe 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -644,3 +644,11 @@ class BuildEnvironment: from sphinx.domains.index import IndexDomain domain = cast(IndexDomain, self.get_domain('index')) return domain.entries + + @indexentries.setter + def indexentries(self, entries: Dict[str, List[Tuple[str, str, str, str, str]]]) -> None: + warnings.warn('env.indexentries() is deprecated. Please use IndexDomain instead.', + RemovedInSphinx40Warning, stacklevel=2) + from sphinx.domains.index import IndexDomain + domain = cast(IndexDomain, self.get_domain('index')) + domain.data['entries'] = entries diff --git a/sphinx/ext/apidoc.py b/sphinx/ext/apidoc.py index 99cf67016..a9196d3a6 100644 --- a/sphinx/ext/apidoc.py +++ b/sphinx/ext/apidoc.py @@ -29,7 +29,7 @@ from typing import Any, List, Tuple import sphinx.locale from sphinx import __display_version__, package_dir from sphinx.cmd.quickstart import EXTENSIONS -from sphinx.deprecation import RemovedInSphinx40Warning +from sphinx.deprecation import RemovedInSphinx40Warning, deprecated_alias from sphinx.locale import __ from sphinx.util import rst from sphinx.util.osutil import FileAvoidWrite, ensuredir @@ -46,7 +46,6 @@ else: 'show-inheritance', ] -INITPY = '__init__.py' PY_SUFFIXES = ('.py', '.pyx') + tuple(EXTENSION_SUFFIXES) template_dir = path.join(package_dir, 'templates', 'apidoc') @@ -66,11 +65,31 @@ def makename(package: str, module: str) -> str: return name +def is_initpy(filename: str) -> bool: + """Check *filename* is __init__ file or not.""" + basename = path.basename(filename) + for suffix in sorted(PY_SUFFIXES, key=len, reverse=True): + if basename == '__init__' + suffix: + return True + else: + return False + + def module_join(*modnames: str) -> str: """Join module names with dots.""" return '.'.join(filter(None, modnames)) +def is_packagedir(dirname: str = None, files: List[str] = None) -> bool: + """Check given *files* contains __init__ file.""" + if files is None and dirname is None: + return False + + if files is None: + files = os.listdir(dirname) + return any(f for f in files if is_initpy(f)) + + def write_file(name: str, text: str, opts: Any) -> None: """Write the output file for module/package <name>.""" quiet = getattr(opts, 'quiet', None) @@ -132,15 +151,14 @@ def create_package_file(root: str, master_package: str, subroot: str, py_files: opts: Any, subs: List[str], is_namespace: bool, excludes: List[str] = [], user_template_dir: str = None) -> None: """Build the text of the file and write the file.""" - # 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)] + # build a list of sub packages (directories containing an __init__ file) subpackages = [module_join(master_package, subroot, pkgname) - for pkgname in subpackages] + for pkgname in subs + if not is_skipped_package(path.join(root, pkgname), opts, excludes)] # build a list of sub modules submodules = [sub.split('.')[0] for sub in py_files if not is_skipped_module(path.join(root, sub), opts, excludes) and - sub != INITPY] + not is_initpy(sub)] submodules = [module_join(master_package, subroot, modname) for modname in submodules] options = copy(OPTIONS) @@ -189,12 +207,14 @@ def create_modules_toc_file(modules: List[str], opts: Any, name: str = 'modules' def shall_skip(module: str, opts: Any, excludes: List[str] = []) -> bool: """Check if we want to skip this module.""" + warnings.warn('shall_skip() is deprecated.', + RemovedInSphinx40Warning) # skip if the file doesn't exist and not using implicit namespaces if not opts.implicit_namespaces and not path.exists(module): return True # Are we a package (here defined as __init__.py, not the folder in itself) - if os.path.basename(module) == INITPY: + if is_initpy(module): # Yes, check if we have any non-excluded modules at all here all_skipped = True basemodule = path.dirname(module) @@ -207,12 +227,30 @@ def shall_skip(module: str, opts: Any, excludes: List[str] = []) -> bool: # skip if it has a "private" name and this is selected filename = path.basename(module) - if filename != '__init__.py' and filename.startswith('_') and \ - not opts.includeprivate: + if is_initpy(filename) and filename.startswith('_') and not opts.includeprivate: return True return False +def is_skipped_package(dirname: str, opts: Any, excludes: List[str] = []) -> bool: + """Check if we want to skip this module.""" + if not path.isdir(dirname): + return False + + files = glob.glob(path.join(dirname, '*.py')) + regular_package = any(f for f in files if is_initpy(f)) + if not regular_package and not opts.implicit_namespaces: + # *dirname* is not both a regular package and an implicit namespace pacage + return True + + # Check there is some showable module inside package + if all(is_excluded(path.join(dirname, f), excludes) for f in files): + # all submodules are excluded + return True + else: + return False + + def is_skipped_module(filename: str, opts: Any, excludes: List[str]) -> bool: """Check if we want to skip this module.""" if not path.exists(filename): @@ -236,7 +274,7 @@ def recurse_tree(rootpath: str, excludes: List[str], opts: Any, implicit_namespaces = getattr(opts, 'implicit_namespaces', False) # check if the base directory is a package and get its name - if INITPY in os.listdir(rootpath) or implicit_namespaces: + if is_packagedir(rootpath) or implicit_namespaces: root_package = rootpath.split(path.sep)[-1] else: # otherwise, the base is a directory with packages @@ -248,11 +286,13 @@ def recurse_tree(rootpath: str, excludes: List[str], opts: Any, py_files = sorted(f for f in files if f.endswith(PY_SUFFIXES) and not is_excluded(path.join(root, f), excludes)) - is_pkg = INITPY in py_files - is_namespace = INITPY not in py_files and implicit_namespaces + is_pkg = is_packagedir(None, py_files) + is_namespace = not is_pkg and implicit_namespaces if is_pkg: - py_files.remove(INITPY) - py_files.insert(0, INITPY) + for f in py_files[:]: + if is_initpy(f): + py_files.remove(f) + py_files.insert(0, f) elif root != rootpath: # only accept non-package at toplevel unless using implicit namespaces if not implicit_namespaces: @@ -269,7 +309,7 @@ def recurse_tree(rootpath: str, excludes: List[str], opts: Any, if is_pkg or is_namespace: # we are in a package with something to document - if subs or len(py_files) > 1 or not shall_skip(path.join(root, INITPY), opts): + if subs or len(py_files) > 1 or not is_skipped_package(root, opts): subpackage = root[len(rootpath):].lstrip(path.sep).\ replace(path.sep, '.') # if this is not a namespace or @@ -475,6 +515,13 @@ def main(argv: List[str] = sys.argv[1:]) -> int: return 0 +deprecated_alias('sphinx.ext.apidoc', + { + 'INITPY': '__init__.py', + }, + RemovedInSphinx40Warning) + + # So program can be started with "python -m sphinx.apidoc ..." if __name__ == "__main__": main() diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index b9404804b..7abd6c879 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -1065,7 +1065,7 @@ class DecoratorDocumenter(FunctionDocumenter): # must be lower than FunctionDocumenter priority = -1 - def format_args(self, **kwargs): + def format_args(self, **kwargs: Any) -> Any: args = super().format_args(**kwargs) if ',' in args: return args @@ -1146,7 +1146,7 @@ class ClassDocumenter(DocstringSignatureMixin, ModuleLevelDocumenter): # type: if hasattr(self.object, '__bases__') and len(self.object.__bases__): bases = [':class:`%s`' % b.__name__ if b.__module__ in ('__builtin__', 'builtins') - else ':class:`%s.%s`' % (b.__module__, b.__name__) + else ':class:`%s.%s`' % (b.__module__, b.__qualname__) for b in self.object.__bases__] self.add_line(' ' + _('Bases: %s') % ', '.join(bases), sourcename) @@ -1266,7 +1266,7 @@ class DataDocumenter(ModuleLevelDocumenter): if not self.options.annotation: # obtain annotation for this data annotations = getattr(self.parent, '__annotations__', {}) - if self.objpath[-1] in annotations: + if annotations and self.objpath[-1] in annotations: objrepr = stringify_typehint(annotations.get(self.objpath[-1])) self.add_line(' :type: ' + objrepr, sourcename) else: @@ -1454,7 +1454,7 @@ class AttributeDocumenter(DocstringStripSignatureMixin, ClassLevelDocumenter): if not self._datadescriptor: # obtain annotation for this attribute annotations = getattr(self.parent, '__annotations__', {}) - if self.objpath[-1] in annotations: + if annotations and self.objpath[-1] in annotations: objrepr = stringify_typehint(annotations.get(self.objpath[-1])) self.add_line(' :type: ' + objrepr, sourcename) else: diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index 3d4fb1805..e98b97915 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -12,7 +12,7 @@ import importlib import traceback import warnings from collections import namedtuple -from typing import Any, Callable, Dict, List, Tuple +from typing import Any, Callable, Dict, List, Mapping, Tuple from sphinx.deprecation import RemovedInSphinx40Warning, deprecated_alias from sphinx.util import logging @@ -164,7 +164,7 @@ def get_object_members(subject: Any, objpath: List[str], attrgetter: Callable, continue # annotation only member (ex. attr: int) - if hasattr(subject, '__annotations__'): + if hasattr(subject, '__annotations__') and isinstance(subject.__annotations__, Mapping): for name in subject.__annotations__: if name not in members: members[name] = Attribute(name, True, INSTANCEATTR) diff --git a/sphinx/ext/autodoc/type_comment.py b/sphinx/ext/autodoc/type_comment.py index c94020bf0..a7eb2c4a2 100644 --- a/sphinx/ext/autodoc/type_comment.py +++ b/sphinx/ext/autodoc/type_comment.py @@ -8,13 +8,14 @@ :license: BSD, see LICENSE for details. """ -import ast -from inspect import getsource -from typing import Any, Dict +from inspect import Parameter, Signature, getsource +from typing import Any, Dict, List from typing import cast import sphinx from sphinx.application import Sphinx +from sphinx.locale import __ +from sphinx.pycode.ast import ast from sphinx.pycode.ast import parse as ast_parse from sphinx.pycode.ast import unparse as ast_unparse from sphinx.util import inspect @@ -23,11 +24,73 @@ from sphinx.util import logging logger = logging.getLogger(__name__) -def get_type_comment(obj: Any) -> ast.FunctionDef: +def not_suppressed(argtypes: List[ast.AST] = []) -> bool: + """Check given *argtypes* is suppressed type_comment or not.""" + if len(argtypes) == 0: # no argtypees + return False + elif len(argtypes) == 1 and ast_unparse(argtypes[0]) == "...": # suppressed + # Note: To support multiple versions of python, this uses ``ast_unparse()`` for + # comparison with Ellipsis. Since 3.8, ast.Constant has been used to represent + # Ellipsis node instead of ast.Ellipsis. + return False + else: # not suppressed + return True + + +def signature_from_ast(node: ast.FunctionDef, bound_method: bool, + type_comment: ast.FunctionDef) -> Signature: + """Return a Signature object for the given *node*. + + :param bound_method: Specify *node* is a bound method or not + """ + params = [] + if hasattr(node.args, "posonlyargs"): # for py38+ + for arg in node.args.posonlyargs: # type: ignore + param = Parameter(arg.arg, Parameter.POSITIONAL_ONLY, annotation=arg.type_comment) + params.append(param) + + for arg in node.args.args: + param = Parameter(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, + annotation=arg.type_comment or Parameter.empty) + params.append(param) + + if node.args.vararg: + param = Parameter(node.args.vararg.arg, Parameter.VAR_POSITIONAL, + annotation=arg.type_comment or Parameter.empty) + params.append(param) + + for arg in node.args.kwonlyargs: + param = Parameter(arg.arg, Parameter.KEYWORD_ONLY, + annotation=arg.type_comment or Parameter.empty) + params.append(param) + + if node.args.kwarg: + param = Parameter(node.args.kwarg.arg, Parameter.VAR_KEYWORD, + annotation=arg.type_comment or Parameter.empty) + params.append(param) + + # Remove first parameter when *obj* is bound_method + if bound_method and params: + params.pop(0) + + # merge type_comment into signature + if not_suppressed(type_comment.argtypes): # type: ignore + for i, param in enumerate(params): + params[i] = param.replace(annotation=type_comment.argtypes[i]) # type: ignore + + if node.returns: + return Signature(params, return_annotation=node.returns) + elif type_comment.returns: + return Signature(params, return_annotation=ast_unparse(type_comment.returns)) + else: + return Signature(params) + + +def get_type_comment(obj: Any, bound_method: bool = False) -> Signature: """Get type_comment'ed FunctionDef object from living object. This tries to parse original code for living object and returns - AST node for given *obj*. It requires py38+ or typed_ast module. + Signature for given *obj*. It requires py38+ or typed_ast module. """ try: source = getsource(obj) @@ -41,7 +104,8 @@ def get_type_comment(obj: Any) -> ast.FunctionDef: subject = cast(ast.FunctionDef, module.body[0]) # type: ignore if getattr(subject, "type_comment", None): - return ast_parse(subject.type_comment, mode='func_type') # type: ignore + function = ast_parse(subject.type_comment, mode='func_type') + return signature_from_ast(subject, bound_method, function) # type: ignore else: return None except (OSError, TypeError): # failed to load source code @@ -53,19 +117,19 @@ def get_type_comment(obj: Any) -> ast.FunctionDef: def update_annotations_using_type_comments(app: Sphinx, obj: Any, bound_method: bool) -> None: """Update annotations info of *obj* using type_comments.""" try: - function = get_type_comment(obj) - if function and hasattr(function, 'argtypes'): - if function.argtypes != [ast.Ellipsis]: # type: ignore - sig = inspect.signature(obj, bound_method) - for i, param in enumerate(sig.parameters.values()): - if param.name not in obj.__annotations__: - annotation = ast_unparse(function.argtypes[i]) # type: ignore - obj.__annotations__[param.name] = annotation + type_sig = get_type_comment(obj, bound_method) + if type_sig: + sig = inspect.signature(obj, bound_method) + for param in sig.parameters.values(): + if param.name not in obj.__annotations__: + annotation = type_sig.parameters[param.name].annotation + if annotation is not Parameter.empty: + obj.__annotations__[param.name] = ast_unparse(annotation) if 'return' not in obj.__annotations__: - obj.__annotations__['return'] = ast_unparse(function.returns) # type: ignore + obj.__annotations__['return'] = type_sig.return_annotation except NotImplementedError as exc: # failed to ast.unparse() - logger.warning("Failed to parse type_comment for %r: %s", obj, exc) + logger.warning(__("Failed to parse type_comment for %r: %s"), obj, exc) def setup(app: Sphinx) -> Dict[str, Any]: diff --git a/sphinx/ext/autodoc/typehints.py b/sphinx/ext/autodoc/typehints.py index d7b4ee96b..64782dc1c 100644 --- a/sphinx/ext/autodoc/typehints.py +++ b/sphinx/ext/autodoc/typehints.py @@ -18,19 +18,19 @@ from docutils.nodes import Element from sphinx import addnodes from sphinx.application import Sphinx -from sphinx.config import ENUM +from sphinx.config import Config, ENUM from sphinx.util import inspect, typing -def config_inited(app, config): +def config_inited(app: Sphinx, config: Config) -> None: if config.autodoc_typehints == 'description': # HACK: override this to make autodoc suppressing typehints in signatures - config.autodoc_typehints = 'none' + config.autodoc_typehints = 'none' # type: ignore # preserve user settings - app._autodoc_typehints_description = True + app._autodoc_typehints_description = True # type: ignore else: - app._autodoc_typehints_description = False + app._autodoc_typehints_description = False # type: ignore def record_typehints(app: Sphinx, objtype: str, name: str, obj: Any, @@ -46,7 +46,7 @@ def record_typehints(app: Sphinx, objtype: str, name: str, obj: Any, annotation[param.name] = typing.stringify(param.annotation) if sig.return_annotation is not sig.empty: annotation['return'] = typing.stringify(sig.return_annotation) - except TypeError: + except (TypeError, ValueError): pass @@ -140,10 +140,16 @@ def modify_field_list(node: nodes.field_list, annotations: Dict[str, str]) -> No node += field -def setup(app): +def setup(app: Sphinx) -> Dict[str, Any]: app.setup_extension('sphinx.ext.autodoc') app.config.values['autodoc_typehints'] = ('signature', True, ENUM("signature", "description", "none")) app.connect('config-inited', config_inited) app.connect('autodoc-process-signature', record_typehints) app.connect('object-description-transform', merge_typehints) + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py index 987cf2919..e8157848f 100644 --- a/sphinx/ext/coverage.py +++ b/sphinx/ext/coverage.py @@ -123,7 +123,7 @@ class CoverageBuilder(Builder): op.write(' * %-50s [%9s]\n' % (name, typ)) op.write('\n') - def ignore_pyobj(self, full_name): + def ignore_pyobj(self, full_name: str) -> bool: for exp in self.py_ignorexps: if exp.search(full_name): return True diff --git a/sphinx/ext/duration.py b/sphinx/ext/duration.py index 02e60cf7e..669baf2f1 100644 --- a/sphinx/ext/duration.py +++ b/sphinx/ext/duration.py @@ -11,8 +11,8 @@ from datetime import datetime, timedelta from itertools import islice from operator import itemgetter +from typing import Any, Dict, List from typing import cast -from typing import Dict, List from docutils import nodes @@ -82,7 +82,7 @@ def on_build_finished(app: Sphinx, error: Exception) -> None: logger.info('%d.%03d %s', d.seconds, d.microseconds / 1000, docname) -def setup(app): +def setup(app: Sphinx) -> Dict[str, Any]: app.add_domain(DurationDomain) app.connect('builder-inited', on_builder_inited) app.connect('source-read', on_source_read) diff --git a/sphinx/ext/inheritance_diagram.py b/sphinx/ext/inheritance_diagram.py index 83e277523..db2a15b14 100644 --- a/sphinx/ext/inheritance_diagram.py +++ b/sphinx/ext/inheritance_diagram.py @@ -229,7 +229,7 @@ class InheritanceGraph: if module in ('__builtin__', 'builtins'): fullname = cls.__name__ else: - fullname = '%s.%s' % (module, cls.__name__) + fullname = '%s.%s' % (module, cls.__qualname__) if parts == 0: result = fullname else: diff --git a/sphinx/ext/napoleon/docstring.py b/sphinx/ext/napoleon/docstring.py index 81f2496cb..7f6ebe478 100644 --- a/sphinx/ext/napoleon/docstring.py +++ b/sphinx/ext/napoleon/docstring.py @@ -561,7 +561,7 @@ class GoogleDocstring: lines = self._consume_to_next_section() self._parsed_lines.extend(lines) - def _parse_admonition(self, admonition, section): + def _parse_admonition(self, admonition: str, section: str) -> List[str]: # type (str, str) -> List[str] lines = self._consume_to_next_section() return self._format_admonition(admonition, lines) @@ -603,7 +603,7 @@ class GoogleDocstring: label = labels.get(section.lower(), section) return self._parse_generic_section(label, use_admonition) - def _parse_custom_generic_section(self, section): + def _parse_custom_generic_section(self, section: str) -> List[str]: # for now, no admonition for simple custom sections return self._parse_generic_section(section, False) diff --git a/sphinx/ext/viewcode.py b/sphinx/ext/viewcode.py index 09fcd695f..dc24a1993 100644 --- a/sphinx/ext/viewcode.py +++ b/sphinx/ext/viewcode.py @@ -54,20 +54,20 @@ def doctree_read(app: Sphinx, doctree: Node) -> None: if app.builder.name.startswith("epub") and not env.config.viewcode_enable_epub: return - def has_tag(modname, fullname, docname, refname): + def has_tag(modname: str, fullname: str, docname: str, refname: str) -> bool: entry = env._viewcode_modules.get(modname, None) # type: ignore if entry is False: - return + return False code_tags = app.emit_firstresult('viewcode-find-source', modname) if code_tags is None: try: analyzer = ModuleAnalyzer.for_module(modname) + analyzer.find_tags() except Exception: env._viewcode_modules[modname] = False # type: ignore - return + return False - analyzer.find_tags() code = analyzer.code tags = analyzer.tags else: @@ -81,6 +81,8 @@ def doctree_read(app: Sphinx, doctree: Node) -> None: used[fullname] = docname return True + return False + for objnode in doctree.traverse(addnodes.desc): if objnode.get('domain') != 'py': continue diff --git a/sphinx/highlighting.py b/sphinx/highlighting.py index f3445d1bd..4dc9ce543 100644 --- a/sphinx/highlighting.py +++ b/sphinx/highlighting.py @@ -105,11 +105,6 @@ class PygmentsBridge: lang = 'pycon3' else: lang = 'python3' - elif lang == 'guess': - try: - lexer = guess_lexer(source) - except Exception: - lexer = lexers['none'] if lang in lexers: # just return custom lexers here (without installing raiseonerror filter) @@ -119,7 +114,7 @@ class PygmentsBridge: else: try: if lang == 'guess': - lexer = guess_lexer(lang, **opts) + lexer = guess_lexer(source, **opts) else: lexer = get_lexer_by_name(lang, **opts) except ClassNotFound: diff --git a/sphinx/parsers.py b/sphinx/parsers.py index 2a61971f3..3974d1c66 100644 --- a/sphinx/parsers.py +++ b/sphinx/parsers.py @@ -8,6 +8,7 @@ :license: BSD, see LICENSE for details. """ +import warnings from typing import Any, Dict, List, Union import docutils.parsers @@ -17,6 +18,7 @@ from docutils.parsers.rst import states from docutils.statemachine import StringList from docutils.transforms.universal import SmartQuotes +from sphinx.deprecation import RemovedInSphinx50Warning from sphinx.util.rst import append_epilog, prepend_prolog if False: @@ -47,6 +49,8 @@ class Parser(docutils.parsers.Parser): .. deprecated:: 1.6 ``warn()`` and ``info()`` is deprecated. Use :mod:`sphinx.util.logging` instead. + .. deprecated:: 3.0 + parser.app is deprecated. """ def set_application(self, app: "Sphinx") -> None: @@ -54,10 +58,15 @@ class Parser(docutils.parsers.Parser): :param sphinx.application.Sphinx app: Sphinx application object """ - self.app = app + self._app = app self.config = app.config self.env = app.env + @property + def app(self) -> "Sphinx": + warnings.warn('parser.app is deprecated.', RemovedInSphinx50Warning) + return self._app + class RSTParser(docutils.parsers.rst.Parser, Parser): """A reST parser for Sphinx.""" diff --git a/sphinx/pycode/ast.py b/sphinx/pycode/ast.py index 22207b715..52617e3bc 100644 --- a/sphinx/pycode/ast.py +++ b/sphinx/pycode/ast.py @@ -9,6 +9,7 @@ """ import sys +from typing import List if sys.version_info > (3, 8): import ast @@ -40,6 +41,13 @@ def unparse(node: ast.AST) -> str: return None elif isinstance(node, str): return node + elif isinstance(node, ast.arg): + if node.annotation: + return "%s: %s" % (node.arg, unparse(node.annotation)) + else: + return node.arg + elif isinstance(node, ast.arguments): + return unparse_arguments(node) elif isinstance(node, ast.Attribute): return "%s.%s" % (unparse(node.value), node.attr) elif isinstance(node, ast.Bytes): @@ -58,7 +66,7 @@ def unparse(node: ast.AST) -> str: elif isinstance(node, ast.Index): return unparse(node.value) elif isinstance(node, ast.Lambda): - return "<function <lambda>>" # TODO + return "lambda %s: ..." % unparse(node.args) elif isinstance(node, ast.List): return "[" + ", ".join(unparse(e) for e in node.elts) + "]" elif isinstance(node, ast.Name): @@ -80,3 +88,61 @@ def unparse(node: ast.AST) -> str: return repr(node.value) else: raise NotImplementedError('Unable to parse %s object' % type(node).__name__) + + +def unparse_arguments(node: ast.arguments) -> str: + """Unparse an arguments to string.""" + defaults = list(node.defaults) + positionals = len(node.args) + posonlyargs = 0 + if hasattr(node, "posonlyargs"): # for py38+ + posonlyargs += len(node.posonlyargs) # type:ignore + positionals += posonlyargs + for _ in range(len(defaults), positionals): + defaults.insert(0, None) + + kw_defaults = list(node.kw_defaults) + for _ in range(len(kw_defaults), len(node.kwonlyargs)): + kw_defaults.insert(0, None) + + args = [] # type: List[str] + if hasattr(node, "posonlyargs"): # for py38+ + for i, arg in enumerate(node.posonlyargs): # type: ignore + name = unparse(arg) + if defaults[i]: + if arg.annotation: + name += " = %s" % unparse(defaults[i]) + else: + name += "=%s" % unparse(defaults[i]) + args.append(name) + + if node.posonlyargs: # type: ignore + args.append('/') + + for i, arg in enumerate(node.args): + name = unparse(arg) + if defaults[i + posonlyargs]: + if arg.annotation: + name += " = %s" % unparse(defaults[i + posonlyargs]) + else: + name += "=%s" % unparse(defaults[i + posonlyargs]) + args.append(name) + + if node.vararg: + args.append("*" + unparse(node.vararg)) + + if node.kwonlyargs and not node.vararg: + args.append('*') + for i, arg in enumerate(node.kwonlyargs): + name = unparse(arg) + if kw_defaults[i]: + if arg.annotation: + name += " = %s" % unparse(kw_defaults[i]) + else: + name += "=%s" % unparse(kw_defaults[i]) + args.append(name) + + if node.kwarg: + args.append("**" + unparse(node.kwarg)) + + return ", ".join(args) diff --git a/sphinx/texinputs/Makefile_t b/sphinx/texinputs/Makefile_t index 90cee829b..96bb0fed1 100644 --- a/sphinx/texinputs/Makefile_t +++ b/sphinx/texinputs/Makefile_t @@ -14,13 +14,13 @@ ALLPS = $(addsuffix .ps,$(ALLDOCS)) # Prefix for archive names ARCHIVEPREFIX = # Additional LaTeX options (passed via variables in latexmkrc/latexmkjarc file) -export LATEXOPTS = +export LATEXOPTS ?= # Additional latexmk options {% if latex_engine == 'xelatex' -%} # with latexmk version 4.52b or higher set LATEXMKOPTS to -xelatex either here # or on command line for faster builds. {% endif -%} -LATEXMKOPTS = +LATEXMKOPTS ?= {% if xindy_use -%} export XINDYOPTS = {{ xindy_lang_option }} -M sphinx.xdy {% if latex_engine == 'pdflatex' -%} diff --git a/sphinx/transforms/__init__.py b/sphinx/transforms/__init__.py index 922b22e46..a00f04fdf 100644 --- a/sphinx/transforms/__init__.py +++ b/sphinx/transforms/__init__.py @@ -173,7 +173,9 @@ class AutoNumbering(SphinxTransform): domain = self.env.get_domain('std') # type: StandardDomain for node in self.document.traverse(nodes.Element): - if domain.is_enumerable_node(node) and domain.get_numfig_title(node) is not None: + if (domain.is_enumerable_node(node) and + domain.get_numfig_title(node) is not None and + node['ids'] == []): self.document.note_implicit_target(node) diff --git a/sphinx/transforms/post_transforms/images.py b/sphinx/transforms/post_transforms/images.py index 758e92f0d..d1b513b27 100644 --- a/sphinx/transforms/post_transforms/images.py +++ b/sphinx/transforms/post_transforms/images.py @@ -9,6 +9,7 @@ """ import os +import re from hashlib import sha1 from math import ceil from typing import Any, Dict, List, Tuple @@ -27,6 +28,7 @@ from sphinx.util.osutil import ensuredir, movefile logger = logging.getLogger(__name__) MAX_FILENAME_LEN = 32 +CRITICAL_PATH_CHAR_RE = re.compile('[:;<>|*" ]') class BaseImageConverter(SphinxTransform): @@ -65,6 +67,7 @@ class ImageDownloader(BaseImageConverter): if basename == '' or len(basename) > MAX_FILENAME_LEN: filename, ext = os.path.splitext(node['uri']) basename = sha1(filename.encode()).hexdigest() + ext + basename = re.sub(CRITICAL_PATH_CHAR_RE, "_", basename) dirname = node['uri'].replace('://', '/').translate({ord("?"): "/", ord("&"): "/"}) @@ -146,6 +149,7 @@ class DataURIExtractor(BaseImageConverter): def get_filename_for(filename: str, mimetype: str) -> str: basename = os.path.basename(filename) + basename = re.sub(CRITICAL_PATH_CHAR_RE, "_", basename) return os.path.splitext(basename)[0] + get_image_extension(mimetype) diff --git a/sphinx/util/__init__.py b/sphinx/util/__init__.py index 954315b86..12ae051f1 100644 --- a/sphinx/util/__init__.py +++ b/sphinx/util/__init__.py @@ -649,7 +649,7 @@ class progress_message: def __call__(self, f: Callable) -> Callable: @functools.wraps(f) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: with self: return f(*args, **kwargs) diff --git a/sphinx/util/images.py b/sphinx/util/images.py index 17bd95685..568682b37 100644 --- a/sphinx/util/images.py +++ b/sphinx/util/images.py @@ -41,6 +41,8 @@ def get_image_size(filename: str) -> Tuple[int, int]: size = imagesize.get(filename) if size[0] == -1: size = None + elif isinstance(size[0], float) or isinstance(size[1], float): + size = (int(size[0]), int(size[1])) if size is None and Image: # fallback to Pillow with Image.open(filename) as im: diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index af8102402..5bf3ffbbf 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -21,8 +21,11 @@ from inspect import ( # NOQA ) from io import StringIO from typing import Any, Callable, Mapping, List, Tuple +from typing import cast -from sphinx.deprecation import RemovedInSphinx40Warning +from sphinx.deprecation import RemovedInSphinx40Warning, RemovedInSphinx50Warning +from sphinx.pycode.ast import ast # for py35-37 +from sphinx.pycode.ast import unparse as ast_unparse from sphinx.util import logging from sphinx.util.typing import stringify as stringify_annotation @@ -51,9 +54,11 @@ memory_address_re = re.compile(r' at 0x[0-9a-f]{8,16}(?=>)', re.IGNORECASE) # Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, # 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017 Python Software # Foundation; All Rights Reserved -def getargspec(func): +def getargspec(func: Callable) -> Any: """Like inspect.getfullargspec but supports bound methods, and wrapped methods.""" + warnings.warn('sphinx.ext.inspect.getargspec() is deprecated', + RemovedInSphinx50Warning) # On 3.5+, signature(int) or similar raises ValueError. On 3.4, it # succeeds with a bogus signature. We want a TypeError uniformly, to # match historical behavior. @@ -387,9 +392,9 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True, if param.kind != param.POSITIONAL_ONLY and last_kind == param.POSITIONAL_ONLY: # PEP-570: Separator for Positional Only Parameter: / args.append('/') - elif param.kind == param.KEYWORD_ONLY and last_kind in (param.POSITIONAL_OR_KEYWORD, - param.POSITIONAL_ONLY, - None): + if param.kind == param.KEYWORD_ONLY and last_kind in (param.POSITIONAL_OR_KEYWORD, + param.POSITIONAL_ONLY, + None): # PEP-3102: Separator for Keyword Only Parameter: * args.append('*') @@ -427,6 +432,52 @@ def stringify_signature(sig: inspect.Signature, show_annotation: bool = True, return '(%s) -> %s' % (', '.join(args), annotation) +def signature_from_str(signature: str) -> inspect.Signature: + """Create a Signature object from string.""" + module = ast.parse('def func' + signature + ': pass') + definition = cast(ast.FunctionDef, module.body[0]) # type: ignore + + # parameters + args = definition.args + params = [] + + if hasattr(args, "posonlyargs"): + for arg in args.posonlyargs: # type: ignore + annotation = ast_unparse(arg.annotation) or Parameter.empty + params.append(Parameter(arg.arg, Parameter.POSITIONAL_ONLY, + annotation=annotation)) + + for i, arg in enumerate(args.args): + if len(args.args) - i <= len(args.defaults): + default = ast_unparse(args.defaults[-len(args.args) + i]) + else: + default = Parameter.empty + + annotation = ast_unparse(arg.annotation) or Parameter.empty + params.append(Parameter(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, + default=default, annotation=annotation)) + + if args.vararg: + annotation = ast_unparse(args.vararg.annotation) or Parameter.empty + params.append(Parameter(args.vararg.arg, Parameter.VAR_POSITIONAL, + annotation=annotation)) + + for i, arg in enumerate(args.kwonlyargs): + default = ast_unparse(args.kw_defaults[i]) + annotation = ast_unparse(arg.annotation) or Parameter.empty + params.append(Parameter(arg.arg, Parameter.KEYWORD_ONLY, default=default, + annotation=annotation)) + + if args.kwarg: + annotation = ast_unparse(args.kwarg.annotation) or Parameter.empty + params.append(Parameter(args.kwarg.arg, Parameter.VAR_KEYWORD, + annotation=annotation)) + + return_annotation = ast_unparse(definition.returns) or Parameter.empty + + return inspect.Signature(params, return_annotation=return_annotation) + + class Signature: """The Signature object represents the call signature of a callable object and its return annotation. diff --git a/sphinx/util/inventory.py b/sphinx/util/inventory.py index 64f4ef4e3..9b647ccac 100644 --- a/sphinx/util/inventory.py +++ b/sphinx/util/inventory.py @@ -122,7 +122,7 @@ class InventoryFile: for line in stream.read_compressed_lines(): # be careful to handle names with embedded spaces correctly - m = re.match(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+(\S+)\s+(.*)', + m = re.match(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)', line.rstrip()) if not m: continue diff --git a/sphinx/util/pycompat.py b/sphinx/util/pycompat.py index ba4f83a71..061bbcb6d 100644 --- a/sphinx/util/pycompat.py +++ b/sphinx/util/pycompat.py @@ -52,7 +52,7 @@ class UnicodeMixin: .. deprecated:: 2.0 """ - def __str__(self): + def __str__(self) -> str: warnings.warn('UnicodeMixin is deprecated', RemovedInSphinx40Warning, stacklevel=2) return self.__unicode__() # type: ignore diff --git a/sphinx/writers/html.py b/sphinx/writers/html.py index 32de55031..e74c0334f 100644 --- a/sphinx/writers/html.py +++ b/sphinx/writers/html.py @@ -604,11 +604,7 @@ class HTMLTranslator(SphinxTranslator, BaseTranslator): atts['height'] = int(atts['height']) * scale atts['alt'] = node.get('alt', uri) if 'align' in node: - self.body.append('<div align="%s" class="align-%s">' % - (node['align'], node['align'])) - self.context.append('</div>\n') - else: - self.context.append('') + atts['class'] = 'align-%s' % node['align'] self.body.append(self.emptytag(node, 'img', '', **atts)) return @@ -617,7 +613,7 @@ class HTMLTranslator(SphinxTranslator, BaseTranslator): # overwritten def depart_image(self, node: Element) -> None: if node['uri'].lower().endswith(('svg', 'svgz')): - self.body.append(self.context.pop()) + pass else: super().depart_image(node) diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index c6d4b4f99..0c00a1fa4 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -545,11 +545,7 @@ class HTML5Translator(SphinxTranslator, BaseTranslator): atts['height'] = int(atts['height']) * scale atts['alt'] = node.get('alt', uri) if 'align' in node: - self.body.append('<div align="%s" class="align-%s">' % - (node['align'], node['align'])) - self.context.append('</div>\n') - else: - self.context.append('') + atts['class'] = 'align-%s' % node['align'] self.body.append(self.emptytag(node, 'img', '', **atts)) return @@ -558,7 +554,7 @@ class HTML5Translator(SphinxTranslator, BaseTranslator): # overwritten def depart_image(self, node: Element) -> None: if node['uri'].lower().endswith(('svg', 'svgz')): - self.body.append(self.context.pop()) + pass else: super().depart_image(node) diff --git a/sphinx/writers/text.py b/sphinx/writers/text.py index 9b9f8e62a..ac3ba9dae 100644 --- a/sphinx/writers/text.py +++ b/sphinx/writers/text.py @@ -916,7 +916,6 @@ class TextTranslator(SphinxTranslator): def _depart_admonition(self, node: Element) -> None: label = admonitionlabels[node.tagname] indent = sum(self.stateindent) + len(label) - print(self.states[-1]) if (len(self.states[-1]) == 1 and self.states[-1][0][0] == 0 and MAXWIDTH - indent >= sum(len(s) for s in self.states[-1][0][1])): diff --git a/tests/roots/test-builder-dirhtml/bar.rst b/tests/roots/test-builder-dirhtml/bar.rst new file mode 100644 index 000000000..11f287a18 --- /dev/null +++ b/tests/roots/test-builder-dirhtml/bar.rst @@ -0,0 +1,4 @@ +.. _bar: + +bar +=== diff --git a/tests/roots/test-builder-dirhtml/conf.py b/tests/roots/test-builder-dirhtml/conf.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/roots/test-builder-dirhtml/conf.py diff --git a/tests/roots/test-builder-dirhtml/foo/foo_1.rst b/tests/roots/test-builder-dirhtml/foo/foo_1.rst new file mode 100644 index 000000000..6db0ea57e --- /dev/null +++ b/tests/roots/test-builder-dirhtml/foo/foo_1.rst @@ -0,0 +1,4 @@ +.. _foo_1: + +foo/foo_1 +========= diff --git a/tests/roots/test-builder-dirhtml/foo/foo_2.rst b/tests/roots/test-builder-dirhtml/foo/foo_2.rst new file mode 100644 index 000000000..fae7f26ef --- /dev/null +++ b/tests/roots/test-builder-dirhtml/foo/foo_2.rst @@ -0,0 +1,4 @@ +.. _foo_2: + +foo/foo_2 +========= diff --git a/tests/roots/test-builder-dirhtml/foo/index.rst b/tests/roots/test-builder-dirhtml/foo/index.rst new file mode 100644 index 000000000..92d473c6b --- /dev/null +++ b/tests/roots/test-builder-dirhtml/foo/index.rst @@ -0,0 +1,9 @@ +.. _foo: + +foo/index +========= + +.. toctree:: + + foo_1 + foo_2 diff --git a/tests/roots/test-builder-dirhtml/index.rst b/tests/roots/test-builder-dirhtml/index.rst new file mode 100644 index 000000000..274e17793 --- /dev/null +++ b/tests/roots/test-builder-dirhtml/index.rst @@ -0,0 +1,9 @@ +.. _index: + +index +===== + +.. toctree:: + + foo/index + bar diff --git a/tests/roots/test-ext-autodoc/target/__init__.py b/tests/roots/test-ext-autodoc/target/__init__.py index f6970a36c..e28eeef8a 100644 --- a/tests/roots/test-ext-autodoc/target/__init__.py +++ b/tests/roots/test-ext-autodoc/target/__init__.py @@ -110,6 +110,10 @@ class Outer(object): factory = dict +class InnerChild(Outer.Inner): + """InnerChild docstring""" + + class DocstringSig(object): def meth(self): """meth(FOO, BAR=1) -> BAZ diff --git a/tests/roots/test-ext-autodoc/target/pep570.py b/tests/roots/test-ext-autodoc/target/pep570.py index 904692eeb..1a77eae93 100644 --- a/tests/roots/test-ext-autodoc/target/pep570.py +++ b/tests/roots/test-ext-autodoc/target/pep570.py @@ -1,5 +1,11 @@ -def foo(a, b, /, c, d): +def foo(*, a, b): pass -def bar(a, b, /): +def bar(a, b, /, c, d): + pass + +def baz(a, /, *, b): + pass + +def qux(a, b, /): pass diff --git a/tests/roots/test-ext-autodoc/target/typehints.py b/tests/roots/test-ext-autodoc/target/typehints.py index 842530c13..ab5bfb624 100644 --- a/tests/roots/test-ext-autodoc/target/typehints.py +++ b/tests/roots/test-ext-autodoc/target/typehints.py @@ -18,7 +18,27 @@ class Math: # type: (int, int) -> int return a - b + def nothing(self): + # type: () -> None + pass + + def horse(self, + a, # type: str + b, # type: int + ): + # type: (...) -> None + return + def complex_func(arg1, arg2, arg3=None, *args, **kwargs): # type: (str, List[int], Tuple[int, Union[str, Unknown]], *str, **str) -> None pass + + +def missing_attr(c, + a, # type: str + b=None # type: Optional[str] + ): + # type: (...) -> str + return a + (b or "") + diff --git a/tests/roots/test-html_scaled_image_link/conf.py b/tests/roots/test-html_scaled_image_link/conf.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/tests/roots/test-html_scaled_image_link/conf.py diff --git a/tests/roots/test-html_scaled_image_link/img.png b/tests/roots/test-html_scaled_image_link/img.png Binary files differnew file mode 100644 index 000000000..a97e86d66 --- /dev/null +++ b/tests/roots/test-html_scaled_image_link/img.png diff --git a/tests/roots/test-html_scaled_image_link/index.rst b/tests/roots/test-html_scaled_image_link/index.rst new file mode 100644 index 000000000..0e4794058 --- /dev/null +++ b/tests/roots/test-html_scaled_image_link/index.rst @@ -0,0 +1,11 @@ +test-html_scaled_image_link +=========================== + +.. image:: img.png + +.. image:: img.png + :scale: 50% + +.. image:: img.png + :scale: 50% + :class: no-scaled-link diff --git a/tests/roots/test-inheritance/diagram_w_nested_classes.rst b/tests/roots/test-inheritance/diagram_w_nested_classes.rst new file mode 100644 index 000000000..7fa02175f --- /dev/null +++ b/tests/roots/test-inheritance/diagram_w_nested_classes.rst @@ -0,0 +1,5 @@ +Diagram with Nested Classes +=========================== + +.. inheritance-diagram:: + dummy.test_nested diff --git a/tests/roots/test-inheritance/dummy/test_nested.py b/tests/roots/test-inheritance/dummy/test_nested.py new file mode 100644 index 000000000..1e732aab5 --- /dev/null +++ b/tests/roots/test-inheritance/dummy/test_nested.py @@ -0,0 +1,12 @@ +""" + Test with nested classes. +""" + + +class A(object): + class B(object): + pass + + +class C(A.B): + pass diff --git a/tests/test_autodoc.py b/tests/test_autodoc.py index 52e9bf5c5..e6b4cc5b6 100644 --- a/tests/test_autodoc.py +++ b/tests/test_autodoc.py @@ -685,6 +685,7 @@ def test_autodoc_ignore_module_all(app): assert list(filter(lambda l: 'class::' in l, actual)) == [ '.. py:class:: Class(arg)', '.. py:class:: CustomDict', + '.. py:class:: InnerChild', '.. py:class:: InstAttCls()', '.. py:class:: Outer', ' .. py:class:: Outer.Inner', @@ -775,6 +776,18 @@ def test_autodoc_inner_class(app): ' ', ] + options['show-inheritance'] = True + actual = do_autodoc(app, 'class', 'target.InnerChild', options) + assert list(actual) == [ + '', + '.. py:class:: InnerChild', + ' :module: target', '', + ' Bases: :class:`target.Outer.Inner`', + '', + ' InnerChild docstring', + ' ' + ] + @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_classmethod(app): diff --git a/tests/test_build_dirhtml.py b/tests/test_build_dirhtml.py new file mode 100644 index 000000000..e89e6888d --- /dev/null +++ b/tests/test_build_dirhtml.py @@ -0,0 +1,48 @@ +""" + test_build_dirhtml + ~~~~~~~~~~~~~~~~~~ + + Test dirhtml builder. + + :copyright: Copyright 2007-2020 by the Sphinx team, see AUTHORS. + :license: BSD, see LICENSE for details. +""" + +import posixpath + +import pytest + +from sphinx.util.inventory import InventoryFile + + +@pytest.mark.sphinx(buildername='dirhtml', testroot='builder-dirhtml') +def test_dirhtml(app, status, warning): + app.build() + + assert (app.outdir / 'index.html').exists() + assert (app.outdir / 'foo/index.html').exists() + assert (app.outdir / 'foo/foo_1/index.html').exists() + assert (app.outdir / 'foo/foo_2/index.html').exists() + assert (app.outdir / 'bar/index.html').exists() + + content = (app.outdir / 'index.html').read_text() + assert 'href="foo/"' in content + assert 'href="foo/foo_1/"' in content + assert 'href="foo/foo_2/"' in content + assert 'href="bar/"' in content + + # objects.inv (refs: #7095) + with (app.outdir / 'objects.inv').open('rb') as f: + invdata = InventoryFile.load(f, 'path/to', posixpath.join) + + assert 'index' in invdata.get('std:doc') + assert ('Python', '', 'path/to/', '-') == invdata['std:doc']['index'] + + assert 'foo/index' in invdata.get('std:doc') + assert ('Python', '', 'path/to/foo/', '-') == invdata['std:doc']['foo/index'] + + assert 'index' in invdata.get('std:label') + assert ('Python', '', 'path/to/#index', '-') == invdata['std:label']['index'] + + assert 'foo' in invdata.get('std:label') + assert ('Python', '', 'path/to/foo/#foo', 'foo/index') == invdata['std:label']['foo'] diff --git a/tests/test_build_html.py b/tests/test_build_html.py index ac44ca7e4..25ef87644 100644 --- a/tests/test_build_html.py +++ b/tests/test_build_html.py @@ -1542,3 +1542,22 @@ def test_validate_html_static_path(app): ] validate_html_static_path(app, app.config) assert app.config.html_static_path == ['_static'] + + +@pytest.mark.sphinx(testroot='html_scaled_image_link') +def test_html_scaled_image_link(app): + app.build() + context = (app.outdir / 'index.html').text() + + # no scaled parameters + assert re.search('\n<img alt="_images/img.png" src="_images/img.png" />', context) + + # scaled_image_link + assert re.search('\n<a class="reference internal image-reference" href="_images/img.png">' + '<img alt="_images/img.png" src="_images/img.png" style="[^"]+" /></a>', + context) + + # no-scaled-link class disables the feature + assert re.search('\n<img alt="_images/img.png" class="no-scaled-link"' + ' src="_images/img.png" style="[^"]+" />', + context) diff --git a/tests/test_domain_js.py b/tests/test_domain_js.py index 581856502..6726c7bbe 100644 --- a/tests/test_domain_js.py +++ b/tests/test_domain_js.py @@ -14,7 +14,12 @@ import pytest from docutils import nodes from sphinx import addnodes +from sphinx.addnodes import ( + desc, desc_annotation, desc_content, desc_name, + desc_parameter, desc_parameterlist, desc_signature +) from sphinx.domains.javascript import JavaScriptDomain +from sphinx.testing import restructuredtext from sphinx.testing.util import assert_node @@ -158,3 +163,51 @@ def test_get_full_qualified_name(): kwargs = {'js:module': 'module1', 'js:object': 'Class'} node = nodes.reference(reftarget='func', **kwargs) assert domain.get_full_qualified_name(node) == 'module1.Class.func' + + +def test_js_module(app): + text = ".. js:module:: sphinx" + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (nodes.target, + addnodes.index)) + assert_node(doctree[0], nodes.target, ids=["module-sphinx"]) + assert_node(doctree[1], addnodes.index, + entries=[("single", "sphinx (module)", "module-sphinx", "", None)]) + + +def test_js_function(app): + text = ".. js:function:: sum(a, b)" + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + [desc, ([desc_signature, ([desc_name, "sum"], + desc_parameterlist)], + [desc_content, ()])])) + assert_node(doctree[1][0][1], [desc_parameterlist, ([desc_parameter, "a"], + [desc_parameter, "b"])]) + assert_node(doctree[0], addnodes.index, + entries=[("single", "sum() (built-in function)", "sum", "", None)]) + assert_node(doctree[1], addnodes.desc, domain="js", objtype="function", noindex=False) + + +def test_js_class(app): + text = ".. js:class:: Application" + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + [desc, ([desc_signature, ([desc_annotation, "class "], + [desc_name, "Application"], + [desc_parameterlist, ()])], + [desc_content, ()])])) + assert_node(doctree[0], addnodes.index, + entries=[("single", "Application() (class)", "Application", "", None)]) + assert_node(doctree[1], addnodes.desc, domain="js", objtype="class", noindex=False) + + +def test_js_data(app): + text = ".. js:data:: name" + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + [desc, ([desc_signature, desc_name, "name"], + [desc_content, ()])])) + assert_node(doctree[0], addnodes.index, + entries=[("single", "name (global variable or constant)", "name", "", None)]) + assert_node(doctree[1], addnodes.desc, domain="js", objtype="data", noindex=False) diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index f78c1e9d8..8e9228537 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -8,6 +8,7 @@ :license: BSD, see LICENSE for details. """ +import sys from unittest.mock import Mock import pytest @@ -168,7 +169,7 @@ def test_domain_py_objects(app, status, warning): def test_resolve_xref_for_properties(app, status, warning): app.builder.build_all() - content = (app.outdir / 'module.html').text() + content = (app.outdir / 'module.html').read_text() assert ('Link to <a class="reference internal" href="#module_a.submodule.ModTopLevel.prop"' ' title="module_a.submodule.ModTopLevel.prop">' '<code class="xref py py-attr docutils literal notranslate"><span class="pre">' @@ -241,7 +242,73 @@ def test_pyfunction_signature(app): desc_content)])) assert_node(doctree[1], addnodes.desc, desctype="function", domain="py", objtype="function", noindex=False) - assert_node(doctree[1][0][1], [desc_parameterlist, desc_parameter, "name: str"]) + assert_node(doctree[1][0][1], + [desc_parameterlist, desc_parameter, ("name", + ": str")]) + + +def test_pyfunction_signature_full(app): + text = (".. py:function:: hello(a: str, b = 1, *args: str, " + "c: bool = True, **kwargs: str) -> str") + doctree = restructuredtext.parse(app, text) + assert_node(doctree, (addnodes.index, + [desc, ([desc_signature, ([desc_name, "hello"], + desc_parameterlist, + [desc_returns, "str"])], + desc_content)])) + assert_node(doctree[1], addnodes.desc, desctype="function", + domain="py", objtype="function", noindex=False) + assert_node(doctree[1][0][1], + [desc_parameterlist, ([desc_parameter, ("a", + ": str")], + [desc_parameter, ("b", + "=1")], + [desc_parameter, ("*args", + ": str")], + [desc_parameter, ("c", + ": bool", + " = True")], + [desc_parameter, ("**kwargs", + ": str")])]) + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason='python 3.8+ is required.') +def test_pyfunction_signature_full_py38(app): + # case: separator at head + text = ".. py:function:: hello(*, a)" + doctree = restructuredtext.parse(app, text) + assert_node(doctree[1][0][1], + [desc_parameterlist, ("*", + [desc_parameter, ("a", + "=None")])]) + + # case: separator in the middle + text = ".. py:function:: hello(a, /, b, *, c)" + doctree = restructuredtext.parse(app, text) + assert_node(doctree[1][0][1], + [desc_parameterlist, ([desc_parameter, "a"], + "/", + [desc_parameter, "b"], + "*", + [desc_parameter, ("c", + "=None")])]) + + # case: separator in the middle (2) + text = ".. py:function:: hello(a, /, *, b)" + doctree = restructuredtext.parse(app, text) + assert_node(doctree[1][0][1], + [desc_parameterlist, ([desc_parameter, "a"], + "/", + "*", + [desc_parameter, ("b", + "=None")])]) + + # case: separator at tail + text = ".. py:function:: hello(a, /)" + doctree = restructuredtext.parse(app, text) + assert_node(doctree[1][0][1], + [desc_parameterlist, ([desc_parameter, "a"], + "/")]) def test_optional_pyfunction_signature(app): diff --git a/tests/test_ext_autodoc_configs.py b/tests/test_ext_autodoc_configs.py index a9af8a272..b90772f6e 100644 --- a/tests/test_ext_autodoc_configs.py +++ b/tests/test_ext_autodoc_configs.py @@ -482,9 +482,17 @@ def test_autodoc_typehints_signature(app): ' :module: target.typehints', ' ', ' ', + ' .. py:method:: Math.horse(a: str, b: int) -> None', + ' :module: target.typehints', + ' ', + ' ', ' .. py:method:: Math.incr(a: int, b: int = 1) -> int', ' :module: target.typehints', ' ', + ' ', + ' .. py:method:: Math.nothing() -> None', + ' :module: target.typehints', + ' ', '', '.. py:function:: complex_func(arg1: str, arg2: List[int], arg3: Tuple[int, ' 'Union[str, Unknown]] = None, *args: str, **kwargs: str) -> None', @@ -497,6 +505,10 @@ def test_autodoc_typehints_signature(app): '', '.. py:function:: incr(a: int, b: int = 1) -> int', ' :module: target.typehints', + '', + '', + '.. py:function:: missing_attr(c, a: str, b: Optional[str] = None) -> str', + ' :module: target.typehints', '' ] @@ -521,9 +533,17 @@ def test_autodoc_typehints_none(app): ' :module: target.typehints', ' ', ' ', + ' .. py:method:: Math.horse(a, b)', + ' :module: target.typehints', + ' ', + ' ', ' .. py:method:: Math.incr(a, b=1)', ' :module: target.typehints', ' ', + ' ', + ' .. py:method:: Math.nothing()', + ' :module: target.typehints', + ' ', '', '.. py:function:: complex_func(arg1, arg2, arg3=None, *args, **kwargs)', ' :module: target.typehints', @@ -535,6 +555,10 @@ def test_autodoc_typehints_none(app): '', '.. py:function:: incr(a, b=1)', ' :module: target.typehints', + '', + '', + '.. py:function:: missing_attr(c, a, b=None)', + ' :module: target.typehints', '' ] diff --git a/tests/test_ext_inheritance_diagram.py b/tests/test_ext_inheritance_diagram.py index baeaa401e..3125f2c6e 100644 --- a/tests/test_ext_inheritance_diagram.py +++ b/tests/test_ext_inheritance_diagram.py @@ -132,6 +132,14 @@ def test_inheritance_diagram(app, status, warning): ('dummy.test.A', 'dummy.test.A', [], None), ] + # inheritance diagram involving a base class nested within another class + for cls in graphs['diagram_w_nested_classes'].class_info: + assert cls in [ + ('dummy.test_nested.A', 'dummy.test_nested.A', [], None), + ('dummy.test_nested.C', 'dummy.test_nested.C', ['dummy.test_nested.A.B'], None), + ('dummy.test_nested.A.B', 'dummy.test_nested.A.B', [], None) + ] + @pytest.mark.sphinx('html', testroot='ext-inheritance_diagram') @pytest.mark.usefixtures('if_graphviz_found') diff --git a/tests/test_highlighting.py b/tests/test_highlighting.py index 525af2547..c2b470a6b 100644 --- a/tests/test_highlighting.py +++ b/tests/test_highlighting.py @@ -40,7 +40,7 @@ class ComplainOnUnhighlighted(PygmentsBridge): def test_add_lexer(app, status, warning): - app.add_lexer('test', MyLexer()) + app.add_lexer('test', MyLexer) bridge = PygmentsBridge('html') ret = bridge.highlight_block('ab', 'test') diff --git a/tests/test_pycode_ast.py b/tests/test_pycode_ast.py index af7e34a86..d195e5c6f 100644 --- a/tests/test_pycode_ast.py +++ b/tests/test_pycode_ast.py @@ -8,6 +8,8 @@ :license: BSD, see LICENSE for details. """ +import sys + import pytest from sphinx.pycode import ast @@ -23,7 +25,7 @@ from sphinx.pycode import ast ("...", "..."), # Ellipsis ("Tuple[int, int]", "Tuple[int, int]"), # Index, Subscript ("lambda x, y: x + y", - "<function <lambda>>"), # Lambda + "lambda x, y: ..."), # Lambda ("[1, 2, 3]", "[1, 2, 3]"), # List ("sys", "sys"), # Name, NameConstant ("1234", "1234"), # Num @@ -38,3 +40,11 @@ def test_unparse(source, expected): def test_unparse_None(): assert ast.unparse(None) is None + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason='python 3.8+ is required.') +def test_unparse_py38(): + source = "lambda x=0, /, y=1, *args, z, **kwargs: x + y + z" + expected = "lambda x=0, /, y=1, *args, z, **kwargs: ..." + module = ast.parse(source) + assert ast.unparse(module.body[0].value) == expected diff --git a/tests/test_util.py b/tests/test_util.py index f794c4f74..434d96d3a 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -18,7 +18,7 @@ import sphinx from sphinx.errors import ExtensionError, PycodeError from sphinx.testing.util import strip_escseq from sphinx.util import ( - SkipProgressMessage, display_chunk, encode_uri, ensuredir, get_module_source, + SkipProgressMessage, display_chunk, encode_uri, ensuredir, import_object, parselinenos, progress_message, status_iterator, xmlname_checker ) from sphinx.util import logging @@ -61,16 +61,6 @@ def test_display_chunk(): assert display_chunk(('hello', 'sphinx', 'world')) == 'hello .. world' -def test_get_module_source(): - assert get_module_source('sphinx') == ('file', sphinx.__file__) - - # failed to obtain source information from builtin modules - with pytest.raises(PycodeError): - get_module_source('builtins') - with pytest.raises(PycodeError): - get_module_source('itertools') - - def test_import_object(): module = import_object('sphinx') assert module.__name__ == 'sphinx' diff --git a/tests/test_util_inspect.py b/tests/test_util_inspect.py index 34844c9bf..5c9520370 100644 --- a/tests/test_util_inspect.py +++ b/tests/test_util_inspect.py @@ -13,6 +13,7 @@ import datetime import functools import sys import types +from inspect import Parameter import pytest @@ -20,76 +21,6 @@ from sphinx.util import inspect from sphinx.util.inspect import stringify_signature -def test_getargspec(): - def func(a, b, c=1, d=2, *e, **f): - pass - - spec = inspect.getargspec(func) - assert spec.args == ['a', 'b', 'c', 'd'] - assert spec.varargs == 'e' - assert spec.varkw == 'f' - assert spec.defaults == (1, 2) - assert spec.kwonlyargs == [] - assert spec.kwonlydefaults is None - assert spec.annotations == {} - - -def test_getargspec_partial(): - def func1(a, b, c=1, d=2, *e, **f): - pass - - partial = functools.partial(func1, 10, c=11) - spec = inspect.getargspec(partial) - assert spec.args == ['b'] - assert spec.varargs is None - assert spec.varkw == 'f' - assert spec.defaults is None - assert spec.kwonlyargs == ['c', 'd'] - assert spec.kwonlydefaults == {'c': 11, 'd': 2} - assert spec.annotations == {} - - -def test_getargspec_partial2(): - def fun(a, b, c=1, d=2): - pass - p = functools.partial(fun, 10, c=11) - - def f_expected(b, *, c=11, d=2): - pass - expected = inspect.getargspec(f_expected) - - assert expected == inspect.getargspec(p) - - -def test_getargspec_builtin_type(): - with pytest.raises(TypeError): - inspect.getargspec(int) - - -def test_getargspec_bound_methods(): - def f_expected_unbound(self, arg1, **kwargs): - pass - expected_unbound = inspect.getargspec(f_expected_unbound) - - def f_expected_bound(arg1, **kwargs): - pass - expected_bound = inspect.getargspec(f_expected_bound) - - class Foo: - def method(self, arg1, **kwargs): - pass - - bound_method = Foo().method - - @functools.wraps(bound_method) - def wrapped_bound_method(*args, **kwargs): - pass - - assert expected_unbound == inspect.getargspec(Foo.method) - assert expected_bound == inspect.getargspec(bound_method) - assert expected_bound == inspect.getargspec(wrapped_bound_method) - - def test_signature(): # literals with pytest.raises(TypeError): @@ -298,17 +229,108 @@ def test_signature_annotations(): @pytest.mark.skipif(sys.version_info < (3, 8), reason='python 3.8+ is required.') @pytest.mark.sphinx(testroot='ext-autodoc') def test_signature_annotations_py38(app): - from target.pep570 import foo, bar + from target.pep570 import foo, bar, baz, qux - # case: separator in the middle + # case: separator at head sig = inspect.signature(foo) + assert stringify_signature(sig) == '(*, a, b)' + + # case: separator in the middle + sig = inspect.signature(bar) assert stringify_signature(sig) == '(a, b, /, c, d)' + sig = inspect.signature(baz) + assert stringify_signature(sig) == '(a, /, *, b)' + # case: separator at tail - sig = inspect.signature(bar) + sig = inspect.signature(qux) assert stringify_signature(sig) == '(a, b, /)' +def test_signature_from_str_basic(): + signature = '(a, b, *args, c=0, d="blah", **kwargs)' + sig = inspect.signature_from_str(signature) + assert list(sig.parameters.keys()) == ['a', 'b', 'args', 'c', 'd', 'kwargs'] + assert sig.parameters['a'].name == 'a' + assert sig.parameters['a'].kind == Parameter.POSITIONAL_OR_KEYWORD + assert sig.parameters['a'].default == Parameter.empty + assert sig.parameters['a'].annotation == Parameter.empty + assert sig.parameters['b'].name == 'b' + assert sig.parameters['b'].kind == Parameter.POSITIONAL_OR_KEYWORD + assert sig.parameters['b'].default == Parameter.empty + assert sig.parameters['b'].annotation == Parameter.empty + assert sig.parameters['args'].name == 'args' + assert sig.parameters['args'].kind == Parameter.VAR_POSITIONAL + assert sig.parameters['args'].default == Parameter.empty + assert sig.parameters['args'].annotation == Parameter.empty + assert sig.parameters['c'].name == 'c' + assert sig.parameters['c'].kind == Parameter.KEYWORD_ONLY + assert sig.parameters['c'].default == '0' + assert sig.parameters['c'].annotation == Parameter.empty + assert sig.parameters['d'].name == 'd' + assert sig.parameters['d'].kind == Parameter.KEYWORD_ONLY + assert sig.parameters['d'].default == "'blah'" + assert sig.parameters['d'].annotation == Parameter.empty + assert sig.parameters['kwargs'].name == 'kwargs' + assert sig.parameters['kwargs'].kind == Parameter.VAR_KEYWORD + assert sig.parameters['kwargs'].default == Parameter.empty + assert sig.parameters['kwargs'].annotation == Parameter.empty + assert sig.return_annotation == Parameter.empty + + +def test_signature_from_str_default_values(): + signature = ('(a=0, b=0.0, c="str", d=b"bytes", e=..., f=True, ' + 'g=[1, 2, 3], h={"a": 1}, i={1, 2, 3}, ' + 'j=lambda x, y: None, k=None, l=object(), m=foo.bar.CONSTANT)') + sig = inspect.signature_from_str(signature) + assert sig.parameters['a'].default == '0' + assert sig.parameters['b'].default == '0.0' + assert sig.parameters['c'].default == "'str'" + assert sig.parameters['d'].default == "b'bytes'" + assert sig.parameters['e'].default == '...' + assert sig.parameters['f'].default == 'True' + assert sig.parameters['g'].default == '[1, 2, 3]' + assert sig.parameters['h'].default == "{'a': 1}" + assert sig.parameters['i'].default == '{1, 2, 3}' + assert sig.parameters['j'].default == 'lambda x, y: ...' + assert sig.parameters['k'].default == 'None' + assert sig.parameters['l'].default == 'object()' + assert sig.parameters['m'].default == 'foo.bar.CONSTANT' + + +def test_signature_from_str_annotations(): + signature = '(a: int, *args: bytes, b: str = "blah", **kwargs: float) -> None' + sig = inspect.signature_from_str(signature) + assert list(sig.parameters.keys()) == ['a', 'args', 'b', 'kwargs'] + assert sig.parameters['a'].annotation == "int" + assert sig.parameters['args'].annotation == "bytes" + assert sig.parameters['b'].annotation == "str" + assert sig.parameters['kwargs'].annotation == "float" + assert sig.return_annotation == 'None' + + +def test_signature_from_str_complex_annotations(): + sig = inspect.signature_from_str('() -> Tuple[str, int, ...]') + assert sig.return_annotation == 'Tuple[str, int, ...]' + + sig = inspect.signature_from_str('() -> Callable[[int, int], int]') + assert sig.return_annotation == 'Callable[[int, int], int]' + + +@pytest.mark.skipif(sys.version_info < (3, 8), + reason='python-3.8 or above is required') +def test_signature_from_str_positionaly_only_args(): + sig = inspect.signature_from_str('(a, /, b)') + assert list(sig.parameters.keys()) == ['a', 'b'] + assert sig.parameters['a'].kind == Parameter.POSITIONAL_ONLY + assert sig.parameters['b'].kind == Parameter.POSITIONAL_OR_KEYWORD + + +def test_signature_from_str_invalid(): + with pytest.raises(SyntaxError): + inspect.signature_from_str('') + + def test_safe_getattr_with_default(): class Foo: def __getattr__(self, item): @@ -11,7 +11,7 @@ description = du{12,13,14}: Run unit tests with the given version of docutils. deps = coverage < 5.0 # refs: https://github.com/sphinx-doc/sphinx/pull/6924 - git+https://github.com/html5lib/html5lib-python ; python_version >= "3.9" # refs: https://github.com/html5lib/html5lib-python/issues/419 + git+https://github.com/html5lib/html5lib-python # refs: https://github.com/html5lib/html5lib-python/issues/419 du12: docutils==0.12 du13: docutils==0.13.1 du14: docutils==0.14 @@ -20,7 +20,7 @@ deps = extras = test 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 + PYTHONWARNINGS = all,ignore::ImportWarning:importlib._bootstrap_external,ignore::DeprecationWarning:site,ignore::DeprecationWarning:distutils commands= pytest --durations 25 {posargs} @@ -28,10 +28,12 @@ commands= basepython = python3 description = Run style checks. +whitelist_externals = + flake8 extras = lint commands = - flake8 + flake8 {posargs} [testenv:pylint] basepython = python3 |
