diff options
author | Iris Yang <irisykyang@google.com> | 2020-09-01 14:43:44 +0800 |
---|---|---|
committer | Iris Yang <irisykyang@google.com> | 2020-09-01 14:43:44 +0800 |
commit | d5d7b04cfc953da1f9b326f599a610ed2dd14704 (patch) | |
tree | 0dde3def694ea284074bc6a1fd24fb6b66c4bd5b | |
parent | 3e87759c5e2a3bdafcca3d02c3cc6a89b1dd8b7c (diff) | |
parent | 122f69384488f031b9b781018e092c8560aa4cb9 (diff) | |
download | markupsafe-d5d7b04cfc953da1f9b326f599a610ed2dd14704.tar.gz |
Import external/python/markupsafe into master
Merge branch 'upstream-master' of sso://googleplex-android/platform/external/python/markupsafe
Third-party review: ag/12415762
Bug: 165703675
Change-Id: Id1e1cc64feccc247be68dc1caa7ebebb76a5f607
42 files changed, 1976 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..220a591 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 +max_line_length = 88 + +[*.{yml,yaml,json,js,css,html}] +indent_size = 2 + +[*.c] +indent_style = tab diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..17581fa --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,28 @@ +name: Build +on: + push: + branches: + - master + - '*.x' + tags: + - '*' +jobs: + wheels: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + env: + CIBW_SKIP: 'cp27-* pp27-* cp35-*' + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: '3.8' + - run: pip install cibuildwheel + - run: cibuildwheel + - uses: actions/upload-artifact@v2 + with: + path: ./wheelhouse diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..4c457e1 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,52 @@ +name: Tests +on: + push: + branches: + - master + - '*.x' + pull_request: + branches: + - master + - '*.x' +jobs: + tests: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - {name: Linux, python: '3.8', os: ubuntu-latest, tox: py38} + - {name: Windows, python: '3.8', os: windows-latest, tox: py38} + - {name: Mac, python: '3.8', os: macos-latest, tox: py38} + - {name: '3.7', python: '3.7', os: ubuntu-latest, tox: py37} + - {name: '3.6', python: '3.6', os: ubuntu-latest, tox: py36} + - {name: 'PyPy', python: pypy3, os: ubuntu-latest, tox: pypy3} + - {name: Style, python: '3.8', os: ubuntu-latest, tox: style} + - {name: Docs, python: '3.8', os: ubuntu-latest, tox: docs} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: update pip + run: | + pip install -U wheel + pip install -U setuptools + python -m pip install -U pip + - name: get pip cache dir + id: pip-cache + run: echo "::set-output name=dir::$(pip cache dir)" + - name: cache pip + uses: actions/cache@v1 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: pip|${{ runner.os }}|${{ matrix.python }}|${{ hashFiles('setup.py') }}|${{ hashFiles('requirements/*.txt') }} + - name: cache pre-commit + uses: actions/cache@v1 + with: + path: ~/.cache/pre-commit + key: pre-commit|${{ matrix.python }}|${{ hashFiles('.pre-commit-config.yaml') }} + if: matrix.tox == 'style' + - run: pip install tox + - run: tox -e ${{ matrix.tox }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba7609d --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.DS_Store +*.pyc +*.pyo +*.o +*.so +env/ +venv/ +dist/ +build/ +*.egg-info/ +.tox/ +.cache/ +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +docs/_build/ +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cd7a684 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,26 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v2.4.3 + hooks: + - id: pyupgrade + args: ["--py36-plus"] + - repo: https://github.com/asottile/reorder_python_imports + rev: v2.3.0 + hooks: + - id: reorder-python-imports + args: ["--application-directories", "src"] + - repo: https://github.com/python/black + rev: 19.10b0 + hooks: + - id: black + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.2 + hooks: + - id: flake8 + additional_dependencies: [flake8-bugbear] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.1.0 + hooks: + - id: check-byte-order-marker + - id: trailing-whitespace + - id: end-of-file-fixer diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..f4dd25b --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,8 @@ +version: 2 +python: + install: + - method: pip + path: . + - requirements: requirements/docs.txt +sphinx: + builder: dirhtml diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 0000000..7f729e7 --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,101 @@ +Version 2.0.0 +------------- + +Unreleased + +- Drop Python 2.7, 3.4, and 3.5 support. +- ``Markup.unescape`` uses :func:`html.unescape` to support HTML5 + character references. :pr:`117` + + +Version 1.1.1 +------------- + +Released 2019-02-23 + +- Fix segfault when ``__html__`` method raises an exception when using + the C speedups. The exception is now propagated correctly. :pr:`109` + + +Version 1.1.0 +------------- + +Released 2018-11-05 + +- Drop support for Python 2.6 and 3.3. +- Build wheels for Linux, Mac, and Windows, allowing systems without + a compiler to take advantage of the C extension speedups. :pr:`104` +- Use newer CPython API on Python 3, resulting in a 1.5x speedup. + :pr`64` +- ``escape`` wraps ``__html__`` result in ``Markup``, consistent with + documented behavior. :pr:`69` + + +Version 1.0 +----------- + +Released 2017-03-07 + +- Fixed custom types not invoking ``__unicode__`` when used with + ``format()``. +- Added ``__version__`` module attribute. +- Improve unescape code to leave lone ampersands alone. + + +Version 0.18 +------------ + +Released 2013-05-22 + +- Fixed ``__mul__`` and string splitting on Python 3. + + +Version 0.17 +------------ + +Released 2013-05-21 + +- Fixed a bug with broken interpolation on tuples. + + +Version 0.16 +------------ + +Released 2013-05-20 + +- Improved Python 3 Support and removed 2to3. +- Removed support for Python 3.2 and 2.5. + + +Version 0.15 +------------ + +Released 2011-07-20 + +- Fixed a typo that caused the library to fail to install on pypy and + jython. + + +Version 0.14 +------------ + +Released 2011-07-20 + +- Release fix for 0.13. + + +Version 0.13 +------------ + +Released 2011-07-20 + +- Do not attempt to compile extension for PyPy or Jython. +- Work around some 64bit Windows issues. + + +Version 0.12 +------------ + +Released 2011-02-17 + +- Improved PyPy compatibility. diff --git a/LICENSE.rst b/LICENSE.rst new file mode 100644 index 0000000..9d227a0 --- /dev/null +++ b/LICENSE.rst @@ -0,0 +1,28 @@ +Copyright 2010 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..278d994 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include CHANGES.rst +include tox.ini +include requirements/*.txt +graft docs +prune docs/_build +graft tests +global-exclude *.pyc diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..26f2e44 --- /dev/null +++ b/README.rst @@ -0,0 +1,68 @@ +MarkupSafe +========== + +MarkupSafe implements a text object that escapes characters so it is +safe to use in HTML and XML. Characters that have special meanings are +replaced so that they display as the actual characters. This mitigates +injection attacks, meaning untrusted user input can safely be displayed +on a page. + + +Installing +---------- + +Install and update using `pip`_: + +.. code-block:: text + + pip install -U MarkupSafe + +.. _pip: https://pip.pypa.io/en/stable/quickstart/ + + +Examples +-------- + +.. code-block:: pycon + + >>> from markupsafe import Markup, escape + + >>> # escape replaces special characters and wraps in Markup + >>> escape("<script>alert(document.cookie);</script>") + Markup('<script>alert(document.cookie);</script>') + + >>> # wrap in Markup to mark text "safe" and prevent escaping + >>> Markup("<strong>Hello</strong>") + Markup('<strong>hello</strong>') + + >>> escape(Markup("<strong>Hello</strong>")) + Markup('<strong>hello</strong>') + + >>> # Markup is a str subclass + >>> # methods and operators escape their arguments + >>> template = Markup("Hello <em>{name}</em>") + >>> template.format(name='"World"') + Markup('Hello <em>"World"</em>') + + +Donate +------ + +The Pallets organization develops and supports MarkupSafe and other +libraries that use it. In order to grow the community of contributors +and users, and allow the maintainers to devote more time to the +projects, `please donate today`_. + +.. _please donate today: https://palletsprojects.com/donate + + +Links +----- + +* Website: https://palletsprojects.com/p/markupsafe/ +* Documentation: https://markupsafe.palletsprojects.com/ +* Releases: https://pypi.org/project/MarkupSafe/ +* Code: https://github.com/pallets/markupsafe +* Issue tracker: https://github.com/pallets/markupsafe/issues +* Test status: https://dev.azure.com/pallets/markupsafe/_build +* Official chat: https://discord.gg/t6rrQZH diff --git a/bench/bench_basic.py b/bench/bench_basic.py new file mode 100644 index 0000000..b67eda4 --- /dev/null +++ b/bench/bench_basic.py @@ -0,0 +1,5 @@ +from markupsafe import escape + + +def run(): + escape("<strong>Hello World!</strong>") diff --git a/bench/bench_largestring.py b/bench/bench_largestring.py new file mode 100644 index 0000000..9ced67f --- /dev/null +++ b/bench/bench_largestring.py @@ -0,0 +1,6 @@ +from markupsafe import escape + + +def run(): + string = "<strong>Hello World!</strong>" * 1000 + escape(string) diff --git a/bench/bench_long_empty_string.py b/bench/bench_long_empty_string.py new file mode 100644 index 0000000..ad2480c --- /dev/null +++ b/bench/bench_long_empty_string.py @@ -0,0 +1,6 @@ +from markupsafe import escape + + +def run(): + string = "Hello World!" * 1000 + escape(string) diff --git a/bench/bench_long_suffix.py b/bench/bench_long_suffix.py new file mode 100644 index 0000000..35f38da --- /dev/null +++ b/bench/bench_long_suffix.py @@ -0,0 +1,6 @@ +from markupsafe import escape + + +def run(): + string = "<strong>Hello World!</strong>" + "x" * 100000 + escape(string) diff --git a/bench/bench_short_empty_string.py b/bench/bench_short_empty_string.py new file mode 100644 index 0000000..0664a6f --- /dev/null +++ b/bench/bench_short_empty_string.py @@ -0,0 +1,5 @@ +from markupsafe import escape + + +def run(): + escape("Hello World!") diff --git a/bench/runbench.py b/bench/runbench.py new file mode 100644 index 0000000..f20cd49 --- /dev/null +++ b/bench/runbench.py @@ -0,0 +1,38 @@ +import os +import re +import sys +from subprocess import Popen + +_filename_re = re.compile(r"^bench_(.*?)\.py$") +bench_directory = os.path.abspath(os.path.dirname(__file__)) + + +def list_benchmarks(): + result = [] + for name in os.listdir(bench_directory): + match = _filename_re.match(name) + if match is not None: + result.append(match.group(1)) + result.sort(key=lambda x: (x.startswith("logging_"), x.lower())) + return result + + +def run_bench(name): + print(name) + Popen( + [sys.executable, "-m", "timeit", "-s", f"from bench_{name} import run", "run()"] + ).wait() + + +def main(): + print("=" * 80) + print("Running benchmark for MarkupSafe") + print("-" * 80) + os.chdir(bench_directory) + for bench in list_benchmarks(): + run_bench(bench) + print("-" * 80) + + +if __name__ == "__main__": + main() diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..5128596 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/changes.rst b/docs/changes.rst new file mode 100644 index 0000000..955deaf --- /dev/null +++ b/docs/changes.rst @@ -0,0 +1,4 @@ +Changes +======= + +.. include:: ../CHANGES.rst diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..881f1ab --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,49 @@ +from pallets_sphinx_themes import get_version +from pallets_sphinx_themes import ProjectLink + +# Project -------------------------------------------------------------- + +project = "MarkupSafe" +copyright = "2010 Pallets" +author = "Pallets" +release, version = get_version("MarkupSafe") + +# General -------------------------------------------------------------- + +master_doc = "index" +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "pallets_sphinx_themes", + "sphinxcontrib.log_cabinet", + "sphinx_issues", +] +intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)} +issues_github_path = "pallets/markupsafe" + +# HTML ----------------------------------------------------------------- + +html_theme = "jinja" +html_theme_options = {"index_sidebar_logo": False} +html_context = { + "project_links": [ + ProjectLink("Donate to Pallets", "https://palletsprojects.com/donate"), + ProjectLink("Website", "https://palletsprojects.com/p/markupsafe/"), + ProjectLink("PyPI releases", "https://pypi.org/project/MarkupSafe/"), + ProjectLink("Source Code", "https://github.com/pallets/markupsafe/"), + ProjectLink("Issue Tracker", "https://github.com/pallets/markupsafe/issues/"), + ] +} +html_sidebars = { + "index": ["project.html", "localtoc.html", "searchbox.html"], + "**": ["localtoc.html", "relations.html", "searchbox.html"], +} +singlehtml_sidebars = {"index": ["project.html", "localtoc.html"]} +html_title = f"MarkupSafe Documentation ({version})" +html_show_sourcelink = False + +# LaTeX ---------------------------------------------------------------- + +latex_documents = [ + (master_doc, f"MarkupSafe-{version}.tex", html_title, author, "manual") +] diff --git a/docs/escaping.rst b/docs/escaping.rst new file mode 100644 index 0000000..9e7000a --- /dev/null +++ b/docs/escaping.rst @@ -0,0 +1,21 @@ +.. module:: markupsafe + +Working With Safe Text +====================== + +.. autofunction:: escape + +.. autoclass:: Markup + :members: escape, unescape, striptags + + +Optional Values +--------------- + +.. autofunction:: escape_silent + + +Convert an Object to a String +----------------------------- + +.. autofunction:: soft_str diff --git a/docs/formatting.rst b/docs/formatting.rst new file mode 100644 index 0000000..c14f917 --- /dev/null +++ b/docs/formatting.rst @@ -0,0 +1,77 @@ +.. currentmodule:: markupsafe + +String Formatting +================= + +The :class:`Markup` class can be used as a format string. Objects +formatted into a markup string will be escaped first. + + +Format Method +------------- + +The ``format`` method extends the standard :meth:`str.format` behavior +to use an ``__html_format__`` method. + +#. If an object has an ``__html_format__`` method, it is called as a + replacement for the ``__format__`` method. It is passed a format + specifier if it's given. The method must return a string or + :class:`Markup` instance. + +#. If an object has an ``__html__`` method, it is called. If a format + specifier was passed and the class defined ``__html__`` but not + ``__html_format__``, a ``ValueError`` is raised. + +#. Otherwise Python's default format behavior is used and the result + is escaped. + +For example, to implement a ``User`` that wraps its ``name`` in a +``span`` tag, and adds a link when using the ``"link"`` format +specifier: + +.. code-block:: python + + class User(object): + def __init__(self, id, name): + self.id = id + self.name = name + + def __html_format__(self, format_spec): + if format_spec == "link": + return Markup( + '<a href="/user/{}">{}</a>' + ).format(self.id, self.__html__()) + elif format_spec: + raise ValueError("Invalid format spec") + return self.__html__() + + def __html__(self): + return Markup( + '<span class="user">{0}</span>' + ).format(self.name) + + +.. code-block:: pycon + + >>> user = User(3, "<script>") + >>> escape(user) + Markup('<span class="user"><script></span>') + >>> Markup("<p>User: {user:link}").format(user=user) + Markup('<p>User: <a href="/user/3"><span class="user"><script></span></a> + +See Python's docs on :ref:`format string syntax <python:formatstrings>`. + + +printf-style Formatting +----------------------- + +Besides escaping, there's no special behavior involved with percent +formatting. + +.. code-block:: pycon + + >>> user = User(3, "<script>") + >>> Markup('<a href="/user/%d">%s</a>') % (user.id, user.name) + Markup('<a href="/user/3"><script></a>') + +See Python's docs on :ref:`printf-style formatting <python:old-string-formatting>`. diff --git a/docs/html.rst b/docs/html.rst new file mode 100644 index 0000000..dec87af --- /dev/null +++ b/docs/html.rst @@ -0,0 +1,49 @@ +.. currentmodule:: markupsafe + +HTML Representations +==================== + +In many frameworks, if a class implements an ``__html__`` method it +will be used to get the object's representation in HTML. MarkupSafe's +:func:`escape` function and :class:`Markup` class understand and +implement this method. If an object has an ``__html__`` method it will +be called rather than converting the object to a string, and the result +will be assumed safe and not escaped. + +For example, an ``Image`` class might automatically generate an +``<img>`` tag: + +.. code-block:: python + + class Image: + def __init__(self, url): + self.url = url + + def __html__(self): + return f'<img src="{self.url}">' + +.. code-block:: pycon + + >>> img = Image("/static/logo.png") + >>> Markup(img) + Markup('<img src="/static/logo.png">') + +Since this bypasses escaping, you need to be careful about using +user-provided data in the output. For example, a user's display name +should still be escaped: + +.. code-block:: python + + class User: + def __init__(self, id, name): + self.id = id + self.name = name + + def __html__(self): + return f'<a href="/user/{self.id}">{escape(self.name)}</a>' + +.. code-block:: pycon + + >>> user = User(3, "<script>") + >>> escape(user) + Markup('<a href="/users/3"><script></a>') diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..5c45e64 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,47 @@ +.. currentmodule:: markupsafe + +MarkupSafe +========== + +MarkupSafe escapes characters so text is safe to use in HTML and XML. +Characters that have special meanings are replaced so that they display +as the actual characters. This mitigates injection attacks, meaning +untrusted user input can safely be displayed on a page. + +The :func:`escape` function escapes text and returns a :class:`Markup` +object. The object won't be escaped anymore, but any text that is used +with it will be, ensuring that the result remains safe to use in HTML. + +>>> from markupsafe import escape +>>> hello = escape("<em>Hello</em>") +>>> hello +Markup('<em>Hello</em>') +>>> escape(hello) +Markup('<em>Hello</em>') +>>> hello + " <strong>World</strong>" +Markup('<em>Hello</em> <strong>World</strong>') + + +Installing +---------- + +Install and update using `pip`_: + +.. code-block:: text + + pip install -U MarkupSafe + +.. _pip: https://pip.pypa.io/en/stable/quickstart/ + + +Table of Contents +----------------- + +.. toctree:: + :maxdepth: 2 + + escaping + html + formatting + license + changes diff --git a/docs/license.rst b/docs/license.rst new file mode 100644 index 0000000..a53a98c --- /dev/null +++ b/docs/license.rst @@ -0,0 +1,4 @@ +BSD-3-Clause License +==================== + +.. include:: ../LICENSE.rst diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..7893348 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/requirements/dev.in b/requirements/dev.in new file mode 100644 index 0000000..bcc48da --- /dev/null +++ b/requirements/dev.in @@ -0,0 +1,5 @@ +# -r docs.in # can't include due to Sphinx/Jinja mutual dependency +-r tests.in +pip-tools +pre-commit +tox diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 0000000..1959dc5 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,31 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# 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 +more-itertools==8.3.0 # via pytest +nodeenv==1.3.5 # via pre-commit +packaging==20.4 # via pytest, tox +pip-tools==5.3.1 # via -r requirements/dev.in +pluggy==0.13.1 # via pytest, tox +pre-commit==2.6.0 # via -r requirements/dev.in +py==1.9.0 # via pytest, tox +pyparsing==2.4.7 # via packaging +pytest==6.0.1 # via -r requirements/tests.in +pyyaml==5.3.1 # via pre-commit +six==1.15.0 # via packaging, pip-tools, tox, virtualenv +toml==0.10.1 # via pre-commit, pytest, tox +tox==3.19.0 # via -r requirements/dev.in +virtualenv==20.0.21 # via pre-commit, tox + +# The following packages are considered to be unsafe in a requirements file: +# pip diff --git a/requirements/docs.in b/requirements/docs.in new file mode 100644 index 0000000..7ec501b --- /dev/null +++ b/requirements/docs.in @@ -0,0 +1,4 @@ +Pallets-Sphinx-Themes +Sphinx +sphinx-issues +sphinxcontrib-log-cabinet diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 0000000..9b6ca1d --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,36 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/docs.in +# +alabaster==0.7.12 # via sphinx +babel==2.8.0 # via sphinx +certifi==2020.4.5.1 # via requests +chardet==3.0.4 # via requests +docutils==0.16 # via sphinx +idna==2.9 # via requests +imagesize==1.2.0 # via sphinx +jinja2==2.11.2 # via sphinx +markupsafe==1.1.1 # via jinja2 +packaging==20.4 # via pallets-sphinx-themes, sphinx +pallets-sphinx-themes==1.2.3 # via -r requirements/docs.in +pygments==2.6.1 # via sphinx +pyparsing==2.4.7 # via packaging +pytz==2020.1 # via babel +requests==2.23.0 # via sphinx +six==1.15.0 # via packaging +snowballstemmer==2.0.0 # via sphinx +sphinx-issues==1.2.0 # via -r requirements/docs.in +sphinx==3.2.0 # 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 +urllib3==1.25.9 # via requests + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/requirements/tests.in b/requirements/tests.in new file mode 100644 index 0000000..e079f8a --- /dev/null +++ b/requirements/tests.in @@ -0,0 +1 @@ +pytest diff --git a/requirements/tests.txt b/requirements/tests.txt new file mode 100644 index 0000000..233bc81 --- /dev/null +++ b/requirements/tests.txt @@ -0,0 +1,16 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile requirements/tests.in +# +attrs==19.3.0 # via pytest +iniconfig==1.0.0 # via pytest +more-itertools==8.3.0 # via pytest +packaging==20.4 # via pytest +pluggy==0.13.1 # via pytest +py==1.9.0 # via pytest +pyparsing==2.4.7 # via packaging +pytest==6.0.1 # via -r requirements/tests.in +six==1.15.0 # via packaging +toml==0.10.1 # via pytest diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..08801c7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,70 @@ +[metadata] +name = MarkupSafe +version = attr: markupsafe.__version__ +url = https://palletsprojects.com/p/markupsafe/ +project_urls = + Documentation = https://markupsafe.palletsprojects.com/ + Code = https://github.com/pallets/markupsafe + Issue tracker = https://github.com/pallets/markupsafe/issues +license = BSD-3-Clause +license_files = LICENSE.rst +author = Armin Ronacher +author_email = armin.ronacher@active-4.com +maintainer = Pallets +maintainer_email = contact@palletsprojects.com +description = Safely add untrusted strings to HTML/XML markup. +long_description = file: README.rst +long_description_content_type = text/x-rst +classifiers = + Development Status :: 5 - Production/Stable + Environment :: Web Environment + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python + Topic :: Internet :: WWW/HTTP :: Dynamic Content + Topic :: Text Processing :: Markup :: HTML + +[options] +packages = find: +package_dir = = src +include_package_data = true +python_requires = >= 3.6 + +[options.packages.find] +where = src + +[tool:pytest] +testpaths = tests +filterwarnings = + error + +[coverage:run] +branch = true +source = + markupsafe + tests + +[coverage:paths] +source = + src + */site-packages + +[flake8] +# B = bugbear +# E = pycodestyle errors +# F = flake8 pyflakes +# W = pycodestyle warnings +# B9 = bugbear opinions +select = B, E, F, W, B9 +ignore = + # slice notation whitespace, invalid + E203 + # line length, handled by bugbear B950 + E501 + # bare except, handled by bugbear B001 + E722 + # bin op line break, invalid + W503 +# up to 88 allowed by bugbear B950 +max-line-length = 80 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2a8743f --- /dev/null +++ b/setup.py @@ -0,0 +1,79 @@ +import os +import platform +import sys +from distutils.errors import CCompilerError +from distutils.errors import DistutilsExecError +from distutils.errors import DistutilsPlatformError + +from setuptools import Extension +from setuptools import setup +from setuptools.command.build_ext import build_ext + +ext_modules = [Extension("markupsafe._speedups", ["src/markupsafe/_speedups.c"])] + + +class BuildFailed(Exception): + pass + + +class ve_build_ext(build_ext): + """This class allows C extension building to fail.""" + + def run(self): + try: + build_ext.run(self) + except DistutilsPlatformError: + raise BuildFailed() + + def build_extension(self, ext): + try: + build_ext.build_extension(self, ext) + except (CCompilerError, DistutilsExecError, DistutilsPlatformError): + raise BuildFailed() + except ValueError: + # this can happen on Windows 64 bit, see Python issue 7511 + if "'path'" in str(sys.exc_info()[1]): # works with Python 2 and 3 + raise BuildFailed() + raise + + +def run_setup(with_binary): + setup( + name="MarkupSafe", + cmdclass={"build_ext": ve_build_ext}, + ext_modules=ext_modules if with_binary else [], + ) + + +def show_message(*lines): + print("=" * 74) + for line in lines: + print(line) + print("=" * 74) + + +if os.environ.get("CIBUILDWHEEL", "0") == "1": + run_setup(True) +elif platform.python_implementation() not in {"PyPy", "Jython"}: + try: + run_setup(True) + except BuildFailed: + show_message( + "WARNING: The C extension could not be compiled, speedups" + " are not enabled.", + "Failure information, if any, is above.", + "Retrying the build without the C extension now.", + ) + run_setup(False) + show_message( + "WARNING: The C extension could not be compiled, speedups" + " are not enabled.", + "Plain-Python build succeeded.", + ) +else: + run_setup(False) + show_message( + "WARNING: C extensions are not supported on this Python" + " platform, speedups are not enabled.", + "Plain-Python build succeeded.", + ) diff --git a/src/markupsafe/__init__.py b/src/markupsafe/__init__.py new file mode 100644 index 0000000..54d32bf --- /dev/null +++ b/src/markupsafe/__init__.py @@ -0,0 +1,281 @@ +import re +import string +from collections import abc + +__version__ = "2.0.0a1" + +_striptags_re = re.compile(r"(<!--.*?-->|<[^>]*>)") + + +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 + safe. + + Passing an object to the constructor converts it to text and wraps + it to mark it safe without escaping. To escape the text, use the + :meth:`escape` class method instead. + + >>> Markup("Hello, <em>World</em>!") + Markup('Hello, <em>World</em>!') + >>> Markup(42) + Markup('42') + >>> Markup.escape("Hello, <em>World</em>!") + Markup('Hello <em>World</em>!') + + This implements the ``__html__()`` interface that some frameworks + use. Passing an object that implements ``__html__()`` will wrap the + output of that method, marking it safe. + + >>> class Foo: + ... def __html__(self): + ... return '<a href="/foo">foo</a>' + ... + >>> Markup(Foo()) + Markup('<a href="/foo">foo</a>') + + This is a subclass of :class:`str`. It has the same methods, but + escapes their arguments and returns a ``Markup`` instance. + + >>> Markup("<em>%s</em>") % ("foo & bar",) + Markup('<em>foo & bar</em>') + >>> Markup("<em>Hello</em> ") + "<foo>" + Markup('<em>Hello</em> <foo>') + """ + + __slots__ = () + + def __new__(cls, base="", encoding=None, errors="strict"): + 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): + return self + + def __add__(self, other): + if isinstance(other, str) or hasattr(other, "__html__"): + return self.__class__(super().__add__(self.escape(other))) + return NotImplemented + + def __radd__(self, other): + if isinstance(other, str) or hasattr(other, "__html__"): + return self.escape(other).__add__(self) + return NotImplemented + + def __mul__(self, num): + if isinstance(num, int): + return self.__class__(super().__mul__(num)) + return NotImplemented + + __rmul__ = __mul__ + + def __mod__(self, arg): + 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): + return f"{self.__class__.__name__}({super().__repr__()})" + + def join(self, seq): + 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))) + + split.__doc__ = str.split.__doc__ + + def rsplit(self, *args, **kwargs): + return list(map(self.__class__, super().rsplit(*args, **kwargs))) + + rsplit.__doc__ = str.rsplit.__doc__ + + def splitlines(self, *args, **kwargs): + return list(map(self.__class__, super().splitlines(*args, **kwargs))) + + splitlines.__doc__ = str.splitlines.__doc__ + + def unescape(self): + """Convert escaped markup back into a text string. This replaces + HTML entities with the characters they represent. + + >>> Markup("Main » <em>About</em>").unescape() + 'Main » <em>About</em>' + """ + from html import unescape + + return unescape(str(self)) + + def striptags(self): + """:meth:`unescape` the markup, remove tags, and normalize + whitespace to single spaces. + + >>> Markup("Main »\t<em>About</em>").striptags() + 'Main » About' + """ + stripped = " ".join(_striptags_re.sub("", self).split()) + return Markup(stripped).unescape() + + @classmethod + def escape(cls, s): + """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 + + for method in ( + "__getitem__", + "capitalize", + "title", + "lower", + "upper", + "replace", + "ljust", + "rjust", + "lstrip", + "rstrip", + "center", + "strip", + "translate", + "expandtabs", + "swapcase", + "zfill", + ): + locals()[method] = make_simple_escaping_wrapper(method) + + del method, make_simple_escaping_wrapper + + def partition(self, sep): + return tuple(map(self.__class__, super().partition(self.escape(sep)))) + + def rpartition(self, sep): + return tuple(map(self.__class__, super().rpartition(self.escape(sep)))) + + def format(self, *args, **kwargs): + formatter = EscapeFormatter(self.escape) + kwargs = _MagicFormatMapping(args, kwargs) + return self.__class__(formatter.vformat(self, args, kwargs)) + + def __html_format__(self, format_spec): + if format_spec: + raise ValueError("Unsupported format specification for Markup.") + return self + + +class _MagicFormatMapping(abc.Mapping): + """This class implements a dummy wrapper to fix a bug in the Python + standard library for string formatting. + + See http://bugs.python.org/issue13598 for information about why + this is necessary. + """ + + def __init__(self, args, kwargs): + self._args = args + self._kwargs = kwargs + self._last_index = 0 + + def __getitem__(self, key): + if key == "": + idx = self._last_index + self._last_index += 1 + try: + return self._args[idx] + except LookupError: + pass + key = str(idx) + return self._kwargs[key] + + def __iter__(self): + return iter(self._kwargs) + + def __len__(self): + return len(self._kwargs) + + +class EscapeFormatter(string.Formatter): + def __init__(self, escape): + self.escape = escape + + def format_field(self, value, format_spec): + if hasattr(value, "__html_format__"): + rv = value.__html_format__(format_spec) + elif hasattr(value, "__html__"): + if format_spec: + raise ValueError( + f"Format specifier {format_spec} given, but {type(value)} does not" + " define __html_format__. A class that defines __html__ must define" + " __html_format__ to work with format specifiers." + ) + rv = value.__html__() + else: + # We need to make sure the format spec is str here as + # otherwise the wrong callback methods are invoked. + rv = string.Formatter.format_field(self, value, str(format_spec)) + return str(self.escape(rv)) + + +def _escape_argspec(obj, iterable, escape): + """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): + self.obj = obj + self.escape = escape + + def __getitem__(self, item): + return _MarkupEscapeHelper(self.obj[item], self.escape) + + def __str__(self): + return str(self.escape(self.obj)) + + def __repr__(self): + return str(self.escape(repr(self.obj))) + + def __int__(self): + return int(self.obj) + + def __float__(self): + return float(self.obj) + + +# circular import +try: + from ._speedups import escape + from ._speedups import escape_silent + from ._speedups import soft_str + from ._speedups import soft_unicode +except ImportError: + from ._native import escape + from ._native import escape_silent # noqa: F401 + from ._native import soft_str # noqa: F401 + from ._native import soft_unicode # noqa: F401 diff --git a/src/markupsafe/_native.py b/src/markupsafe/_native.py new file mode 100644 index 0000000..7722296 --- /dev/null +++ b/src/markupsafe/_native.py @@ -0,0 +1,70 @@ +from . import Markup + + +def escape(s): + """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. + + If the object has an ``__html__`` method, it is called and the + return value is assumed to already be safe for HTML. + + :param s: An object to be converted to a string and escaped. + :return: A :class:`Markup` string with the escaped text. + """ + if hasattr(s, "__html__"): + return Markup(s.__html__()) + return Markup( + str(s) + .replace("&", "&") + .replace(">", ">") + .replace("<", "<") + .replace("'", "'") + .replace('"', """) + ) + + +def escape_silent(s): + """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``. + + >>> escape(None) + Markup('None') + >>> escape_silent(None) + Markup('') + """ + if s is None: + return Markup() + return escape(s) + + +def soft_str(s): + """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 + again. + + >>> value = escape("<User 1>") + >>> value + Markup('<User 1>') + >>> escape(str(value)) + Markup('&lt;User 1&gt;') + >>> escape(soft_str(value)) + Markup('<User 1>') + """ + if not isinstance(s, str): + return str(s) + return s + + +def soft_unicode(s): + import warnings + + warnings.warn( + "'soft_unicode' has been renamed to 'soft_str'. The old name" + " will be removed in version 2.1.", + DeprecationWarning, + stacklevel=2, + ) + return soft_str(s) diff --git a/src/markupsafe/_speedups.c b/src/markupsafe/_speedups.c new file mode 100644 index 0000000..7b7e57f --- /dev/null +++ b/src/markupsafe/_speedups.c @@ -0,0 +1,339 @@ +#include <Python.h> + +static PyObject* markup; + +static int +init_constants(void) +{ + PyObject *module; + + /* import markup type so that we can mark the return value */ + module = PyImport_ImportModule("markupsafe"); + if (!module) + return 0; + markup = PyObject_GetAttrString(module, "Markup"); + Py_DECREF(module); + + return 1; +} + +#define GET_DELTA(inp, inp_end, delta) \ + while (inp < inp_end) { \ + switch (*inp++) { \ + case '"': \ + case '\'': \ + case '&': \ + delta += 4; \ + break; \ + case '<': \ + case '>': \ + delta += 3; \ + break; \ + } \ + } + +#define DO_ESCAPE(inp, inp_end, outp) \ + { \ + Py_ssize_t ncopy = 0; \ + while (inp < inp_end) { \ + switch (*inp) { \ + case '"': \ + memcpy(outp, inp-ncopy, sizeof(*outp)*ncopy); \ + outp += ncopy; ncopy = 0; \ + *outp++ = '&'; \ + *outp++ = '#'; \ + *outp++ = '3'; \ + *outp++ = '4'; \ + *outp++ = ';'; \ + break; \ + case '\'': \ + memcpy(outp, inp-ncopy, sizeof(*outp)*ncopy); \ + outp += ncopy; ncopy = 0; \ + *outp++ = '&'; \ + *outp++ = '#'; \ + *outp++ = '3'; \ + *outp++ = '9'; \ + *outp++ = ';'; \ + break; \ + case '&': \ + memcpy(outp, inp-ncopy, sizeof(*outp)*ncopy); \ + outp += ncopy; ncopy = 0; \ + *outp++ = '&'; \ + *outp++ = 'a'; \ + *outp++ = 'm'; \ + *outp++ = 'p'; \ + *outp++ = ';'; \ + break; \ + case '<': \ + memcpy(outp, inp-ncopy, sizeof(*outp)*ncopy); \ + outp += ncopy; ncopy = 0; \ + *outp++ = '&'; \ + *outp++ = 'l'; \ + *outp++ = 't'; \ + *outp++ = ';'; \ + break; \ + case '>': \ + memcpy(outp, inp-ncopy, sizeof(*outp)*ncopy); \ + outp += ncopy; ncopy = 0; \ + *outp++ = '&'; \ + *outp++ = 'g'; \ + *outp++ = 't'; \ + *outp++ = ';'; \ + break; \ + default: \ + ncopy++; \ + } \ + inp++; \ + } \ + memcpy(outp, inp-ncopy, sizeof(*outp)*ncopy); \ + } + +static PyObject* +escape_unicode_kind1(PyUnicodeObject *in) +{ + Py_UCS1 *inp = PyUnicode_1BYTE_DATA(in); + Py_UCS1 *inp_end = inp + PyUnicode_GET_LENGTH(in); + Py_UCS1 *outp; + PyObject *out; + Py_ssize_t delta = 0; + + GET_DELTA(inp, inp_end, delta); + if (!delta) { + Py_INCREF(in); + return (PyObject*)in; + } + + out = PyUnicode_New(PyUnicode_GET_LENGTH(in) + delta, + PyUnicode_IS_ASCII(in) ? 127 : 255); + if (!out) + return NULL; + + inp = PyUnicode_1BYTE_DATA(in); + outp = PyUnicode_1BYTE_DATA(out); + DO_ESCAPE(inp, inp_end, outp); + return out; +} + +static PyObject* +escape_unicode_kind2(PyUnicodeObject *in) +{ + Py_UCS2 *inp = PyUnicode_2BYTE_DATA(in); + Py_UCS2 *inp_end = inp + PyUnicode_GET_LENGTH(in); + Py_UCS2 *outp; + PyObject *out; + Py_ssize_t delta = 0; + + GET_DELTA(inp, inp_end, delta); + if (!delta) { + Py_INCREF(in); + return (PyObject*)in; + } + + out = PyUnicode_New(PyUnicode_GET_LENGTH(in) + delta, 65535); + if (!out) + return NULL; + + inp = PyUnicode_2BYTE_DATA(in); + outp = PyUnicode_2BYTE_DATA(out); + DO_ESCAPE(inp, inp_end, outp); + return out; +} + + +static PyObject* +escape_unicode_kind4(PyUnicodeObject *in) +{ + Py_UCS4 *inp = PyUnicode_4BYTE_DATA(in); + Py_UCS4 *inp_end = inp + PyUnicode_GET_LENGTH(in); + Py_UCS4 *outp; + PyObject *out; + Py_ssize_t delta = 0; + + GET_DELTA(inp, inp_end, delta); + if (!delta) { + Py_INCREF(in); + return (PyObject*)in; + } + + out = PyUnicode_New(PyUnicode_GET_LENGTH(in) + delta, 1114111); + if (!out) + return NULL; + + inp = PyUnicode_4BYTE_DATA(in); + outp = PyUnicode_4BYTE_DATA(out); + DO_ESCAPE(inp, inp_end, outp); + return out; +} + +static PyObject* +escape_unicode(PyUnicodeObject *in) +{ + if (PyUnicode_READY(in)) + return NULL; + + switch (PyUnicode_KIND(in)) { + case PyUnicode_1BYTE_KIND: + return escape_unicode_kind1(in); + case PyUnicode_2BYTE_KIND: + return escape_unicode_kind2(in); + case PyUnicode_4BYTE_KIND: + return escape_unicode_kind4(in); + } + assert(0); /* shouldn't happen */ + return NULL; +} + +static PyObject* +escape(PyObject *self, PyObject *text) +{ + static PyObject *id_html; + PyObject *s = NULL, *rv = NULL, *html; + + if (id_html == NULL) { + id_html = PyUnicode_InternFromString("__html__"); + if (id_html == NULL) { + return NULL; + } + } + + /* we don't have to escape integers, bools or floats */ + if (PyLong_CheckExact(text) || + PyFloat_CheckExact(text) || PyBool_Check(text) || + text == Py_None) + return PyObject_CallFunctionObjArgs(markup, text, NULL); + + /* if the object has an __html__ method that performs the escaping */ + html = PyObject_GetAttr(text ,id_html); + if (html) { + s = PyObject_CallObject(html, NULL); + Py_DECREF(html); + if (s == NULL) { + return NULL; + } + /* Convert to Markup object */ + rv = PyObject_CallFunctionObjArgs(markup, (PyObject*)s, NULL); + Py_DECREF(s); + return rv; + } + + /* otherwise make the object unicode if it isn't, then escape */ + PyErr_Clear(); + if (!PyUnicode_Check(text)) { + PyObject *unicode = PyObject_Str(text); + if (!unicode) + return NULL; + s = escape_unicode((PyUnicodeObject*)unicode); + Py_DECREF(unicode); + } + else + s = escape_unicode((PyUnicodeObject*)text); + + /* convert the unicode string into a markup object. */ + rv = PyObject_CallFunctionObjArgs(markup, (PyObject*)s, NULL); + Py_DECREF(s); + return rv; +} + + +static PyObject* +escape_silent(PyObject *self, PyObject *text) +{ + if (text != Py_None) + return escape(self, text); + return PyObject_CallFunctionObjArgs(markup, NULL); +} + + +static PyObject* +soft_str(PyObject *self, PyObject *s) +{ + if (!PyUnicode_Check(s)) + return PyObject_Str(s); + Py_INCREF(s); + return s; +} + + +static PyObject* +soft_unicode(PyObject *self, PyObject *s) +{ + PyErr_WarnEx( + PyExc_DeprecationWarning, + "'soft_unicode' has been renamed to 'soft_str'. The old name" + " will be removed in version 2.1.", + 2 + ); + return soft_str(self, s); +} + + +static PyMethodDef module_methods[] = { + { + "escape", + (PyCFunction)escape, + METH_O, + "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.\n\n" + "If the object has an ``__html__`` method, it is called and the" + " return value is assumed to already be safe for HTML.\n\n" + ":param s: An object to be converted to a string and escaped.\n" + ":return: A :class:`Markup` string with the escaped text.\n" + }, + { + "escape_silent", + (PyCFunction)escape_silent, + METH_O, + "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``.\n\n" + ">>> escape(None)\n" + "Markup('None')\n" + ">>> escape_silent(None)\n" + "Markup('')\n" + }, + { + "soft_str", + (PyCFunction)soft_str, + METH_O, + "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" + " again.\n\n" + ">>> value = escape(\"<User 1>\")\n" + ">>> value\n" + "Markup('<User 1>')\n" + ">>> escape(str(value))\n" + "Markup('&lt;User 1&gt;')\n" + ">>> escape(soft_str(value))\n" + "Markup('<User 1>')\n" + }, + { + "soft_unicode", + (PyCFunction)soft_unicode, + METH_O, + "" + }, + {NULL, NULL, 0, NULL} /* Sentinel */ +}; + +static struct PyModuleDef module_definition = { + PyModuleDef_HEAD_INIT, + "markupsafe._speedups", + NULL, + -1, + module_methods, + NULL, + NULL, + NULL, + NULL +}; + +PyMODINIT_FUNC +PyInit__speedups(void) +{ + if (!init_constants()) + return NULL; + + return PyModule_Create(&module_definition); +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7141547 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,42 @@ +import pytest + +from markupsafe import _native + +try: + from markupsafe import _speedups +except ImportError: + _speedups = None + + +@pytest.fixture( + scope="session", + params=( + _native, + pytest.param( + _speedups, + marks=pytest.mark.skipif(_speedups is None, reason="speedups unavailable"), + ), + ), +) +def _mod(request): + return request.param + + +@pytest.fixture(scope="session") +def escape(_mod): + return _mod.escape + + +@pytest.fixture(scope="session") +def escape_silent(_mod): + return _mod.escape_silent + + +@pytest.fixture(scope="session") +def soft_str(_mod): + return _mod.soft_str + + +@pytest.fixture(scope="session") +def soft_unicode(_mod): + return _mod.soft_unicode diff --git a/tests/test_escape.py b/tests/test_escape.py new file mode 100644 index 0000000..bf53fac --- /dev/null +++ b/tests/test_escape.py @@ -0,0 +1,29 @@ +import pytest + +from markupsafe import Markup + + +@pytest.mark.parametrize( + ("value", "expect"), + ( + # empty + ("", ""), + # ascii + ("abcd&><'\"efgh", "abcd&><'"efgh"), + ("&><'\"efgh", "&><'"efgh"), + ("abcd&><'\"", "abcd&><'""), + # 2 byte + ("こんにちは&><'\"こんばんは", "こんにちは&><'"こんばんは"), + ("&><'\"こんばんは", "&><'"こんばんは"), + ("こんにちは&><'\"", "こんにちは&><'""), + # 4 byte + ( + "\U0001F363\U0001F362&><'\"\U0001F37A xyz", + "\U0001F363\U0001F362&><'"\U0001F37A xyz", + ), + ("&><'\"\U0001F37A xyz", "&><'"\U0001F37A xyz"), + ("\U0001F363\U0001F362&><'\"", "\U0001F363\U0001F362&><'""), + ), +) +def test_escape(escape, value, expect): + assert escape(value) == Markup(expect) diff --git a/tests/test_exception_custom_html.py b/tests/test_exception_custom_html.py new file mode 100644 index 0000000..ec2f10b --- /dev/null +++ b/tests/test_exception_custom_html.py @@ -0,0 +1,18 @@ +import pytest + + +class CustomHtmlThatRaises: + def __html__(self): + raise ValueError(123) + + +def test_exception_custom_html(escape): + """Checks whether exceptions in custom __html__ implementations are + propagated correctly. + + There was a bug in the native implementation at some point: + https://github.com/pallets/markupsafe/issues/108 + """ + obj = CustomHtmlThatRaises() + with pytest.raises(ValueError): + escape(obj) diff --git a/tests/test_leak.py b/tests/test_leak.py new file mode 100644 index 0000000..55b10b9 --- /dev/null +++ b/tests/test_leak.py @@ -0,0 +1,28 @@ +import gc +import platform + +import pytest + +from markupsafe import escape + + +@pytest.mark.skipif( + escape.__module__ == "markupsafe._native", + reason="only test memory leak with speedups", +) +def test_markup_leaks(): + counts = set() + + for _i in range(20): + for _j in range(1000): + escape("foo") + escape("<foo>") + escape("foo") + escape("<foo>") + + if platform.python_implementation() == "PyPy": + gc.collect() + + counts.add(len(gc.get_objects())) + + assert len(counts) == 1 diff --git a/tests/test_markupsafe.py b/tests/test_markupsafe.py new file mode 100644 index 0000000..fcd9347 --- /dev/null +++ b/tests/test_markupsafe.py @@ -0,0 +1,185 @@ +import pytest + +from markupsafe import Markup + + +def test_adding(escape): + unsafe = '<script type="application/x-some-script">alert("foo");</script>' + safe = Markup("<em>username</em>") + assert unsafe + safe == str(escape(unsafe)) + str(safe) + + +@pytest.mark.parametrize( + ("template", "data", "expect"), + ( + ("<em>%s</em>", "<bad user>", "<em><bad user></em>"), + ( + "<em>%(username)s</em>", + {"username": "<bad user>"}, + "<em><bad user></em>", + ), + ("%i", 3.14, "3"), + ("%.2f", 3.14, "3.14"), + ), +) +def test_string_interpolation(template, data, expect): + assert Markup(template) % data == expect + + +def test_type_behavior(): + assert type(Markup("foo") + "bar") is Markup + x = Markup("foo") + assert x.__html__() is x + + +def test_html_interop(): + class Foo: + def __html__(self): + return "<em>awesome</em>" + + def __str__(self): + return "awesome" + + assert Markup(Foo()) == "<em>awesome</em>" + result = Markup("<strong>%s</strong>") % Foo() + assert result == "<strong><em>awesome</em></strong>" + + +def test_tuple_interpol(): + result = Markup("<em>%s:%s</em>") % ("<foo>", "<bar>") + expect = Markup("<em><foo>:<bar></em>") + assert result == expect + + +def test_dict_interpol(): + result = Markup("<em>%(foo)s</em>") % {"foo": "<foo>"} + expect = Markup("<em><foo></em>") + assert result == expect + + result = Markup("<em>%(foo)s:%(bar)s</em>") % {"foo": "<foo>", "bar": "<bar>"} + expect = Markup("<em><foo>:<bar></em>") + assert result == expect + + +def test_escaping(escape): + assert escape("\"<>&'") == ""<>&'" + assert Markup("<em>Foo & Bar</em>").striptags() == "Foo & Bar" + + +def test_unescape(): + assert Markup("<test>").unescape() == "<test>" + + result = Markup("jack & tavi are cooler than mike & russ").unescape() + expect = "jack & tavi are cooler than mike & russ" + assert result == expect + + original = "&foo;" + once = Markup(original).unescape() + twice = Markup(once).unescape() + expect = "&foo;" + assert once == expect + assert twice == expect + + +def test_format(): + result = Markup("<em>{awesome}</em>").format(awesome="<awesome>") + assert result == "<em><awesome></em>" + + result = Markup("{0[1][bar]}").format([0, {"bar": "<bar/>"}]) + assert result == "<bar/>" + + result = Markup("{0[1][bar]}").format([0, {"bar": Markup("<bar/>")}]) + assert result == "<bar/>" + + +def test_formatting_empty(): + formatted = Markup("{}").format(0) + assert formatted == Markup("0") + + +def test_custom_formatting(): + class HasHTMLOnly: + def __html__(self): + return Markup("<foo>") + + class HasHTMLAndFormat: + def __html__(self): + return Markup("<foo>") + + def __html_format__(self, spec): + return Markup("<FORMAT>") + + assert Markup("{0}").format(HasHTMLOnly()) == Markup("<foo>") + assert Markup("{0}").format(HasHTMLAndFormat()) == Markup("<FORMAT>") + + +def test_complex_custom_formatting(): + class User: + def __init__(self, id, username): + self.id = id + self.username = username + + def __html_format__(self, format_spec): + if format_spec == "link": + return Markup('<a href="/user/{0}">{1}</a>').format( + self.id, self.__html__() + ) + elif format_spec: + raise ValueError("Invalid format spec") + + return self.__html__() + + def __html__(self): + return Markup("<span class=user>{0}</span>").format(self.username) + + user = User(1, "foo") + result = Markup("<p>User: {0:link}").format(user) + expect = Markup('<p>User: <a href="/user/1"><span class=user>foo</span></a>') + assert result == expect + + +def test_formatting_with_objects(): + class Stringable: + def __str__(self): + return "строка" + + assert Markup("{s}").format(s=Stringable()) == Markup("строка") + + +def test_escape_silent(escape, escape_silent): + assert escape_silent(None) == Markup() + assert escape(None) == Markup(None) + assert escape_silent("<foo>") == Markup("<foo>") + + +def test_splitting(): + expect = [Markup("a"), Markup("b")] + assert Markup("a b").split() == expect + assert Markup("a b").rsplit() == expect + assert Markup("a\nb").splitlines() == expect + + +def test_mul(): + assert Markup("a") * 3 == Markup("aaa") + + +def test_escape_return_type(escape): + assert isinstance(escape("a"), Markup) + assert isinstance(escape(Markup("a")), Markup) + + class Foo: + def __html__(self): + return "<strong>Foo</strong>" + + assert isinstance(escape(Foo()), Markup) + + +def test_soft_str(soft_str): + assert type(soft_str("")) is str + assert type(soft_str(Markup())) is Markup + assert type(soft_str(15)) is str + + +def test_soft_unicode_deprecated(soft_unicode): + with pytest.warns(DeprecationWarning): + assert type(soft_unicode(Markup())) is Markup @@ -0,0 +1,19 @@ +[tox] +envlist = + py{38,37,36,py3} + style + docs +skip_missing_interpreters = true + +[testenv] +deps = -r requirements/tests.txt +commands = pytest --tb=short --basetemp={envtmpdir} {posargs} + +[testenv:style] +deps = pre-commit +skip_install = true +commands = pre-commit run --all-files --show-diff-on-failure + +[testenv:docs] +deps = -r requirements/docs.txt +commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html |