diff --git a/CHANGES b/CHANGES index 80b6ec45b..b4fd529b3 100644 --- a/CHANGES +++ b/CHANGES @@ -119,6 +119,7 @@ Features added info-field-list * #8514: autodoc: Default values of overloaded functions are taken from actual implementation if they're ellipsis +* #8775: autodoc: Support type union operator (PEP-604) in Python 3.10 or above * #8619: html: kbd role generates customizable HTML tags for compound keys * #8634: html: Allow to change the order of JS/CSS via ``priority`` parameter for :meth:`Sphinx.add_js_file()` and :meth:`Sphinx.add_css_file()` @@ -135,6 +136,7 @@ Features added * #8004: napoleon: Type definitions in Google style docstrings are rendered as references when :confval:`napoleon_preprocess_types` enabled * #6241: mathjax: Include mathjax.js only on the document using equations +* #8775: py domain: Support type union operator (PEP-604) * #8651: std domain: cross-reference for a rubric having inline item is broken * #7642: std domain: Optimize case-insensitive match of term * #8681: viewcode: Support incremental build @@ -158,6 +160,8 @@ Bugs fixed contains invalid type comments * #8693: autodoc: Default values for overloaded functions are rendered as string * #8134: autodoc: crashes when mocked decorator takes arguments +* #8800: autodoc: Uninitialized attributes in superclass are recognized as + undocumented * #8306: autosummary: mocked modules are documented as empty page when using :recursive: option * #8232: graphviz: Image node is not rendered if graph file is in subdirectory @@ -168,13 +172,16 @@ Bugs fixed * #8123: html search: fix searching for terms containing + (Requires a custom search language that does not split on +) * #8665: html theme: Could not override globaltoc_maxdepth in theme.conf +* #8446: html: consecutive spaces are displayed as single space * #8745: i18n: crashes with KeyError when translation message adds a new auto footnote reference * #4304: linkcheck: Fix race condition that could lead to checking the availability of the same URL twice +* #8791: linkcheck: The docname for each hyperlink is not displayed * #7118: sphinx-quickstart: questionare got Mojibake if libreadline unavailable * #8094: texinfo: image files on the different directory with document are not copied +* #8782: todo: Cross references in todolist get broken * #8720: viewcode: module pages are generated for epub on incremental build * #8704: viewcode: anchors are generated in incremental build after singlehtml * #8756: viewcode: highlighted code is generated even if not referenced @@ -194,6 +201,8 @@ Bugs fixed :confval:`numfig` is not True * #8442: LaTeX: some indexed terms are ignored when using xelatex engine (or pdflatex and :confval:`latex_use_xindy` set to True) with memoir class +* #8750: LaTeX: URLs as footnotes fail to show in PDF if originating from + inside function type signatures * #8780: LaTeX: long words in narrow columns may not be hyphenated * #8788: LaTeX: ``\titleformat`` last argument in sphinx.sty should be bracketed, not braced (and is anyhow not needed) @@ -222,6 +231,8 @@ Bugs fixed * #8655: autodoc: Failed to generate document if target module contains an object that raises an exception on ``hasattr()`` * C, ``expr`` role should start symbol lookup in the current scope. +* #8796: LaTeX: potentially critical low level TeX coding mistake has gone + unnoticed so far Testing -------- diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 0283fcb7c..de3416c47 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -70,13 +70,14 @@ Project information The author name(s) of the document. The default value is ``'unknown'``. .. confval:: copyright -.. confval:: project_copyright A copyright statement in the style ``'2008, Author Name'``. - .. versionchanged:: 3.5 +.. confval:: project_copyright - As an alias, ``project_copyright`` is also allowed. + An alias of :confval:`copyright`. + + .. versionadded:: 3.5 .. confval:: version diff --git a/sphinx/builders/linkcheck.py b/sphinx/builders/linkcheck.py index 9a5ca6041..c61ba5cfc 100644 --- a/sphinx/builders/linkcheck.py +++ b/sphinx/builders/linkcheck.py @@ -108,11 +108,11 @@ class CheckExternalLinksBuilder(DummyBuilder): def init(self) -> None: self.hyperlinks = {} # type: Dict[str, Hyperlink] - self.to_ignore = [re.compile(x) for x in self.app.config.linkcheck_ignore] + self.to_ignore = [re.compile(x) for x in self.config.linkcheck_ignore] self.anchors_ignore = [re.compile(x) - for x in self.app.config.linkcheck_anchors_ignore] + for x in self.config.linkcheck_anchors_ignore] self.auth = [(re.compile(pattern), auth_info) for pattern, auth_info - in self.app.config.linkcheck_auth] + in self.config.linkcheck_auth] self._good = set() # type: Set[str] self._broken = {} # type: Dict[str, str] self._redirected = {} # type: Dict[str, Tuple[str, int]] @@ -124,13 +124,13 @@ class CheckExternalLinksBuilder(DummyBuilder): self.wqueue = queue.PriorityQueue() # type: queue.PriorityQueue self.rqueue = queue.Queue() # type: queue.Queue self.workers = [] # type: List[threading.Thread] - for i in range(self.app.config.linkcheck_workers): + for i in range(self.config.linkcheck_workers): thread = threading.Thread(target=self.check_thread, daemon=True) thread.start() self.workers.append(thread) @property - def good(self): + def good(self) -> Set[str]: warnings.warn( "%s.%s is deprecated." % (self.__class__.__name__, "good"), RemovedInSphinx50Warning, @@ -139,7 +139,7 @@ class CheckExternalLinksBuilder(DummyBuilder): return self._good @property - def broken(self): + def broken(self) -> Dict[str, str]: warnings.warn( "%s.%s is deprecated." % (self.__class__.__name__, "broken"), RemovedInSphinx50Warning, @@ -148,7 +148,7 @@ class CheckExternalLinksBuilder(DummyBuilder): return self._broken @property - def redirected(self): + def redirected(self) -> Dict[str, Tuple[str, int]]: warnings.warn( "%s.%s is deprecated." % (self.__class__.__name__, "redirected"), RemovedInSphinx50Warning, @@ -158,8 +158,8 @@ class CheckExternalLinksBuilder(DummyBuilder): def check_thread(self) -> None: kwargs = {} - if self.app.config.linkcheck_timeout: - kwargs['timeout'] = self.app.config.linkcheck_timeout + if self.config.linkcheck_timeout: + kwargs['timeout'] = self.config.linkcheck_timeout def get_request_headers() -> Dict: url = urlparse(uri) @@ -205,9 +205,9 @@ class CheckExternalLinksBuilder(DummyBuilder): kwargs['headers'] = get_request_headers() try: - if anchor and self.app.config.linkcheck_anchors: + if anchor and self.config.linkcheck_anchors: # Read the whole document and see if #anchor exists - response = requests.get(req_url, stream=True, config=self.app.config, + response = requests.get(req_url, stream=True, config=self.config, auth=auth_info, **kwargs) response.raise_for_status() found = check_anchor(response, unquote(anchor)) @@ -219,7 +219,7 @@ class CheckExternalLinksBuilder(DummyBuilder): # try a HEAD request first, which should be easier on # the server and the network response = requests.head(req_url, allow_redirects=True, - config=self.app.config, auth=auth_info, + config=self.config, auth=auth_info, **kwargs) response.raise_for_status() except (HTTPError, TooManyRedirects) as err: @@ -228,7 +228,7 @@ class CheckExternalLinksBuilder(DummyBuilder): # retry with GET request if that fails, some servers # don't like HEAD requests. response = requests.get(req_url, stream=True, - config=self.app.config, + config=self.config, auth=auth_info, **kwargs) response.raise_for_status() except HTTPError as err: @@ -297,7 +297,7 @@ class CheckExternalLinksBuilder(DummyBuilder): return 'ignored', '', 0 # need to actually check the URI - for _ in range(self.app.config.linkcheck_retries): + for _ in range(self.config.linkcheck_retries): status, info, code = check_uri() if status != "broken": break @@ -360,7 +360,7 @@ class CheckExternalLinksBuilder(DummyBuilder): next_check = time.time() + delay netloc = urlparse(response.url).netloc if next_check is None: - max_delay = self.app.config.linkcheck_rate_limit_timeout + max_delay = self.config.linkcheck_rate_limit_timeout try: rate_limit = self.rate_limits[netloc] except KeyError: @@ -390,7 +390,7 @@ class CheckExternalLinksBuilder(DummyBuilder): self.write_linkstat(linkstat) return if lineno: - logger.info('(line %4d) ', lineno, nonl=True) + logger.info('(%16s: line %4d) ', docname, lineno, nonl=True) if status == 'ignored': if info: logger.info(darkgray('-ignored- ') + uri + ': ' + info) diff --git a/sphinx/domains/python.py b/sphinx/domains/python.py index c07c31e87..80028e2b2 100644 --- a/sphinx/domains/python.py +++ b/sphinx/domains/python.py @@ -101,17 +101,19 @@ def _parse_annotation(annotation: str, env: BuildEnvironment = None) -> List[Nod def unparse(node: ast.AST) -> List[Node]: if isinstance(node, ast.Attribute): return [nodes.Text("%s.%s" % (unparse(node.value)[0], node.attr))] - elif isinstance(node, ast.Constant): # type: ignore - if node.value is Ellipsis: - return [addnodes.desc_sig_punctuation('', "...")] - else: - return [nodes.Text(node.value)] + elif isinstance(node, ast.BinOp): + result = unparse(node.left) # type: List[Node] + result.extend(unparse(node.op)) + result.extend(unparse(node.right)) + return result + elif isinstance(node, ast.BitOr): + return [nodes.Text(' '), addnodes.desc_sig_punctuation('', '|'), nodes.Text(' ')] elif isinstance(node, ast.Expr): return unparse(node.value) elif isinstance(node, ast.Index): return unparse(node.value) elif isinstance(node, ast.List): - result = [addnodes.desc_sig_punctuation('', '[')] # type: List[Node] + result = [addnodes.desc_sig_punctuation('', '[')] for elem in node.elts: result.extend(unparse(elem)) result.append(addnodes.desc_sig_punctuation('', ', ')) @@ -157,7 +159,7 @@ def _parse_annotation(annotation: str, env: BuildEnvironment = None) -> List[Nod tree = ast_parse(annotation) result = unparse(tree) for i, node in enumerate(result): - if isinstance(node, nodes.Text): + if isinstance(node, nodes.Text) and node.strip(): result[i] = type_to_xref(str(node), env) return result except SyntaxError: diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index b4d5a4581..699787fb6 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -1012,10 +1012,6 @@ class ModuleDocumenter(Documenter): try: if not self.options.ignore_module_all: self.__all__ = inspect.getall(self.object) - except AttributeError as exc: - # __all__ raises an error. - logger.warning(__('%s.__all__ raises an error. Ignored: %r'), - (self.fullname, exc), type='autodoc') except ValueError as exc: # invalid __all__ found. logger.warning(__('__all__ should be a list of strings, not %r ' @@ -1056,14 +1052,11 @@ class ModuleDocumenter(Documenter): continue # annotation only member (ex. attr: int) - try: - for name in inspect.getannotations(self.object): - if name not in members: - docstring = attr_docs.get(('', name), []) - members[name] = ObjectMember(name, INSTANCEATTR, - docstring="\n".join(docstring)) - except AttributeError: - pass + for name in inspect.getannotations(self.object): + if name not in members: + docstring = attr_docs.get(('', name), []) + members[name] = ObjectMember(name, INSTANCEATTR, + docstring="\n".join(docstring)) return members @@ -1890,16 +1883,16 @@ class DataDocumenter(GenericAliasMixin, NewTypeMixin, TypeVarMixin, def update_annotations(self, parent: Any) -> None: """Update __annotations__ to support type_comment and so on.""" - try: - annotations = dict(inspect.getannotations(parent)) - parent.__annotations__ = annotations + annotations = dict(inspect.getannotations(parent)) + parent.__annotations__ = annotations + try: analyzer = ModuleAnalyzer.for_module(self.modname) analyzer.analyze() for (classname, attrname), annotation in analyzer.annotations.items(): if classname == '' and attrname not in annotations: annotations[attrname] = annotation - except AttributeError: + except PycodeError: pass def import_object(self, raiseerror: bool = False) -> bool: @@ -2212,7 +2205,7 @@ class SlotsMixin(DataDocumenterMixinBase): return True else: return False - except (AttributeError, ValueError, TypeError): + except (ValueError, TypeError): return False def import_object(self, raiseerror: bool = False) -> bool: @@ -2238,7 +2231,7 @@ class SlotsMixin(DataDocumenterMixinBase): return [docstring] else: return [] - except (AttributeError, ValueError) as exc: + except ValueError as exc: logger.warning(__('Invalid __slots__ found on %s. Ignored.'), (self.parent.__qualname__, exc), type='autodoc') return [] @@ -2429,8 +2422,6 @@ class AttributeDocumenter(GenericAliasMixin, NewTypeMixin, SlotsMixin, # type: annotations[attrname] = annotation except (AttributeError, PycodeError): pass - except AttributeError: - pass except TypeError: # Failed to set __annotations__ (built-in, extensions, etc.) pass @@ -2484,22 +2475,19 @@ class AttributeDocumenter(GenericAliasMixin, NewTypeMixin, SlotsMixin, # type: pass def get_attribute_comment(self, parent: Any, attrname: str) -> Optional[List[str]]: - try: - for cls in inspect.getmro(parent): - try: - module = safe_getattr(cls, '__module__') - qualname = safe_getattr(cls, '__qualname__') + for cls in inspect.getmro(parent): + try: + module = safe_getattr(cls, '__module__') + qualname = safe_getattr(cls, '__qualname__') - analyzer = ModuleAnalyzer.for_module(module) - analyzer.analyze() - if qualname and self.objpath: - key = (qualname, attrname) - if key in analyzer.attr_docs: - return list(analyzer.attr_docs[key]) - except (AttributeError, PycodeError): - pass - except (AttributeError, PycodeError): - pass + analyzer = ModuleAnalyzer.for_module(module) + analyzer.analyze() + if qualname and self.objpath: + key = (qualname, attrname) + if key in analyzer.attr_docs: + return list(analyzer.attr_docs[key]) + except (AttributeError, PycodeError): + pass return None diff --git a/sphinx/ext/autodoc/importer.py b/sphinx/ext/autodoc/importer.py index a60feed7e..666039163 100644 --- a/sphinx/ext/autodoc/importer.py +++ b/sphinx/ext/autodoc/importer.py @@ -156,12 +156,9 @@ def get_module_members(module: Any) -> List[Tuple[str, Any]]: continue # annotation only member (ex. attr: int) - try: - for name in getannotations(module): - if name not in members: - members[name] = (name, INSTANCEATTR) - except AttributeError: - pass + for name in getannotations(module): + if name not in members: + members[name] = (name, INSTANCEATTR) return sorted(list(members.values())) @@ -202,7 +199,7 @@ def get_object_members(subject: Any, objpath: List[str], attrgetter: Callable, for name in __slots__: members[name] = Attribute(name, True, SLOTSATTR) - except (AttributeError, TypeError, ValueError): + except (TypeError, ValueError): pass # other members @@ -218,13 +215,10 @@ def get_object_members(subject: Any, objpath: List[str], attrgetter: Callable, # annotation only member (ex. attr: int) for i, cls in enumerate(getmro(subject)): - try: - for name in getannotations(cls): - name = unmangle(cls, name) - if name and name not in members: - members[name] = Attribute(name, i == 0, INSTANCEATTR) - except AttributeError: - pass + for name in getannotations(cls): + name = unmangle(cls, name) + if name and name not in members: + members[name] = Attribute(name, i == 0, INSTANCEATTR) if analyzer: # append instance attributes (cf. self.attr1) if analyzer knows @@ -267,7 +261,7 @@ def get_class_members(subject: Any, objpath: List[str], attrgetter: Callable for name, docstring in __slots__.items(): members[name] = ObjectMember(name, SLOTSATTR, class_=subject, docstring=docstring) - except (AttributeError, TypeError, ValueError): + except (TypeError, ValueError): pass # other members @@ -288,27 +282,35 @@ def get_class_members(subject: Any, objpath: List[str], attrgetter: Callable try: for cls in getmro(subject): - # annotation only member (ex. attr: int) - try: - for name in getannotations(cls): - name = unmangle(cls, name) - if name and name not in members: - members[name] = ObjectMember(name, INSTANCEATTR, class_=cls) - except AttributeError: - pass - - # append instance attributes (cf. self.attr1) if analyzer knows try: modname = safe_getattr(cls, '__module__') qualname = safe_getattr(cls, '__qualname__') analyzer = ModuleAnalyzer.for_module(modname) analyzer.analyze() + except AttributeError: + qualname = None + analyzer = None + except PycodeError: + analyzer = None + + # annotation only member (ex. attr: int) + for name in getannotations(cls): + name = unmangle(cls, name) + if name and name not in members: + if analyzer and (qualname, name) in analyzer.attr_docs: + docstring = '\n'.join(analyzer.attr_docs[qualname, name]) + else: + docstring = None + + members[name] = ObjectMember(name, INSTANCEATTR, class_=cls, + docstring=docstring) + + # append instance attributes (cf. self.attr1) if analyzer knows + if analyzer: for (ns, name), docstring in analyzer.attr_docs.items(): if ns == qualname and name not in members: members[name] = ObjectMember(name, INSTANCEATTR, class_=cls, docstring='\n'.join(docstring)) - except (AttributeError, PycodeError): - pass except AttributeError: pass diff --git a/sphinx/ext/todo.py b/sphinx/ext/todo.py index 0c9a054f4..e0fdd31e0 100644 --- a/sphinx/ext/todo.py +++ b/sphinx/ext/todo.py @@ -19,6 +19,7 @@ from docutils.parsers.rst import directives from docutils.parsers.rst.directives.admonitions import BaseAdmonition import sphinx +from sphinx import addnodes from sphinx.application import Sphinx from sphinx.domains import Domain from sphinx.environment import BuildEnvironment @@ -123,12 +124,12 @@ class TodoListProcessor: self.config = app.config self.env = app.env self.domain = cast(TodoDomain, app.env.get_domain('todo')) + self.document = new_document('') self.process(doctree, docname) def process(self, doctree: nodes.document, docname: str) -> None: todos = sum(self.domain.todos.values(), []) # type: List[todo_node] - document = new_document('') for node in doctree.traverse(todolist): if not self.config.todo_include_todos: node.parent.remove(node) @@ -144,12 +145,7 @@ class TodoListProcessor: new_todo = todo.deepcopy() new_todo['ids'].clear() - # (Recursively) resolve references in the todo content - # - # Note: To resolve references, it is needed to wrap it with document node - document += new_todo - self.env.resolve_references(document, todo['docname'], self.builder) - document.remove(new_todo) + self.resolve_reference(new_todo, docname) content.append(new_todo) todo_ref = self.create_todo_reference(todo, docname) @@ -185,6 +181,17 @@ class TodoListProcessor: return para + def resolve_reference(self, todo: todo_node, docname: str) -> None: + """Resolve references in the todo content.""" + for node in todo.traverse(addnodes.pending_xref): + if 'refdoc' in node: + node['refdoc'] = docname + + # Note: To resolve references, it is needed to wrap it with document node + self.document += todo + self.env.resolve_references(self.document, docname, self.builder) + self.document.remove(todo) + def visit_todo_node(self: HTMLTranslator, node: todo_node) -> None: if self.config.todo_include_todos: diff --git a/sphinx/texinputs/sphinxpackagefootnote.sty b/sphinx/texinputs/sphinxpackagefootnote.sty index 1de38867c..25c8e2627 100644 --- a/sphinx/texinputs/sphinxpackagefootnote.sty +++ b/sphinx/texinputs/sphinxpackagefootnote.sty @@ -1,6 +1,6 @@ \NeedsTeXFormat{LaTeX2e} \ProvidesPackage{sphinxpackagefootnote}% - [2021/01/26 v1.1b footnotehyper adapted to sphinx (Sphinx team)] + [2021/01/29 v1.1c footnotehyper adapted to sphinx (Sphinx team)] % Provides support for this output mark-up from Sphinx latex writer: % - footnote environment % - savenotes environment (table templates) @@ -8,7 +8,7 @@ % %% %% Package: sphinxpackagefootnote -%% Version: based on footnotehyper.sty 2021/01/26 v1.1b +%% Version: based on footnotehyper.sty 2021/01/29 v1.1c %% as available at https://www.ctan.org/pkg/footnotehyper %% License: the one applying to Sphinx %% @@ -25,6 +25,7 @@ \DeclareOption*{\PackageWarning{sphinxpackagefootnote}{Option `\CurrentOption' is unknown}}% \ProcessOptions\relax \newbox\FNH@notes +\newtoks\FNH@toks % 1.1c \newdimen\FNH@width \let\FNH@colwidth\columnwidth \newif\ifFNH@savingnotes @@ -32,6 +33,7 @@ \let\FNH@latex@footnote \footnote \let\FNH@latex@footnotetext\footnotetext \let\FNH@H@@footnotetext \@footnotetext + \let\FNH@H@@mpfootnotetext \@mpfootnotetext \newenvironment{savenotes} {\FNH@savenotes\ignorespaces}{\FNH@spewnotes\ignorespacesafterend}% \let\spewnotes \FNH@spewnotes @@ -42,6 +44,7 @@ \@ifpackageloaded{hyperref} {\ifHy@hyperfootnotes \let\FNH@H@@footnotetext\H@@footnotetext + \let\FNH@H@@mpfootnotetext\H@@mpfootnotetext \else \let\FNH@hyper@fntext\FNH@nohyp@fntext \fi}% @@ -116,14 +119,33 @@ \fi }% \def\FNH@spewnotes {% - \endgroup + \if@endpe\ifx\par\@@par\FNH@toks{}\else + \FNH@toks\expandafter{\expandafter + \def\expandafter\par\expandafter{\par}\@endpetrue}% + \expandafter\expandafter\expandafter + \FNH@toks + \expandafter\expandafter\expandafter + {\expandafter\the\expandafter\FNH@toks + \expandafter\def\expandafter\@par\expandafter{\@par}}% + \expandafter\expandafter\expandafter + \FNH@toks + \expandafter\expandafter\expandafter + {\expandafter\the\expandafter\FNH@toks + \expandafter\everypar\expandafter{\the\everypar}}\fi + \else\FNH@toks{}\fi + \expandafter + \endgroup\the\FNH@toks \ifFNH@savingnotes\else \ifvoid\FNH@notes\else \begingroup \let\@makefntext\@empty \let\@finalstrut\@gobble \let\rule\@gobbletwo - \FNH@H@@footnotetext{\unvbox\FNH@notes}% + \ifx\@footnotetext\@mpfootnotetext + \expandafter\FNH@H@@mpfootnotetext + \else + \expandafter\FNH@H@@footnotetext + \fi{\unvbox\FNH@notes}% \endgroup \fi \fi diff --git a/sphinx/util/inspect.py b/sphinx/util/inspect.py index f797339cf..3cf1c6824 100644 --- a/sphinx/util/inspect.py +++ b/sphinx/util/inspect.py @@ -145,7 +145,6 @@ def getall(obj: Any) -> Optional[Sequence[str]]: """Get __all__ attribute of the module as dict. Return None if given *obj* does not have __all__. - Raises AttributeError if given *obj* raises an error on accessing __all__. Raises ValueError if given *obj* have invalid __all__. """ __all__ = safe_getattr(obj, '__all__', None) @@ -159,10 +158,7 @@ def getall(obj: Any) -> Optional[Sequence[str]]: def getannotations(obj: Any) -> Mapping[str, Any]: - """Get __annotations__ from given *obj* safely. - - Raises AttributeError if given *obj* raises an error on accessing __attribute__. - """ + """Get __annotations__ from given *obj* safely.""" __annotations__ = safe_getattr(obj, '__annotations__', None) if isinstance(__annotations__, Mapping): return __annotations__ @@ -171,10 +167,7 @@ def getannotations(obj: Any) -> Mapping[str, Any]: def getmro(obj: Any) -> Tuple["Type", ...]: - """Get __mro__ from given *obj* safely. - - Raises AttributeError if given *obj* raises an error on accessing __mro__. - """ + """Get __mro__ from given *obj* safely.""" __mro__ = safe_getattr(obj, '__mro__', None) if isinstance(__mro__, tuple): return __mro__ @@ -186,7 +179,6 @@ def getslots(obj: Any) -> Optional[Dict]: """Get __slots__ attribute of the class as dict. Return None if gienv *obj* does not have __slots__. - Raises AttributeError if given *obj* raises an error on accessing __slots__. Raises TypeError if given *obj* is not a class. Raises ValueError if given *obj* have invalid __slots__. """ @@ -464,12 +456,7 @@ def is_builtin_class_method(obj: Any, attr_name: str) -> bool: but PyPy implements it by pure Python code. """ try: - mro = inspect.getmro(obj) - except AttributeError: - # no __mro__, assume the object has no methods as we know them - return False - - try: + mro = getmro(obj) cls = next(c for c in mro if attr_name in safe_getattr(c, '__dict__', {})) except StopIteration: return False diff --git a/sphinx/util/typing.py b/sphinx/util/typing.py index ae9a3de28..d2bd03efd 100644 --- a/sphinx/util/typing.py +++ b/sphinx/util/typing.py @@ -30,6 +30,11 @@ else: ref = _ForwardRef(self.arg) return ref._eval_type(globalns, localns) +if sys.version_info > (3, 10): + from types import Union as types_Union +else: + types_Union = None + if False: # For type annotation from typing import Type # NOQA # for python3.5.1 @@ -72,7 +77,9 @@ def get_type_hints(obj: Any, globalns: Dict = None, localns: Dict = None) -> Dic # Failed to evaluate ForwardRef (maybe TYPE_CHECKING) return safe_getattr(obj, '__annotations__', {}) except TypeError: - return {} + # Invalid object is given. But try to get __annotations__ as a fallback for + # the code using type union operator (PEP 604) in python 3.9 or below. + return safe_getattr(obj, '__annotations__', {}) except KeyError: # a broken class found (refs: https://github.com/sphinx-doc/sphinx/issues/8084) return {} @@ -97,6 +104,12 @@ def restify(cls: Optional["Type"]) -> str: return ':class:`struct.Struct`' elif inspect.isNewType(cls): return ':class:`%s`' % cls.__name__ + elif types_Union and isinstance(cls, types_Union): + if len(cls.__args__) > 1 and None in cls.__args__: + args = ' | '.join(restify(a) for a in cls.__args__ if a) + return 'Optional[%s]' % args + else: + return ' | '.join(restify(a) for a in cls.__args__) elif cls.__module__ in ('__builtin__', 'builtins'): return ':class:`%s`' % cls.__name__ else: @@ -290,6 +303,8 @@ def _stringify_py37(annotation: Any) -> str: elif hasattr(annotation, '__origin__'): # instantiated generic provided by a user qualname = stringify(annotation.__origin__) + elif types_Union and isinstance(annotation, types_Union): # types.Union (for py3.10+) + qualname = 'types.Union' else: # we weren't able to extract the base type, appending arguments would # only make them appear twice @@ -309,6 +324,12 @@ def _stringify_py37(annotation: Any) -> str: else: args = ', '.join(stringify(a) for a in annotation.__args__) return 'Union[%s]' % args + elif qualname == 'types.Union': + if len(annotation.__args__) > 1 and None in annotation.__args__: + args = ' | '.join(stringify(a) for a in annotation.__args__ if a) + return 'Optional[%s]' % args + else: + return ' | '.join(stringify(a) for a in annotation.__args__) elif qualname == 'Callable': args = ', '.join(stringify(a) for a in annotation.__args__[:-1]) returns = stringify(annotation.__args__[-1]) diff --git a/sphinx/writers/html.py b/sphinx/writers/html.py index cf89bd2d1..64d1e8969 100644 --- a/sphinx/writers/html.py +++ b/sphinx/writers/html.py @@ -116,8 +116,10 @@ class HTMLTranslator(SphinxTranslator, BaseTranslator): def visit_desc_signature(self, node: Element) -> None: # the id is set automatically self.body.append(self.starttag(node, 'dt')) + self.protect_literal_text += 1 def depart_desc_signature(self, node: Element) -> None: + self.protect_literal_text -= 1 if not node.get('is_multiline'): self.add_permalink_ref(node, _('Permalink to this definition')) self.body.append('\n') diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py index 3ae6a914c..b354bf804 100644 --- a/sphinx/writers/html5.py +++ b/sphinx/writers/html5.py @@ -87,8 +87,10 @@ class HTML5Translator(SphinxTranslator, BaseTranslator): def visit_desc_signature(self, node: Element) -> None: # the id is set automatically self.body.append(self.starttag(node, 'dt')) + self.protect_literal_text += 1 def depart_desc_signature(self, node: Element) -> None: + self.protect_literal_text -= 1 if not node.get('is_multiline'): self.add_permalink_ref(node, _('Permalink to this definition')) self.body.append('\n') diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py index 4698c1e79..12036dcd9 100644 --- a/sphinx/writers/latex.py +++ b/sphinx/writers/latex.py @@ -702,12 +702,18 @@ class LaTeXTranslator(SphinxTranslator): self.body.append(self.context.pop()) def visit_desc(self, node: Element) -> None: - self.body.append('\n\n\\begin{fulllineitems}\n') + if self.config.latex_show_urls == 'footnote': + self.body.append('\n\n\\begin{savenotes}\\begin{fulllineitems}\n') + else: + self.body.append('\n\n\\begin{fulllineitems}\n') if self.table: self.table.has_problematic = True def depart_desc(self, node: Element) -> None: - self.body.append('\n\\end{fulllineitems}\n\n') + if self.config.latex_show_urls == 'footnote': + self.body.append('\n\\end{fulllineitems}\\end{savenotes}\n\n') + else: + self.body.append('\n\\end{fulllineitems}\n\n') def _visit_signature_line(self, node: Element) -> None: for child in node: diff --git a/tests/roots/test-ext-autodoc/target/pep604.py b/tests/roots/test-ext-autodoc/target/pep604.py new file mode 100644 index 000000000..9b1f94a59 --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/pep604.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +attr: int | str #: docstring + + +def sum(x: int | str, y: int | str) -> int | str: + """docstring""" + + +class Foo: + """docstring""" + + attr: int | str #: docstring + + def meth(self, x: int | str, y: int | str) -> int | str: + """docstring""" diff --git a/tests/roots/test-ext-autodoc/target/uninitialized_attributes.py b/tests/roots/test-ext-autodoc/target/uninitialized_attributes.py new file mode 100644 index 000000000..e0f229c76 --- /dev/null +++ b/tests/roots/test-ext-autodoc/target/uninitialized_attributes.py @@ -0,0 +1,8 @@ +class Base: + attr1: int #: docstring + attr2: str + + +class Derived(Base): + attr3: int #: docstring + attr4: str diff --git a/tests/roots/test-footnotes/index.rst b/tests/roots/test-footnotes/index.rst index 226c868a9..d15a27b72 100644 --- a/tests/roots/test-footnotes/index.rst +++ b/tests/roots/test-footnotes/index.rst @@ -169,3 +169,9 @@ Footnote in term [#]_ def bar(x,y): return x+y + +The section with an object description +====================================== + +.. py:function:: dummy(N) + :noindex: diff --git a/tests/test_build_html.py b/tests/test_build_html.py index d337f4337..3df65245c 100644 --- a/tests/test_build_html.py +++ b/tests/test_build_html.py @@ -178,8 +178,8 @@ def test_html4_output(app, status, warning): ], 'autodoc.html': [ (".//dl[@class='py class']/dt[@id='autodoc_target.Class']", ''), - (".//dl[@class='py function']/dt[@id='autodoc_target.function']/em/span", r'\*\*'), - (".//dl[@class='py function']/dt[@id='autodoc_target.function']/em/span", r'kwds'), + (".//dl[@class='py function']/dt[@id='autodoc_target.function']/em/span/span", r'\*\*'), + (".//dl[@class='py function']/dt[@id='autodoc_target.function']/em/span/span", r'kwds'), (".//dd/p", r'Return spam\.'), ], 'extapi.html': [ @@ -279,8 +279,10 @@ def test_html4_output(app, status, warning): 'objects.html': [ (".//dt[@id='mod.Cls.meth1']", ''), (".//dt[@id='errmod.Error']", ''), - (".//dt/code", r'long\(parameter,\s* list\)'), - (".//dt/code", 'another one'), + (".//dt/code/span", r'long\(parameter,'), + (".//dt/code/span", r'list\)'), + (".//dt/code/span", 'another'), + (".//dt/code/span", 'one'), (".//a[@href='#mod.Cls'][@class='reference internal']", ''), (".//dl[@class='std userdesc']", ''), (".//dt[@id='userdesc-myobj']", ''), diff --git a/tests/test_build_latex.py b/tests/test_build_latex.py index 5b1955a0f..63cca61ed 100644 --- a/tests/test_build_latex.py +++ b/tests/test_build_latex.py @@ -828,6 +828,7 @@ def test_latex_show_urls_is_inline(app, status, warning): assert '\\sphinxurl{https://github.com/sphinx-doc/sphinx}\n' in result assert ('\\sphinxhref{mailto:sphinx-dev@googlegroups.com}' '{sphinx\\sphinxhyphen{}dev@googlegroups.com}') in result + assert '\\begin{savenotes}\\begin{fulllineitems}' not in result @pytest.mark.sphinx( @@ -882,6 +883,7 @@ def test_latex_show_urls_is_footnote(app, status, warning): assert ('\\sphinxurl{https://github.com/sphinx-doc/sphinx}\n' in result) assert ('\\sphinxhref{mailto:sphinx-dev@googlegroups.com}' '{sphinx\\sphinxhyphen{}dev@googlegroups.com}\n') in result + assert '\\begin{savenotes}\\begin{fulllineitems}' in result @pytest.mark.sphinx( @@ -925,6 +927,7 @@ def test_latex_show_urls_is_no(app, status, warning): assert ('\\sphinxurl{https://github.com/sphinx-doc/sphinx}\n' in result) assert ('\\sphinxhref{mailto:sphinx-dev@googlegroups.com}' '{sphinx\\sphinxhyphen{}dev@googlegroups.com}\n') in result + assert '\\begin{savenotes}\\begin{fulllineitems}' not in result @pytest.mark.sphinx( diff --git a/tests/test_domain_py.py b/tests/test_domain_py.py index a1e37fa5e..448c2c954 100644 --- a/tests/test_domain_py.py +++ b/tests/test_domain_py.py @@ -431,6 +431,20 @@ def test_pyfunction_with_number_literals(app): [nodes.inline, "1_6_0"])])]) +def test_pyfunction_with_union_type_operator(app): + text = ".. py:function:: hello(age: int | None)" + doctree = restructuredtext.parse(app, text) + assert_node(doctree[1][0][1], + [desc_parameterlist, ([desc_parameter, ([desc_sig_name, "age"], + [desc_sig_punctuation, ":"], + " ", + [desc_sig_name, ([pending_xref, "int"], + " ", + [desc_sig_punctuation, "|"], + " ", + [pending_xref, "None"])])])]) + + def test_optional_pyfunction_signature(app): text = ".. py:function:: compile(source [, filename [, symbol]]) -> ast object" doctree = restructuredtext.parse(app, text) @@ -498,6 +512,20 @@ def test_pydata_signature_old(app): domain="py", objtype="data", noindex=False) +def test_pydata_with_union_type_operator(app): + text = (".. py:data:: version\n" + " :type: int | str") + doctree = restructuredtext.parse(app, text) + assert_node(doctree[1][0], + ([desc_name, "version"], + [desc_annotation, (": ", + [pending_xref, "int"], + " ", + [desc_sig_punctuation, "|"], + " ", + [pending_xref, "str"])])) + + def test_pyobject_prefix(app): text = (".. py:class:: Foo\n" "\n" diff --git a/tests/test_ext_autodoc.py b/tests/test_ext_autodoc.py index aed5fa5bb..73da31427 100644 --- a/tests/test_ext_autodoc.py +++ b/tests/test_ext_autodoc.py @@ -2226,6 +2226,50 @@ def test_name_mangling(app): ] +@pytest.mark.skipif(sys.version_info < (3, 7), reason='python 3.7+ is required.') +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_type_union_operator(app): + options = {'members': None} + actual = do_autodoc(app, 'module', 'target.pep604', options) + assert list(actual) == [ + '', + '.. py:module:: target.pep604', + '', + '', + '.. py:class:: Foo()', + ' :module: target.pep604', + '', + ' docstring', + '', + '', + ' .. py:attribute:: Foo.attr', + ' :module: target.pep604', + ' :type: int | str', + '', + ' docstring', + '', + '', + ' .. py:method:: Foo.meth(x: int | str, y: int | str) -> int | str', + ' :module: target.pep604', + '', + ' docstring', + '', + '', + '.. py:data:: attr', + ' :module: target.pep604', + ' :type: int | str', + '', + ' docstring', + '', + '', + '.. py:function:: sum(x: int | str, y: int | str) -> int | str', + ' :module: target.pep604', + '', + ' docstring', + '', + ] + + @pytest.mark.skipif(sys.version_info < (3, 6), reason='python 3.6+ is required.') @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_hide_value(app): diff --git a/tests/test_ext_autodoc_autoclass.py b/tests/test_ext_autodoc_autoclass.py index 488b72263..74ddb02f9 100644 --- a/tests/test_ext_autodoc_autoclass.py +++ b/tests/test_ext_autodoc_autoclass.py @@ -106,6 +106,73 @@ def test_inherited_instance_variable(app): ] +@pytest.mark.skipif(sys.version_info < (3, 6), reason='py36+ is available since python3.6.') +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_uninitialized_attributes(app): + options = {"members": None, + "inherited-members": True} + actual = do_autodoc(app, 'class', 'target.uninitialized_attributes.Derived', options) + assert list(actual) == [ + '', + '.. py:class:: Derived()', + ' :module: target.uninitialized_attributes', + '', + '', + ' .. py:attribute:: Derived.attr1', + ' :module: target.uninitialized_attributes', + ' :type: int', + '', + ' docstring', + '', + '', + ' .. py:attribute:: Derived.attr3', + ' :module: target.uninitialized_attributes', + ' :type: int', + '', + ' docstring', + '', + ] + + +@pytest.mark.skipif(sys.version_info < (3, 6), reason='py36+ is available since python3.6.') +@pytest.mark.sphinx('html', testroot='ext-autodoc') +def test_undocumented_uninitialized_attributes(app): + options = {"members": None, + "inherited-members": True, + "undoc-members": True} + actual = do_autodoc(app, 'class', 'target.uninitialized_attributes.Derived', options) + assert list(actual) == [ + '', + '.. py:class:: Derived()', + ' :module: target.uninitialized_attributes', + '', + '', + ' .. py:attribute:: Derived.attr1', + ' :module: target.uninitialized_attributes', + ' :type: int', + '', + ' docstring', + '', + '', + ' .. py:attribute:: Derived.attr2', + ' :module: target.uninitialized_attributes', + ' :type: str', + '', + '', + ' .. py:attribute:: Derived.attr3', + ' :module: target.uninitialized_attributes', + ' :type: int', + '', + ' docstring', + '', + '', + ' .. py:attribute:: Derived.attr4', + ' :module: target.uninitialized_attributes', + ' :type: str', + '', + ] + + def test_decorators(app): actual = do_autodoc(app, 'class', 'target.decorator.Baz') assert list(actual) == [ diff --git a/tests/test_ext_intersphinx.py b/tests/test_ext_intersphinx.py index c4fddf029..a87677525 100644 --- a/tests/test_ext_intersphinx.py +++ b/tests/test_ext_intersphinx.py @@ -250,10 +250,10 @@ def test_missing_reference_cppdomain(tempdir, app, status, warning): 'Bar' in html) assert ('foons' in html) + ' title="(in foo v2.0)">foons' in html) assert ('bartype' in html) + ' title="(in foo v2.0)">bartype' in html) def test_missing_reference_jsdomain(tempdir, app, status, warning): diff --git a/tests/test_util_typing.py b/tests/test_util_typing.py index 9da505814..927db73fd 100644 --- a/tests/test_util_typing.py +++ b/tests/test_util_typing.py @@ -117,6 +117,13 @@ def test_restify_type_ForwardRef(): assert restify(ForwardRef("myint")) == ":class:`myint`" +@pytest.mark.skipif(sys.version_info < (3, 10), reason='python 3.10+ is required.') +def test_restify_type_union_operator(): + assert restify(int | None) == "Optional[:class:`int`]" # type: ignore + assert restify(int | str) == ":class:`int` | :class:`str`" # type: ignore + assert restify(int | str | None) == "Optional[:class:`int` | :class:`str`]" # type: ignore + + def test_restify_broken_type_hints(): assert restify(BrokenType) == ':class:`tests.test_util_typing.BrokenType`' @@ -206,5 +213,12 @@ def test_stringify_type_hints_alias(): assert stringify(MyTuple) == "Tuple[str, str]" # type: ignore +@pytest.mark.skipif(sys.version_info < (3, 10), reason='python 3.10+ is required.') +def test_stringify_type_union_operator(): + assert stringify(int | None) == "Optional[int]" # type: ignore + assert stringify(int | str) == "int | str" # type: ignore + assert stringify(int | str | None) == "Optional[int | str]" # type: ignore + + def test_stringify_broken_type_hints(): assert stringify(BrokenType) == 'tests.test_util_typing.BrokenType'