mirror of
https://github.com/sphinx-doc/sphinx.git
synced 2025-02-25 18:55:22 -06:00
Drop Python 3.7
This commit is contained in:
parent
7649eb1505
commit
4660b62de0
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@ -13,8 +13,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- python: "3.7"
|
||||
docutils: du15
|
||||
- python: "3.8"
|
||||
docutils: du16
|
||||
- python: "3.9"
|
||||
|
1
CHANGES
1
CHANGES
@ -5,6 +5,7 @@ Dependencies
|
||||
------------
|
||||
|
||||
* #10468: Drop Python 3.6 support
|
||||
* #10470: Drop Python 3.7 support. Patch by Adam Turner
|
||||
|
||||
Incompatible changes
|
||||
--------------------
|
||||
|
@ -12,7 +12,7 @@ Installing Sphinx
|
||||
Overview
|
||||
--------
|
||||
|
||||
Sphinx is written in `Python`__ and supports Python 3.7+. It builds upon the
|
||||
Sphinx is written in `Python`__ and supports Python 3.8+. It builds upon the
|
||||
shoulders of many third-party libraries such as `Docutils`__ and `Jinja`__,
|
||||
which are installed when Sphinx is installed.
|
||||
|
||||
|
@ -13,7 +13,7 @@ urls.Download = "https://pypi.org/project/Sphinx/"
|
||||
urls.Homepage = "https://www.sphinx-doc.org/"
|
||||
urls."Issue tracker" = "https://github.com/sphinx-doc/sphinx/issues"
|
||||
license.text = "BSD"
|
||||
requires-python = ">=3.7"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
# Classifiers list: https://pypi.org/classifiers/
|
||||
classifiers = [
|
||||
@ -30,7 +30,6 @@ classifiers = [
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
@ -89,13 +88,11 @@ lint = [
|
||||
"mypy>=0.981",
|
||||
"sphinx-lint",
|
||||
"docutils-stubs",
|
||||
"types-typed-ast",
|
||||
"types-requests",
|
||||
]
|
||||
test = [
|
||||
"pytest>=4.6",
|
||||
"html5lib",
|
||||
"typed_ast; python_version < '3.8'",
|
||||
"cython",
|
||||
]
|
||||
|
||||
@ -144,7 +141,7 @@ disallow_incomplete_defs = true
|
||||
follow_imports = "skip"
|
||||
ignore_missing_imports = true
|
||||
no_implicit_optional = true
|
||||
python_version = "3.7"
|
||||
python_version = "3.8"
|
||||
show_column_numbers = true
|
||||
show_error_codes = true
|
||||
show_error_context = true
|
||||
|
@ -77,7 +77,7 @@ class TocTree(SphinxDirective):
|
||||
return ret
|
||||
|
||||
def parse_content(self, toctree: addnodes.toctree) -> List[Node]:
|
||||
generated_docnames = frozenset(self.env.domains['std'].initial_data['labels'].keys())
|
||||
generated_docnames = frozenset(self.env.domains['std']._virtual_doc_names)
|
||||
suffixes = self.config.source_suffix
|
||||
|
||||
# glob target documents
|
||||
|
@ -1,9 +1,9 @@
|
||||
"""The Python domain."""
|
||||
|
||||
import ast
|
||||
import builtins
|
||||
import inspect
|
||||
import re
|
||||
import sys
|
||||
import typing
|
||||
from inspect import Parameter
|
||||
from typing import Any, Dict, Iterable, Iterator, List, NamedTuple, Optional, Tuple, Type, cast
|
||||
@ -21,7 +21,6 @@ from sphinx.directives import ObjectDescription
|
||||
from sphinx.domains import Domain, Index, IndexEntry, ObjType
|
||||
from sphinx.environment import BuildEnvironment
|
||||
from sphinx.locale import _, __
|
||||
from sphinx.pycode.ast import ast
|
||||
from sphinx.pycode.ast import parse as ast_parse
|
||||
from sphinx.roles import XRefRole
|
||||
from sphinx.util import logging
|
||||
@ -138,7 +137,7 @@ def _parse_annotation(annotation: str, env: BuildEnvironment) -> List[Node]:
|
||||
return [addnodes.desc_sig_space(),
|
||||
addnodes.desc_sig_punctuation('', '|'),
|
||||
addnodes.desc_sig_space()]
|
||||
elif isinstance(node, ast.Constant): # type: ignore
|
||||
elif isinstance(node, ast.Constant):
|
||||
if node.value is Ellipsis:
|
||||
return [addnodes.desc_sig_punctuation('', "...")]
|
||||
elif isinstance(node.value, bool):
|
||||
@ -204,18 +203,6 @@ def _parse_annotation(annotation: str, env: BuildEnvironment) -> List[Node]:
|
||||
|
||||
return result
|
||||
else:
|
||||
if sys.version_info[:2] <= (3, 7):
|
||||
if isinstance(node, ast.Bytes):
|
||||
return [addnodes.desc_sig_literal_string('', repr(node.s))]
|
||||
elif isinstance(node, ast.Ellipsis):
|
||||
return [addnodes.desc_sig_punctuation('', "...")]
|
||||
elif isinstance(node, ast.NameConstant):
|
||||
return [nodes.Text(node.value)]
|
||||
elif isinstance(node, ast.Num):
|
||||
return [addnodes.desc_sig_literal_string('', repr(node.n))]
|
||||
elif isinstance(node, ast.Str):
|
||||
return [addnodes.desc_sig_literal_string('', repr(node.s))]
|
||||
|
||||
raise SyntaxError # unsupported syntax
|
||||
|
||||
try:
|
||||
|
@ -1,10 +1,9 @@
|
||||
"""The standard domain."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from copy import copy
|
||||
from typing import (TYPE_CHECKING, Any, Callable, Dict, Iterable, Iterator, List, Optional,
|
||||
Tuple, Type, Union, cast)
|
||||
from typing import (TYPE_CHECKING, Any, Callable, Dict, Final, Iterable, Iterator, List,
|
||||
Optional, Tuple, Type, Union, cast)
|
||||
|
||||
from docutils import nodes
|
||||
from docutils.nodes import Element, Node, system_message
|
||||
@ -29,11 +28,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if sys.version_info[:2] >= (3, 8):
|
||||
from typing import Final
|
||||
else:
|
||||
Final = Any
|
||||
|
||||
# RE for option descriptions
|
||||
option_desc_re = re.compile(r'((?:/|--|-|\+)?[^\s=]+)(=?\s*.*)')
|
||||
# RE for grammar tokens
|
||||
@ -589,7 +583,7 @@ class StandardDomain(Domain):
|
||||
'doc': XRefRole(warn_dangling=True, innernodeclass=nodes.inline),
|
||||
}
|
||||
|
||||
initial_data: Final = {
|
||||
initial_data: Final = { # type: ignore[misc]
|
||||
'progoptions': {}, # (program, name) -> docname, labelid
|
||||
'objects': {}, # (type, name) -> docname, labelid
|
||||
'labels': { # labelname -> docname, labelid, sectionname
|
||||
@ -604,6 +598,12 @@ class StandardDomain(Domain):
|
||||
},
|
||||
}
|
||||
|
||||
_virtual_doc_names: Dict[str, Tuple[str, str]] = { # labelname -> docname, sectionname
|
||||
'genindex': ('genindex', _('Index')),
|
||||
'modindex': ('py-modindex', _('Module Index')),
|
||||
'search': ('search', _('Search Page')),
|
||||
}
|
||||
|
||||
dangling_warnings = {
|
||||
'term': 'term not in glossary: %(target)r',
|
||||
'numref': 'undefined label: %(target)r',
|
||||
|
@ -54,7 +54,7 @@ class TocTree:
|
||||
"""
|
||||
if toctree.get('hidden', False) and not includehidden:
|
||||
return None
|
||||
generated_docnames: Dict[str, Tuple[str, str, str]] = self.env.domains['std'].initial_data['labels'].copy() # NoQA: E501
|
||||
generated_docnames: Dict[str, Tuple[str, str]] = self.env.domains['std']._virtual_doc_names.copy() # NoQA: E501
|
||||
|
||||
# For reading the following two helper function, it is useful to keep
|
||||
# in mind the node structure of a toctree (using HTML-like node names
|
||||
@ -141,7 +141,7 @@ class TocTree:
|
||||
# don't show subitems
|
||||
toc = nodes.bullet_list('', item)
|
||||
elif ref in generated_docnames:
|
||||
docname, _, sectionname = generated_docnames[ref]
|
||||
docname, sectionname = generated_docnames[ref]
|
||||
if not title:
|
||||
title = sectionname
|
||||
reference = nodes.reference('', title, internal=True,
|
||||
|
@ -236,7 +236,7 @@ class TocTreeCollector(EnvironmentCollector):
|
||||
|
||||
def assign_figure_numbers(self, env: BuildEnvironment) -> List[str]:
|
||||
"""Assign a figure number to each figure under a numbered toctree."""
|
||||
generated_docnames = frozenset(env.domains['std'].initial_data['labels'].keys())
|
||||
generated_docnames = frozenset(env.domains['std']._virtual_doc_names)
|
||||
|
||||
rewrite_needed = []
|
||||
|
||||
|
@ -6,8 +6,6 @@ and keep them not evaluated for readability.
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import sys
|
||||
from inspect import Parameter
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sphinx.application import Sphinx
|
||||
@ -48,8 +46,6 @@ def get_function_def(obj: Any) -> Optional[ast.FunctionDef]:
|
||||
|
||||
def get_default_value(lines: List[str], position: ast.AST) -> Optional[str]:
|
||||
try:
|
||||
if sys.version_info[:2] <= (3, 7): # only for py38+
|
||||
return None
|
||||
if position.lineno == position.end_lineno:
|
||||
line = lines[position.lineno - 1]
|
||||
return line[position.col_offset:position.end_col_offset]
|
||||
@ -89,18 +85,18 @@ def update_defvalue(app: Sphinx, obj: Any, bound_method: bool) -> None:
|
||||
default = defaults.pop(0)
|
||||
value = get_default_value(lines, default)
|
||||
if value is None:
|
||||
value = ast_unparse(default) # type: ignore
|
||||
value = ast_unparse(default)
|
||||
parameters[i] = param.replace(default=DefaultValue(value))
|
||||
else:
|
||||
default = kw_defaults.pop(0)
|
||||
value = get_default_value(lines, default)
|
||||
if value is None:
|
||||
value = ast_unparse(default) # type: ignore
|
||||
value = ast_unparse(default)
|
||||
parameters[i] = param.replace(default=DefaultValue(value))
|
||||
|
||||
if bound_method and inspect.ismethod(obj):
|
||||
# classmethods
|
||||
cls = inspect.Parameter('cls', Parameter.POSITIONAL_OR_KEYWORD)
|
||||
cls = inspect.Parameter('cls', inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
parameters.insert(0, cls)
|
||||
|
||||
sig = sig.replace(parameters=parameters)
|
||||
|
@ -1,12 +1,12 @@
|
||||
"""Update annotations info of living objects using type_comments."""
|
||||
|
||||
import ast
|
||||
from inspect import Parameter, Signature, getsource
|
||||
from typing import Any, Dict, List, 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, logging
|
||||
@ -34,10 +34,9 @@ def signature_from_ast(node: ast.FunctionDef, bound_method: bool,
|
||||
: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.posonlyargs:
|
||||
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,
|
||||
@ -80,7 +79,7 @@ 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
|
||||
Signature for given *obj*. It requires py38+ or typed_ast module.
|
||||
Signature for given *obj*.
|
||||
"""
|
||||
try:
|
||||
source = getsource(obj)
|
||||
|
@ -2,29 +2,25 @@
|
||||
|
||||
import gettext
|
||||
import locale
|
||||
from collections import UserString, defaultdict
|
||||
from collections import defaultdict
|
||||
from gettext import NullTranslations
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
|
||||
|
||||
|
||||
class _TranslationProxy(UserString):
|
||||
class _TranslationProxy:
|
||||
"""
|
||||
Class for proxy strings from gettext translations. This is a helper for the
|
||||
lazy_* functions from this module.
|
||||
|
||||
The proxy implementation attempts to be as complete as possible, so that
|
||||
the lazy objects should mostly work as expected, for example for sorting.
|
||||
|
||||
This inherits from UserString because some docutils versions use UserString
|
||||
for their Text nodes, which then checks its argument for being either a
|
||||
basestring or UserString, otherwise calls str() -- not unicode() -- on it.
|
||||
"""
|
||||
__slots__ = ('_func', '_args')
|
||||
|
||||
def __new__(cls, func: Callable, *args: str) -> object: # type: ignore
|
||||
def __new__(cls, func: Callable, *args: str) -> "_TranslationProxy":
|
||||
if not args:
|
||||
# not called with "function" and "arguments", but a plain string
|
||||
return str(func)
|
||||
return str(func) # type: ignore[return-value]
|
||||
return object.__new__(cls)
|
||||
|
||||
def __getnewargs__(self) -> Tuple[str]:
|
||||
@ -34,50 +30,14 @@ class _TranslationProxy(UserString):
|
||||
self._func = func
|
||||
self._args = args
|
||||
|
||||
@property
|
||||
def data(self) -> str: # type: ignore
|
||||
return self._func(*self._args)
|
||||
|
||||
# replace function from UserString; it instantiates a self.__class__
|
||||
# for the encoding result
|
||||
|
||||
def encode(self, encoding: str = None, errors: str = None) -> bytes: # type: ignore
|
||||
if encoding:
|
||||
if errors:
|
||||
return self.data.encode(encoding, errors)
|
||||
else:
|
||||
return self.data.encode(encoding)
|
||||
else:
|
||||
return self.data.encode()
|
||||
def __str__(self) -> str:
|
||||
return str(self._func(*self._args))
|
||||
|
||||
def __dir__(self) -> List[str]:
|
||||
return dir(str)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.data)
|
||||
|
||||
def __add__(self, other: str) -> str: # type: ignore
|
||||
return self.data + other
|
||||
|
||||
def __radd__(self, other: str) -> str: # type: ignore
|
||||
return other + self.data
|
||||
|
||||
def __mod__(self, other: str) -> str: # type: ignore
|
||||
return self.data % other
|
||||
|
||||
def __rmod__(self, other: str) -> str: # type: ignore
|
||||
return other % self.data
|
||||
|
||||
def __mul__(self, other: Any) -> str: # type: ignore
|
||||
return self.data * other
|
||||
|
||||
def __rmul__(self, other: Any) -> str: # type: ignore
|
||||
return other * self.data
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name == '__members__':
|
||||
return self.__dir__()
|
||||
return getattr(self.data, name)
|
||||
return getattr(self.__str__(), name)
|
||||
|
||||
def __getstate__(self) -> Tuple[Callable, Tuple[str, ...]]:
|
||||
return self._func, self._args
|
||||
@ -86,13 +46,49 @@ class _TranslationProxy(UserString):
|
||||
self._func, self._args = tup
|
||||
|
||||
def __copy__(self) -> "_TranslationProxy":
|
||||
return self
|
||||
return _TranslationProxy(self._func, *self._args)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
try:
|
||||
return 'i' + repr(str(self.data))
|
||||
return 'i' + repr(str(self.__str__()))
|
||||
except Exception:
|
||||
return '<%s broken>' % self.__class__.__name__
|
||||
return f'<{self.__class__.__name__} broken>'
|
||||
|
||||
def __add__(self, other: str) -> str:
|
||||
return self.__str__() + other
|
||||
|
||||
def __radd__(self, other: str) -> str:
|
||||
return other + self.__str__()
|
||||
|
||||
def __mod__(self, other: str) -> str:
|
||||
return self.__str__() % other
|
||||
|
||||
def __rmod__(self, other: str) -> str:
|
||||
return other % self.__str__()
|
||||
|
||||
def __mul__(self, other: Any) -> str:
|
||||
return self.__str__() * other
|
||||
|
||||
def __rmul__(self, other: Any) -> str:
|
||||
return other * self.__str__()
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.__str__())
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.__str__() == other
|
||||
|
||||
def __lt__(self, string):
|
||||
return self.__str__() < string
|
||||
|
||||
def __contains__(self, char):
|
||||
return char in self.__str__()
|
||||
|
||||
def __len__(self):
|
||||
return len(self.__str__())
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self.__str__()[index]
|
||||
|
||||
|
||||
translators: Dict[Tuple[str, str], NullTranslations] = defaultdict(NullTranslations)
|
||||
@ -219,7 +215,7 @@ def get_translation(catalog: str, namespace: str = 'general') -> Callable[[str],
|
||||
def gettext(message: str, *args: Any) -> str:
|
||||
if not is_translator_registered(catalog, namespace):
|
||||
# not initialized yet
|
||||
return _TranslationProxy(_lazy_translate, catalog, namespace, message) # type: ignore # NOQA
|
||||
return _TranslationProxy(_lazy_translate, catalog, namespace, message) # type: ignore[return-value] # NOQA
|
||||
else:
|
||||
translator = get_translator(catalog, namespace)
|
||||
if len(args) <= 1:
|
||||
|
@ -1,18 +1,8 @@
|
||||
"""Helpers for AST (Abstract Syntax Tree)."""
|
||||
|
||||
import sys
|
||||
import ast
|
||||
from typing import Dict, List, Optional, Type, overload
|
||||
|
||||
if sys.version_info[:2] >= (3, 8):
|
||||
import ast
|
||||
else:
|
||||
try:
|
||||
# use typed_ast module if installed
|
||||
from typed_ast import ast3 as ast
|
||||
except ImportError:
|
||||
import ast # type: ignore
|
||||
|
||||
|
||||
OPERATORS: Dict[Type[ast.AST], str] = {
|
||||
ast.Add: "+",
|
||||
ast.And: "and",
|
||||
@ -37,21 +27,13 @@ OPERATORS: Dict[Type[ast.AST], str] = {
|
||||
|
||||
|
||||
def parse(code: str, mode: str = 'exec') -> "ast.AST":
|
||||
"""Parse the *code* using the built-in ast or typed_ast libraries.
|
||||
|
||||
This enables "type_comments" feature if possible.
|
||||
"""
|
||||
"""Parse the *code* using the built-in ast module."""
|
||||
try:
|
||||
# type_comments parameter is available on py38+
|
||||
return ast.parse(code, mode=mode, type_comments=True) # type: ignore
|
||||
return ast.parse(code, mode=mode, type_comments=True)
|
||||
except SyntaxError:
|
||||
# Some syntax error found. To ignore invalid type comments, retry parsing without
|
||||
# type_comments parameter (refs: https://github.com/sphinx-doc/sphinx/issues/8652).
|
||||
return ast.parse(code, mode=mode)
|
||||
except TypeError:
|
||||
# fallback to ast module.
|
||||
# typed_ast is used to parse type_comments if installed.
|
||||
return ast.parse(code, mode=mode)
|
||||
|
||||
|
||||
@overload
|
||||
@ -102,10 +84,8 @@ class _UnparseVisitor(ast.NodeVisitor):
|
||||
def visit_arguments(self, node: ast.arguments) -> str:
|
||||
defaults: List[Optional[ast.expr]] = list(node.defaults)
|
||||
positionals = len(node.args)
|
||||
posonlyargs = 0
|
||||
if hasattr(node, "posonlyargs"): # for py38+
|
||||
posonlyargs += len(node.posonlyargs) # type:ignore
|
||||
positionals += posonlyargs
|
||||
posonlyargs = len(node.posonlyargs)
|
||||
positionals += posonlyargs
|
||||
for _ in range(len(defaults), positionals):
|
||||
defaults.insert(0, None)
|
||||
|
||||
@ -114,12 +94,11 @@ class _UnparseVisitor(ast.NodeVisitor):
|
||||
kw_defaults.insert(0, None)
|
||||
|
||||
args: List[str] = []
|
||||
if hasattr(node, "posonlyargs"): # for py38+
|
||||
for i, arg in enumerate(node.posonlyargs): # type: ignore
|
||||
args.append(self._visit_arg_with_default(arg, defaults[i]))
|
||||
for i, arg in enumerate(node.posonlyargs):
|
||||
args.append(self._visit_arg_with_default(arg, defaults[i]))
|
||||
|
||||
if node.posonlyargs: # type: ignore
|
||||
args.append('/')
|
||||
if node.posonlyargs:
|
||||
args.append('/')
|
||||
|
||||
for i, arg in enumerate(node.args):
|
||||
args.append(self._visit_arg_with_default(arg, defaults[i + posonlyargs]))
|
||||
@ -155,19 +134,19 @@ class _UnparseVisitor(ast.NodeVisitor):
|
||||
["%s=%s" % (k.arg, self.visit(k.value)) for k in node.keywords])
|
||||
return "%s(%s)" % (self.visit(node.func), ", ".join(args))
|
||||
|
||||
def visit_Constant(self, node: ast.Constant) -> str: # type: ignore
|
||||
def visit_Constant(self, node: ast.Constant) -> str:
|
||||
if node.value is Ellipsis:
|
||||
return "..."
|
||||
elif isinstance(node.value, (int, float, complex)):
|
||||
if self.code and sys.version_info[:2] >= (3, 8):
|
||||
return ast.get_source_segment(self.code, node) # type: ignore
|
||||
if self.code:
|
||||
return ast.get_source_segment(self.code, node) or repr(node.value)
|
||||
else:
|
||||
return repr(node.value)
|
||||
else:
|
||||
return repr(node.value)
|
||||
|
||||
def visit_Dict(self, node: ast.Dict) -> str:
|
||||
keys = (self.visit(k) for k in node.keys)
|
||||
keys = (self.visit(k) for k in node.keys if k is not None)
|
||||
values = (self.visit(v) for v in node.values)
|
||||
items = (k + ": " + v for k, v in zip(keys, values))
|
||||
return "{" + ", ".join(items) + "}"
|
||||
@ -219,22 +198,5 @@ class _UnparseVisitor(ast.NodeVisitor):
|
||||
else:
|
||||
return "(" + ", ".join(self.visit(e) for e in node.elts) + ")"
|
||||
|
||||
if sys.version_info[:2] <= (3, 7):
|
||||
# these ast nodes were deprecated in python 3.8
|
||||
def visit_Bytes(self, node: ast.Bytes) -> str:
|
||||
return repr(node.s)
|
||||
|
||||
def visit_Ellipsis(self, node: ast.Ellipsis) -> str:
|
||||
return "..."
|
||||
|
||||
def visit_NameConstant(self, node: ast.NameConstant) -> str:
|
||||
return repr(node.value)
|
||||
|
||||
def visit_Num(self, node: ast.Num) -> str:
|
||||
return repr(node.n)
|
||||
|
||||
def visit_Str(self, node: ast.Str) -> str:
|
||||
return repr(node.s)
|
||||
|
||||
def generic_visit(self, node):
|
||||
raise NotImplementedError('Unable to parse %s object' % type(node).__name__)
|
||||
|
@ -1,4 +1,6 @@
|
||||
"""Utilities parsing and analyzing Python code."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import itertools
|
||||
import re
|
||||
@ -9,8 +11,8 @@ from token import DEDENT, INDENT, NAME, NEWLINE, NUMBER, OP, STRING
|
||||
from tokenize import COMMENT, NL
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sphinx.pycode.ast import ast # for py37 or older
|
||||
from sphinx.pycode.ast import parse, unparse
|
||||
from sphinx.pycode.ast import parse as ast_parse
|
||||
from sphinx.pycode.ast import unparse as ast_unparse
|
||||
|
||||
comment_re = re.compile('^\\s*#: ?(.*)\r?\n?$')
|
||||
indent_re = re.compile('^\\s*$')
|
||||
@ -266,7 +268,7 @@ class VariableCommentPicker(ast.NodeVisitor):
|
||||
qualname = self.get_qualname_for(name)
|
||||
if qualname:
|
||||
basename = ".".join(qualname[:-1])
|
||||
self.annotations[(basename, name)] = unparse(annotation)
|
||||
self.annotations[(basename, name)] = ast_unparse(annotation)
|
||||
|
||||
def is_final(self, decorators: List[ast.expr]) -> bool:
|
||||
final = []
|
||||
@ -277,7 +279,7 @@ class VariableCommentPicker(ast.NodeVisitor):
|
||||
|
||||
for decorator in decorators:
|
||||
try:
|
||||
if unparse(decorator) in final:
|
||||
if ast_unparse(decorator) in final:
|
||||
return True
|
||||
except NotImplementedError:
|
||||
pass
|
||||
@ -293,7 +295,7 @@ class VariableCommentPicker(ast.NodeVisitor):
|
||||
|
||||
for decorator in decorators:
|
||||
try:
|
||||
if unparse(decorator) in overload:
|
||||
if ast_unparse(decorator) in overload:
|
||||
return True
|
||||
except NotImplementedError:
|
||||
pass
|
||||
@ -304,12 +306,9 @@ class VariableCommentPicker(ast.NodeVisitor):
|
||||
"""Returns the name of the first argument if in a function."""
|
||||
if self.current_function and self.current_function.args.args:
|
||||
return self.current_function.args.args[0]
|
||||
elif (self.current_function and
|
||||
getattr(self.current_function.args, 'posonlyargs', None)):
|
||||
# for py38+
|
||||
return self.current_function.args.posonlyargs[0] # type: ignore
|
||||
else:
|
||||
return None
|
||||
if self.current_function and self.current_function.args.posonlyargs:
|
||||
return self.current_function.args.posonlyargs[0]
|
||||
return None
|
||||
|
||||
def get_line(self, lineno: int) -> str:
|
||||
"""Returns specified line."""
|
||||
@ -553,7 +552,7 @@ class Parser:
|
||||
|
||||
def parse_comments(self) -> None:
|
||||
"""Parse the code and pick up comments."""
|
||||
tree = parse(self.code)
|
||||
tree = ast_parse(self.code)
|
||||
picker = VariableCommentPicker(self.code.splitlines(True), self.encoding)
|
||||
picker.visit(tree)
|
||||
self.annotations = picker.annotations
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""Helpers for inspecting Python modules."""
|
||||
|
||||
import ast
|
||||
import builtins
|
||||
import contextlib
|
||||
import enum
|
||||
@ -8,15 +9,15 @@ import re
|
||||
import sys
|
||||
import types
|
||||
import typing
|
||||
from functools import partial, partialmethod
|
||||
from functools import cached_property, partial, partialmethod, singledispatchmethod
|
||||
from importlib import import_module
|
||||
from inspect import Parameter, isclass, ismethod, ismethoddescriptor, ismodule # NOQA
|
||||
from inspect import (Parameter, isasyncgenfunction, isclass, ismethod, # NOQA
|
||||
ismethoddescriptor, ismodule)
|
||||
from io import StringIO
|
||||
from types import (ClassMethodDescriptorType, MethodDescriptorType, MethodType, ModuleType,
|
||||
WrapperDescriptorType)
|
||||
from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Tuple, Type, cast
|
||||
|
||||
from sphinx.pycode.ast import ast # for py37
|
||||
from sphinx.pycode.ast import unparse as ast_unparse
|
||||
from sphinx.util import logging
|
||||
from sphinx.util.typing import ForwardRef
|
||||
@ -285,11 +286,7 @@ def is_singledispatch_function(obj: Any) -> bool:
|
||||
|
||||
def is_singledispatch_method(obj: Any) -> bool:
|
||||
"""Check if the object is singledispatch method."""
|
||||
try:
|
||||
from functools import singledispatchmethod # type: ignore
|
||||
return isinstance(obj, singledispatchmethod)
|
||||
except ImportError: # py37
|
||||
return False
|
||||
return isinstance(obj, singledispatchmethod)
|
||||
|
||||
|
||||
def isfunction(obj: Any) -> bool:
|
||||
@ -329,27 +326,9 @@ def iscoroutinefunction(obj: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
if sys.version_info[:2] <= (3, 7):
|
||||
def isasyncgenfunction(obj: Any) -> bool:
|
||||
"""Check if the object is async-gen function."""
|
||||
if hasattr(obj, '__code__') and inspect.isasyncgenfunction(obj):
|
||||
# check obj.__code__ because isasyncgenfunction() crashes for custom method-like
|
||||
# objects on python3.7 (see https://github.com/sphinx-doc/sphinx/issues/9838)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
isasyncgenfunction = inspect.isasyncgenfunction
|
||||
|
||||
|
||||
def isproperty(obj: Any) -> bool:
|
||||
"""Check if the object is property."""
|
||||
if sys.version_info[:2] >= (3, 8):
|
||||
from functools import cached_property # cached_property is available since py3.8
|
||||
if isinstance(obj, cached_property):
|
||||
return True
|
||||
|
||||
return isinstance(obj, property)
|
||||
return isinstance(obj, (property, cached_property))
|
||||
|
||||
|
||||
def isgenericalias(obj: Any) -> bool:
|
||||
@ -723,7 +702,7 @@ def signature_from_str(signature: str) -> inspect.Signature:
|
||||
"""Create a Signature object from string."""
|
||||
code = 'def func' + signature + ': pass'
|
||||
module = ast.parse(code)
|
||||
function = cast(ast.FunctionDef, module.body[0]) # type: ignore
|
||||
function = cast(ast.FunctionDef, module.body[0])
|
||||
|
||||
return signature_from_ast(function, code)
|
||||
|
||||
@ -734,7 +713,7 @@ def signature_from_ast(node: ast.FunctionDef, code: str = '') -> inspect.Signatu
|
||||
defaults = list(args.defaults)
|
||||
params = []
|
||||
if hasattr(args, "posonlyargs"):
|
||||
posonlyargs = len(args.posonlyargs) # type: ignore
|
||||
posonlyargs = len(args.posonlyargs)
|
||||
positionals = posonlyargs + len(args.args)
|
||||
else:
|
||||
posonlyargs = 0
|
||||
@ -744,7 +723,7 @@ def signature_from_ast(node: ast.FunctionDef, code: str = '') -> inspect.Signatu
|
||||
defaults.insert(0, Parameter.empty) # type: ignore
|
||||
|
||||
if hasattr(args, "posonlyargs"):
|
||||
for i, arg in enumerate(args.posonlyargs): # type: ignore
|
||||
for i, arg in enumerate(args.posonlyargs):
|
||||
if defaults[i] is Parameter.empty:
|
||||
default = Parameter.empty
|
||||
else:
|
||||
|
@ -1,4 +1,3 @@
|
||||
# for py34 or above
|
||||
from functools import partialmethod
|
||||
|
||||
|
||||
|
@ -1,11 +0,0 @@
|
||||
def foo(*, a, b):
|
||||
pass
|
||||
|
||||
def bar(a, b, /, c, d):
|
||||
pass
|
||||
|
||||
def baz(a, /, *, b):
|
||||
pass
|
||||
|
||||
def qux(a, b, /):
|
||||
pass
|
@ -1,7 +1,6 @@
|
||||
"""Tests the Python Domain"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from unittest.mock import Mock
|
||||
|
||||
import docutils.utils
|
||||
@ -365,7 +364,6 @@ def test_parse_annotation_suppress(app):
|
||||
assert_node(doctree[0], pending_xref, refdomain="py", reftype="obj", reftarget="typing.Dict")
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7), reason='python 3.8+ is required.')
|
||||
def test_parse_annotation_Literal(app):
|
||||
doctree = _parse_annotation("Literal[True, False]", app.env)
|
||||
assert_node(doctree, ([pending_xref, "Literal"],
|
||||
@ -451,37 +449,6 @@ def test_pyfunction_signature_full(app):
|
||||
[desc_sig_punctuation, ":"],
|
||||
desc_sig_space,
|
||||
[desc_sig_name, pending_xref, "str"])])])
|
||||
|
||||
|
||||
def test_pyfunction_with_unary_operators(app):
|
||||
text = ".. py:function:: menu(egg=+1, bacon=-1, sausage=~1, spam=not spam)"
|
||||
doctree = restructuredtext.parse(app, text)
|
||||
assert_node(doctree[1][0][1],
|
||||
[desc_parameterlist, ([desc_parameter, ([desc_sig_name, "egg"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "+1"])],
|
||||
[desc_parameter, ([desc_sig_name, "bacon"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "-1"])],
|
||||
[desc_parameter, ([desc_sig_name, "sausage"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "~1"])],
|
||||
[desc_parameter, ([desc_sig_name, "spam"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "not spam"])])])
|
||||
|
||||
|
||||
def test_pyfunction_with_binary_operators(app):
|
||||
text = ".. py:function:: menu(spam=2**64)"
|
||||
doctree = restructuredtext.parse(app, text)
|
||||
assert_node(doctree[1][0][1],
|
||||
[desc_parameterlist, ([desc_parameter, ([desc_sig_name, "spam"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "2**64"])])])
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7), 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)
|
||||
@ -516,7 +483,33 @@ def test_pyfunction_signature_full_py38(app):
|
||||
[desc_parameter, desc_sig_operator, "/"])])
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7), reason='python 3.8+ is required.')
|
||||
def test_pyfunction_with_unary_operators(app):
|
||||
text = ".. py:function:: menu(egg=+1, bacon=-1, sausage=~1, spam=not spam)"
|
||||
doctree = restructuredtext.parse(app, text)
|
||||
assert_node(doctree[1][0][1],
|
||||
[desc_parameterlist, ([desc_parameter, ([desc_sig_name, "egg"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "+1"])],
|
||||
[desc_parameter, ([desc_sig_name, "bacon"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "-1"])],
|
||||
[desc_parameter, ([desc_sig_name, "sausage"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "~1"])],
|
||||
[desc_parameter, ([desc_sig_name, "spam"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "not spam"])])])
|
||||
|
||||
|
||||
def test_pyfunction_with_binary_operators(app):
|
||||
text = ".. py:function:: menu(spam=2**64)"
|
||||
doctree = restructuredtext.parse(app, text)
|
||||
assert_node(doctree[1][0][1],
|
||||
[desc_parameterlist, ([desc_parameter, ([desc_sig_name, "spam"],
|
||||
[desc_sig_operator, "="],
|
||||
[nodes.inline, "2**64"])])])
|
||||
|
||||
|
||||
def test_pyfunction_with_number_literals(app):
|
||||
text = ".. py:function:: hello(age=0x10, height=1_6_0)"
|
||||
doctree = restructuredtext.parse(app, text)
|
||||
|
@ -1072,8 +1072,6 @@ def test_autodoc_descriptor(app):
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7),
|
||||
reason='cached_property is available since python3.8.')
|
||||
@pytest.mark.sphinx('html', testroot='ext-autodoc')
|
||||
def test_autodoc_cached_property(app):
|
||||
options = {"members": None,
|
||||
@ -2059,8 +2057,6 @@ def test_singledispatch(app):
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7),
|
||||
reason='singledispatchmethod is available since python3.8')
|
||||
@pytest.mark.sphinx('html', testroot='ext-autodoc')
|
||||
def test_singledispatchmethod(app):
|
||||
options = {"members": None}
|
||||
@ -2088,8 +2084,6 @@ def test_singledispatchmethod(app):
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7),
|
||||
reason='singledispatchmethod is available since python3.8')
|
||||
@pytest.mark.sphinx('html', testroot='ext-autodoc')
|
||||
def test_singledispatchmethod_automethod(app):
|
||||
options = {}
|
||||
@ -2142,8 +2136,6 @@ def test_cython(app):
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7),
|
||||
reason='typing.final is available since python3.8')
|
||||
@pytest.mark.sphinx('html', testroot='ext-autodoc')
|
||||
def test_final(app):
|
||||
options = {"members": None}
|
||||
|
@ -4,8 +4,6 @@ This tests mainly the Documenters; the auto directives are tested in a test
|
||||
source file translated by test_build.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from .test_ext_autodoc import do_autodoc
|
||||
@ -40,7 +38,6 @@ def test_class_properties(app):
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7), reason='python 3.8+ is required.')
|
||||
@pytest.mark.sphinx('html', testroot='ext-autodoc')
|
||||
def test_cached_properties(app):
|
||||
actual = do_autodoc(app, 'property', 'target.cached_property.Foo.prop')
|
||||
|
@ -1,7 +1,5 @@
|
||||
"""Test the autodoc extension."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from .test_ext_autodoc import do_autodoc
|
||||
@ -10,10 +8,7 @@ from .test_ext_autodoc import do_autodoc
|
||||
@pytest.mark.sphinx('html', testroot='ext-autodoc',
|
||||
confoverrides={'autodoc_preserve_defaults': True})
|
||||
def test_preserve_defaults(app):
|
||||
if sys.version_info[:2] <= (3, 7):
|
||||
color = "16777215"
|
||||
else:
|
||||
color = "0xFFFFFF"
|
||||
color = "0xFFFFFF"
|
||||
|
||||
options = {"members": None}
|
||||
actual = do_autodoc(app, 'module', 'target.preserve_defaults', options)
|
||||
|
@ -185,8 +185,6 @@ def test_ModuleAnalyzer_find_attr_docs():
|
||||
'Qux.attr2': 17}
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7),
|
||||
reason='posonlyargs are available since python3.8.')
|
||||
def test_ModuleAnalyzer_find_attr_docs_for_posonlyargs_method():
|
||||
code = ('class Foo(object):\n'
|
||||
' def __init__(self, /):\n'
|
||||
|
@ -1,10 +1,10 @@
|
||||
"""Test pycode.ast"""
|
||||
|
||||
import sys
|
||||
import ast
|
||||
|
||||
import pytest
|
||||
|
||||
from sphinx.pycode import ast
|
||||
from sphinx.pycode.ast import unparse as ast_unparse
|
||||
|
||||
|
||||
@pytest.mark.parametrize('source,expected', [
|
||||
@ -48,23 +48,15 @@ from sphinx.pycode import ast
|
||||
("(1, 2, 3)", "(1, 2, 3)"), # Tuple
|
||||
("()", "()"), # Tuple (empty)
|
||||
("(1,)", "(1,)"), # Tuple (single item)
|
||||
("lambda x=0, /, y=1, *args, z, **kwargs: x + y + z",
|
||||
"lambda x=0, /, y=1, *args, z, **kwargs: ..."), # posonlyargs
|
||||
("0x1234", "0x1234"), # Constant
|
||||
("1_000_000", "1_000_000"), # Constant
|
||||
])
|
||||
def test_unparse(source, expected):
|
||||
module = ast.parse(source)
|
||||
assert ast.unparse(module.body[0].value, source) == expected
|
||||
assert ast_unparse(module.body[0].value, source) == expected
|
||||
|
||||
|
||||
def test_unparse_None():
|
||||
assert ast.unparse(None) is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7), reason='python 3.8+ is required.')
|
||||
@pytest.mark.parametrize('source,expected', [
|
||||
("lambda x=0, /, y=1, *args, z, **kwargs: x + y + z",
|
||||
"lambda x=0, /, y=1, *args, z, **kwargs: ..."), # posonlyargs
|
||||
("0x1234", "0x1234"), # Constant
|
||||
("1_000_000", "1_000_000"), # Constant
|
||||
])
|
||||
def test_unparse_py38(source, expected):
|
||||
module = ast.parse(source)
|
||||
assert ast.unparse(module.body[0].value, source) == expected
|
||||
assert ast_unparse(None) is None
|
||||
|
@ -151,7 +151,8 @@ def test_signature_partialmethod():
|
||||
|
||||
def test_signature_annotations():
|
||||
from .typing_test_data import (Node, f0, f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11, f12,
|
||||
f13, f14, f15, f16, f17, f18, f19, f20, f21)
|
||||
f13, f14, f15, f16, f17, f18, f19, f20, f21, f22, f23, f24,
|
||||
f25)
|
||||
|
||||
# Class annotations
|
||||
sig = inspect.signature(f0)
|
||||
@ -272,25 +273,19 @@ def test_signature_annotations():
|
||||
else:
|
||||
assert stringify_signature(sig, unqualified_typehints=True) == '(x: int = None, y: dict = {}) -> None'
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7), reason='python 3.8+ is required.')
|
||||
@pytest.mark.sphinx(testroot='ext-autodoc')
|
||||
def test_signature_annotations_py38(app):
|
||||
from target.pep570 import bar, baz, foo, qux
|
||||
|
||||
# case: separator at head
|
||||
sig = inspect.signature(foo)
|
||||
sig = inspect.signature(f22)
|
||||
assert stringify_signature(sig) == '(*, a, b)'
|
||||
|
||||
# case: separator in the middle
|
||||
sig = inspect.signature(bar)
|
||||
sig = inspect.signature(f23)
|
||||
assert stringify_signature(sig) == '(a, b, /, c, d)'
|
||||
|
||||
sig = inspect.signature(baz)
|
||||
sig = inspect.signature(f24)
|
||||
assert stringify_signature(sig) == '(a, /, *, b)'
|
||||
|
||||
# case: separator at tail
|
||||
sig = inspect.signature(qux)
|
||||
sig = inspect.signature(f25)
|
||||
assert stringify_signature(sig) == '(a, b, /)'
|
||||
|
||||
|
||||
@ -373,8 +368,6 @@ def test_signature_from_str_kwonly_args():
|
||||
assert sig.parameters['b'].default == Parameter.empty
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7),
|
||||
reason='python-3.8 or above is required')
|
||||
def test_signature_from_str_positionaly_only_args():
|
||||
sig = inspect.signature_from_str('(a, b=0, /, c=1)')
|
||||
assert list(sig.parameters.keys()) == ['a', 'b', 'c']
|
||||
|
@ -162,7 +162,6 @@ def test_restify_type_ForwardRef():
|
||||
assert restify(ForwardRef("myint")) == ":py:class:`myint`"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7), reason='python 3.8+ is required.')
|
||||
def test_restify_type_Literal():
|
||||
from typing import Literal # type: ignore
|
||||
assert restify(Literal[1, "2", "\r"]) == ":py:obj:`~typing.Literal`\\ [1, '2', '\\r']"
|
||||
@ -408,7 +407,6 @@ def test_stringify_type_hints_alias():
|
||||
assert stringify(MyTuple, "smart") == "~typing.Tuple[str, str]" # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info[:2] <= (3, 7), reason='python 3.8+ is required.')
|
||||
def test_stringify_type_Literal():
|
||||
from typing import Literal # type: ignore
|
||||
assert stringify(Literal[1, "2", "\r"]) == "Literal[1, '2', '\\r']"
|
||||
|
@ -105,6 +105,22 @@ def f21(arg1='whatever', arg2=Signature.empty):
|
||||
pass
|
||||
|
||||
|
||||
def f22(*, a, b):
|
||||
pass
|
||||
|
||||
|
||||
def f23(a, b, /, c, d):
|
||||
pass
|
||||
|
||||
|
||||
def f24(a, /, *, b):
|
||||
pass
|
||||
|
||||
|
||||
def f25(a, b, /):
|
||||
pass
|
||||
|
||||
|
||||
class Node:
|
||||
def __init__(self, parent: Optional['Node']) -> None:
|
||||
pass
|
||||
|
4
tox.ini
4
tox.ini
@ -1,6 +1,6 @@
|
||||
[tox]
|
||||
minversion = 2.4.0
|
||||
envlist = docs,flake8,mypy,twine,py{37,38,39,310,311},du{14,15,16,17,18,19}
|
||||
envlist = docs,flake8,mypy,twine,py{38,39,310,311},du{14,15,16,17,18,19}
|
||||
isolated_build = True
|
||||
|
||||
[testenv]
|
||||
@ -16,7 +16,7 @@ passenv =
|
||||
EPUBCHECK_PATH
|
||||
TERM
|
||||
description =
|
||||
py{37,38,39,310,311}: Run unit tests against {envname}.
|
||||
py{38,39,310,311}: Run unit tests against {envname}.
|
||||
du{14,15,16,17,18,19}: Run unit tests with the given version of docutils.
|
||||
deps =
|
||||
du14: docutils==0.14.*
|
||||
|
Loading…
Reference in New Issue
Block a user