diff options
-rw-r--r-- | .gitignore | 25 | ||||
-rw-r--r-- | .readthedocs.yaml | 2 | ||||
-rw-r--r-- | CHANGES.rst | 1 | ||||
-rw-r--r-- | CONTRIBUTING.rst | 5 | ||||
-rw-r--r-- | MANIFEST.in | 1 | ||||
-rw-r--r-- | requirements/dev.in | 2 | ||||
-rw-r--r-- | requirements/dev.txt | 134 | ||||
-rw-r--r-- | requirements/typing.in | 2 | ||||
-rw-r--r-- | requirements/typing.txt | 30 | ||||
-rw-r--r-- | setup.cfg | 16 | ||||
-rw-r--r-- | src/markupsafe/__init__.py | 128 | ||||
-rw-r--r-- | src/markupsafe/_native.py | 13 | ||||
-rw-r--r-- | src/markupsafe/_speedups.pyi | 20 | ||||
-rw-r--r-- | src/markupsafe/py.typed | 0 | ||||
-rw-r--r-- | tests/conftest.py | 2 | ||||
-rw-r--r-- | tox.ini | 5 |
16 files changed, 296 insertions, 90 deletions
@@ -1,18 +1,17 @@ -.DS_Store +/.idea/ +/.vscode/ +/env/ +/venv/ +__pycache__/ *.pyc -*.pyo -*.o *.so -env/ -venv/ -dist/ -build/ *.egg-info/ -.tox/ -.cache/ -.pytest_cache/ +/build/ +/dist/ +/.pytest_cache/ +/.tox/ .coverage .coverage.* -htmlcov/ -docs/_build/ -.idea/ +/htmlcov/ +/docs/_build/ +/.mypy_cache/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f4dd25b..1906952 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,8 +1,8 @@ version: 2 python: install: + - requirements: requirements/docs.txt - method: pip path: . - - requirements: requirements/docs.txt sphinx: builder: dirhtml diff --git a/CHANGES.rst b/CHANGES.rst index 7f729e7..9310cdc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,7 @@ Unreleased - Drop Python 2.7, 3.4, and 3.5 support. - ``Markup.unescape`` uses :func:`html.unescape` to support HTML5 character references. :pr:`117` +- Add type annotations for static typing tools. :pr:`149` Version 1.1.1 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 2310bb1..ca21906 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -106,11 +106,12 @@ First time setup > env\Scripts\activate -- Install MarkupSafe in editable mode with development dependencies. +- Install the development dependencies, then install MarkupSafe in + editable mode. .. code-block:: text - $ pip install -e . -r requirements/dev.txt + $ pip install -r requirements/dev.txt && pip install -e . - Install the pre-commit hooks. diff --git a/MANIFEST.in b/MANIFEST.in index 278d994..a3ee780 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,4 +4,5 @@ include requirements/*.txt graft docs prune docs/_build graft tests +include src/markupsafe/py.typed global-exclude *.pyc diff --git a/requirements/dev.in b/requirements/dev.in index bcc48da..c854000 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -1,4 +1,4 @@ -# -r docs.in # can't include due to Sphinx/Jinja mutual dependency +-r docs.in -r tests.in pip-tools pre-commit diff --git a/requirements/dev.txt b/requirements/dev.txt index ad3ce05..d9e9a98 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -4,27 +4,119 @@ # # pip-compile requirements/dev.in # -appdirs==1.4.4 # via virtualenv -attrs==19.3.0 # via pytest -cfgv==3.1.0 # via pre-commit -click==7.1.2 # via pip-tools -distlib==0.3.0 # via virtualenv -filelock==3.0.12 # via tox, virtualenv -identify==1.4.16 # via pre-commit -iniconfig==1.0.0 # via pytest -nodeenv==1.3.5 # via pre-commit -packaging==20.4 # via pytest, tox -pip-tools==5.5.0 # via -r requirements/dev.in -pluggy==0.13.1 # via pytest, tox -pre-commit==2.9.3 # via -r requirements/dev.in -py==1.9.0 # via pytest, tox -pyparsing==2.4.7 # via packaging -pytest==6.2.1 # via -r requirements/tests.in -pyyaml==5.3.1 # via pre-commit -six==1.15.0 # via packaging, tox, virtualenv -toml==0.10.1 # via pre-commit, pytest, tox -tox==3.20.1 # via -r requirements/dev.in -virtualenv==20.0.21 # via pre-commit, tox +alabaster==0.7.12 + # via sphinx +appdirs==1.4.4 + # via virtualenv +attrs==19.3.0 + # via pytest +babel==2.9.0 + # via sphinx +certifi==2020.12.5 + # via requests +cfgv==3.1.0 + # via pre-commit +chardet==4.0.0 + # via requests +click==7.1.2 + # via pip-tools +distlib==0.3.0 + # via virtualenv +docutils==0.16 + # via sphinx +filelock==3.0.12 + # via + # tox + # virtualenv +identify==1.4.16 + # via pre-commit +idna==2.10 + # via requests +imagesize==1.2.0 + # via sphinx +iniconfig==1.0.0 + # via pytest +jinja2==2.11.2 + # via sphinx +markupsafe==1.1.1 + # via jinja2 +nodeenv==1.3.5 + # via pre-commit +packaging==20.4 + # via + # pallets-sphinx-themes + # pytest + # sphinx + # tox +pallets-sphinx-themes==1.2.3 + # via -r requirements/docs.in +pip-tools==5.5.0 + # via -r requirements/dev.in +pluggy==0.13.1 + # via + # pytest + # tox +pre-commit==2.9.3 + # via -r requirements/dev.in +py==1.9.0 + # via + # pytest + # tox +pygments==2.7.4 + # via sphinx +pyparsing==2.4.7 + # via packaging +pytest==6.2.1 + # via -r requirements/tests.in +pytz==2020.5 + # via babel +pyyaml==5.3.1 + # via pre-commit +requests==2.25.1 + # via sphinx +six==1.15.0 + # via + # packaging + # tox + # virtualenv +snowballstemmer==2.1.0 + # via sphinx +sphinx-issues==1.2.0 + # via -r requirements/docs.in +sphinx==3.4.3 + # via + # -r requirements/docs.in + # pallets-sphinx-themes + # sphinx-issues + # sphinxcontrib-log-cabinet +sphinxcontrib-applehelp==1.0.2 + # via sphinx +sphinxcontrib-devhelp==1.0.2 + # via sphinx +sphinxcontrib-htmlhelp==1.0.3 + # via sphinx +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-log-cabinet==1.0.1 + # via -r requirements/docs.in +sphinxcontrib-qthelp==1.0.3 + # via sphinx +sphinxcontrib-serializinghtml==1.1.4 + # via sphinx +toml==0.10.1 + # via + # pre-commit + # pytest + # tox +tox==3.20.1 + # via -r requirements/dev.in +urllib3==1.26.3 + # via requests +virtualenv==20.0.21 + # via + # pre-commit + # tox # The following packages are considered to be unsafe in a requirements file: # pip +# setuptools diff --git a/requirements/typing.in b/requirements/typing.in new file mode 100644 index 0000000..d76706e --- /dev/null +++ b/requirements/typing.in @@ -0,0 +1,2 @@ +mypy +pytest diff --git a/requirements/typing.txt b/requirements/typing.txt new file mode 100644 index 0000000..c67c7a0 --- /dev/null +++ b/requirements/typing.txt @@ -0,0 +1,30 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/typing.in +# +attrs==20.3.0 + # via pytest +iniconfig==1.1.1 + # via pytest +mypy-extensions==0.4.3 + # via mypy +mypy==0.800 + # via -r requirements/typing.in +packaging==20.9 + # via pytest +pluggy==0.13.1 + # via pytest +py==1.10.0 + # via pytest +pyparsing==2.4.7 + # via packaging +pytest==6.2.2 + # via -r requirements/typing.in +toml==0.10.2 + # via pytest +typed-ast==1.4.2 + # via mypy +typing-extensions==3.7.4.3 + # via mypy @@ -68,3 +68,19 @@ ignore = W503 # up to 88 allowed by bugbear B950 max-line-length = 80 + +[mypy] +files = src/ +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +no_implicit_optional = true +local_partial_types = true +;no_implicit_reexport = true +strict_equality = true +warn_redundant_casts = true +warn_unused_configs = true +warn_unused_ignores = true +warn_return_any = true +warn_unreachable = true diff --git a/src/markupsafe/__init__.py b/src/markupsafe/__init__.py index 789979f..1d786fd 100644 --- a/src/markupsafe/__init__.py +++ b/src/markupsafe/__init__.py @@ -1,11 +1,32 @@ +import functools import re import string +import typing as t + +if t.TYPE_CHECKING: + + class HasHTML(t.Protocol): + def __html__(self) -> str: + pass + __version__ = "2.0.0a1" _striptags_re = re.compile(r"(<!--.*?-->|<[^>]*>)") +def _simple_escaping_wrapper(name: str) -> t.Callable[..., "Markup"]: + orig = getattr(str, name) + + @functools.wraps(orig) + def wrapped(self: "Markup", *args: t.Any, **kwargs: t.Any) -> "Markup": + args = _escape_argspec(list(args), enumerate(args), self.escape) # type: ignore + _escape_argspec(kwargs, kwargs.items(), self.escape) + return self.__class__(orig(self, *args, **kwargs)) + + return wrapped + + class Markup(str): """A string that is ready to be safely inserted into an HTML or XML document, either because it was escaped or because it was marked @@ -44,64 +65,76 @@ class Markup(str): __slots__ = () - def __new__(cls, base="", encoding=None, errors="strict"): + def __new__( + cls, base: t.Any = "", encoding: t.Optional[str] = None, errors: str = "strict" + ) -> "Markup": if hasattr(base, "__html__"): base = base.__html__() + if encoding is None: return super().__new__(cls, base) + return super().__new__(cls, base, encoding, errors) - def __html__(self): + def __html__(self) -> "Markup": return self - def __add__(self, other): + def __add__(self, other: t.Union[str, "HasHTML"]) -> "Markup": if isinstance(other, str) or hasattr(other, "__html__"): return self.__class__(super().__add__(self.escape(other))) + return NotImplemented - def __radd__(self, other): + def __radd__(self, other: t.Union[str, "HasHTML"]) -> "Markup": if isinstance(other, str) or hasattr(other, "__html__"): return self.escape(other).__add__(self) + return NotImplemented - def __mul__(self, num): + def __mul__(self, num: int) -> "Markup": if isinstance(num, int): return self.__class__(super().__mul__(num)) - return NotImplemented + + return NotImplemented # type: ignore __rmul__ = __mul__ - def __mod__(self, arg): + def __mod__(self, arg: t.Any) -> "Markup": if isinstance(arg, tuple): arg = tuple(_MarkupEscapeHelper(x, self.escape) for x in arg) else: arg = _MarkupEscapeHelper(arg, self.escape) + return self.__class__(super().__mod__(arg)) - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}({super().__repr__()})" - def join(self, seq): + def join(self, seq: t.Iterable[t.Union[str, "HasHTML"]]) -> "Markup": return self.__class__(super().join(map(self.escape, seq))) join.__doc__ = str.join.__doc__ - def split(self, *args, **kwargs): - return list(map(self.__class__, super().split(*args, **kwargs))) + def split( # type: ignore + self, sep: t.Optional[str] = None, maxsplit: int = -1 + ) -> t.List["Markup"]: + return [self.__class__(v) for v in super().split(sep, maxsplit)] split.__doc__ = str.split.__doc__ - def rsplit(self, *args, **kwargs): - return list(map(self.__class__, super().rsplit(*args, **kwargs))) + def rsplit( # type: ignore + self, sep: t.Optional[str] = None, maxsplit: int = -1 + ) -> t.List["Markup"]: + return [self.__class__(v) for v in super().rsplit(sep, maxsplit)] rsplit.__doc__ = str.rsplit.__doc__ - def splitlines(self, *args, **kwargs): - return list(map(self.__class__, super().splitlines(*args, **kwargs))) + def splitlines(self, keepends: bool = False) -> t.List["Markup"]: # type: ignore + return [self.__class__(v) for v in super().splitlines(keepends)] splitlines.__doc__ = str.splitlines.__doc__ - def unescape(self): + def unescape(self) -> str: """Convert escaped markup back into a text string. This replaces HTML entities with the characters they represent. @@ -112,7 +145,7 @@ class Markup(str): return unescape(str(self)) - def striptags(self): + def striptags(self) -> str: """:meth:`unescape` the markup, remove tags, and normalize whitespace to single spaces. @@ -123,26 +156,16 @@ class Markup(str): return Markup(stripped).unescape() @classmethod - def escape(cls, s): + def escape(cls, s: t.Any) -> "Markup": """Escape a string. Calls :func:`escape` and ensures that for subclasses the correct type is returned. """ rv = escape(s) + if rv.__class__ is not cls: return cls(rv) - return rv - - def make_simple_escaping_wrapper(name): # noqa: B902 - orig = getattr(str, name) - - def func(self, *args, **kwargs): - args = _escape_argspec(list(args), enumerate(args), self.escape) - _escape_argspec(kwargs, kwargs.items(), self.escape) - return self.__class__(orig(self, *args, **kwargs)) - func.__name__ = orig.__name__ - func.__doc__ = orig.__doc__ - return func + return rv for method in ( "__getitem__", @@ -162,31 +185,36 @@ class Markup(str): "swapcase", "zfill", ): - locals()[method] = make_simple_escaping_wrapper(method) + locals()[method] = _simple_escaping_wrapper(method) - del method, make_simple_escaping_wrapper + del method - def partition(self, sep): - return tuple(map(self.__class__, super().partition(self.escape(sep)))) + def partition(self, sep: str) -> t.Tuple["Markup", "Markup", "Markup"]: + l, s, r = super().partition(self.escape(sep)) + cls = self.__class__ + return cls(l), cls(s), cls(r) - def rpartition(self, sep): - return tuple(map(self.__class__, super().rpartition(self.escape(sep)))) + def rpartition(self, sep: str) -> t.Tuple["Markup", "Markup", "Markup"]: + l, s, r = super().rpartition(self.escape(sep)) + cls = self.__class__ + return cls(l), cls(s), cls(r) - def format(self, *args, **kwargs): + def format(self, *args: t.Any, **kwargs: t.Any) -> "Markup": formatter = EscapeFormatter(self.escape) return self.__class__(formatter.vformat(self, args, kwargs)) - def __html_format__(self, format_spec): + def __html_format__(self, format_spec: str) -> "Markup": if format_spec: raise ValueError("Unsupported format specification for Markup.") + return self class EscapeFormatter(string.Formatter): - def __init__(self, escape): + def __init__(self, escape: t.Callable[[t.Any], Markup]) -> None: self.escape = escape - def format_field(self, value, format_spec): + def format_field(self, value: t.Any, format_spec: str) -> str: if hasattr(value, "__html_format__"): rv = value.__html_format__(format_spec) elif hasattr(value, "__html__"): @@ -204,34 +232,40 @@ class EscapeFormatter(string.Formatter): return str(self.escape(rv)) -def _escape_argspec(obj, iterable, escape): +_ListOrDict = t.TypeVar("_ListOrDict", list, dict) + + +def _escape_argspec( + obj: _ListOrDict, iterable: t.Iterable[t.Any], escape: t.Callable[[t.Any], Markup] +) -> _ListOrDict: """Helper for various string-wrapped functions.""" for key, value in iterable: if isinstance(value, str) or hasattr(value, "__html__"): obj[key] = escape(value) + return obj class _MarkupEscapeHelper: """Helper for :meth:`Markup.__mod__`.""" - def __init__(self, obj, escape): + def __init__(self, obj: t.Any, escape: t.Callable[[t.Any], Markup]) -> None: self.obj = obj self.escape = escape - def __getitem__(self, item): + def __getitem__(self, item: t.Any) -> "_MarkupEscapeHelper": return _MarkupEscapeHelper(self.obj[item], self.escape) - def __str__(self): + def __str__(self) -> str: return str(self.escape(self.obj)) - def __repr__(self): + def __repr__(self) -> str: return str(self.escape(repr(self.obj))) - def __int__(self): + def __int__(self) -> int: return int(self.obj) - def __float__(self): + def __float__(self) -> float: return float(self.obj) diff --git a/src/markupsafe/_native.py b/src/markupsafe/_native.py index 7722296..1a3f214 100644 --- a/src/markupsafe/_native.py +++ b/src/markupsafe/_native.py @@ -1,7 +1,9 @@ +import typing as t + from . import Markup -def escape(s): +def escape(s: t.Any) -> Markup: """Replace the characters ``&``, ``<``, ``>``, ``'``, and ``"`` in the string with HTML-safe sequences. Use this if you need to display text that might contain such characters in HTML. @@ -14,6 +16,7 @@ def escape(s): """ if hasattr(s, "__html__"): return Markup(s.__html__()) + return Markup( str(s) .replace("&", "&") @@ -24,7 +27,7 @@ def escape(s): ) -def escape_silent(s): +def escape_silent(s: t.Optional[t.Any]) -> Markup: """Like :func:`escape` but treats ``None`` as the empty string. Useful with optional values, as otherwise you get the string ``'None'`` when the value is ``None``. @@ -36,10 +39,11 @@ def escape_silent(s): """ if s is None: return Markup() + return escape(s) -def soft_str(s): +def soft_str(s: t.Any) -> str: """Convert an object to a string if it isn't already. This preserves a :class:`Markup` string rather than converting it back to a basic string, so it will still be marked as safe and won't be escaped @@ -55,10 +59,11 @@ def soft_str(s): """ if not isinstance(s, str): return str(s) + return s -def soft_unicode(s): +def soft_unicode(s: t.Any) -> str: import warnings warnings.warn( diff --git a/src/markupsafe/_speedups.pyi b/src/markupsafe/_speedups.pyi new file mode 100644 index 0000000..a3cad64 --- /dev/null +++ b/src/markupsafe/_speedups.pyi @@ -0,0 +1,20 @@ +from typing import Any +from typing import Optional + +from . import Markup + + +def escape(s: Any) -> Markup: + ... + + +def escape_silent(s: Optional[Any]) -> Markup: + ... + + +def soft_str(s: Any) -> str: + ... + + +def soft_unicode(s: Any) -> str: + ... diff --git a/src/markupsafe/py.typed b/src/markupsafe/py.typed new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/markupsafe/py.typed diff --git a/tests/conftest.py b/tests/conftest.py index 7141547..13f938c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ from markupsafe import _native try: from markupsafe import _speedups except ImportError: - _speedups = None + _speedups = None # type: ignore @pytest.fixture( @@ -2,6 +2,7 @@ envlist = py{39,38,37,36,py3} style + typing docs skip_missing_interpreters = true @@ -14,6 +15,10 @@ deps = pre-commit skip_install = true commands = pre-commit run --all-files --show-diff-on-failure +[testenv:typing] +deps = -r requirements/typing.txt +commands = mypy + [testenv:docs] deps = -r requirements/docs.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html |