summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Lord <davidism@gmail.com>2021-01-29 18:27:37 -0800
committerDavid Lord <davidism@gmail.com>2021-01-29 18:40:05 -0800
commit6a64222bfb18bd49e3a12f509f38ee7f2585799f (patch)
tree8c36679e1883ee23bcbecd6ccd0ae5d92e8f995e
parent9724cdedc887632d64d8fc7ed40056d0a8431f06 (diff)
downloadmarkupsafe-6a64222bfb18bd49e3a12f509f38ee7f2585799f.tar.gz
add type annotations
-rw-r--r--.gitignore25
-rw-r--r--.readthedocs.yaml2
-rw-r--r--CHANGES.rst1
-rw-r--r--CONTRIBUTING.rst5
-rw-r--r--MANIFEST.in1
-rw-r--r--requirements/dev.in2
-rw-r--r--requirements/dev.txt134
-rw-r--r--requirements/typing.in2
-rw-r--r--requirements/typing.txt30
-rw-r--r--setup.cfg16
-rw-r--r--src/markupsafe/__init__.py128
-rw-r--r--src/markupsafe/_native.py13
-rw-r--r--src/markupsafe/_speedups.pyi20
-rw-r--r--src/markupsafe/py.typed0
-rw-r--r--tests/conftest.py2
-rw-r--r--tox.ini5
16 files changed, 296 insertions, 90 deletions
diff --git a/.gitignore b/.gitignore
index ba7609d..b48a303 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/setup.cfg b/setup.cfg
index 08801c7..26ebc94 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -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("&", "&amp;")
@@ -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(
diff --git a/tox.ini b/tox.ini
index 9ec517b..689c68b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -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