├── .coveragerc ├── .flake8 ├── .github └── workflows │ └── python.yml ├── .gitignore ├── .therapist.yml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── deploy.sh ├── premailer ├── __init__.py ├── __main__.py ├── cache.py ├── merge_style.py ├── premailer.py └── tests │ ├── test-apple-newsletter.html │ ├── test-external-links.css │ ├── test-external-styles.css │ ├── test-issue78.html │ ├── test-unicode.html │ ├── test_cache.py │ ├── test_merge_style.py │ ├── test_premailer.py │ └── test_utils.py ├── setup.cfg ├── setup.py ├── stresstest ├── README.md ├── run.py └── samples │ ├── 001 │ ├── input.html │ └── output.html │ ├── 002 │ ├── input.html │ └── output.html │ ├── 003 │ ├── input.html │ ├── options.json │ └── output.html │ ├── 004 │ ├── input.html │ ├── options.json │ └── output.html │ └── 005 │ ├── input.html │ └── output.html └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = premailer 3 | 4 | [report] 5 | omit = premailer/test* 6 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | # Based on 2 | # https://pypi.org/project/tox-gh-actions/ 3 | 4 | name: Python 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "pypy3"] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install build dependencies 23 | run: sudo apt-get install -y libxml2-dev libxslt-dev 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install tox tox-gh-actions 29 | - name: Test with tox 30 | run: tox -v 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | dist 4 | build/ 5 | *.egg 6 | premailer.egg-info 7 | premailer/gorunsettings.py 8 | .DS_Store 9 | .coverage 10 | .tox 11 | /htmlcov/ 12 | .eggs 13 | -------------------------------------------------------------------------------- /.therapist.yml: -------------------------------------------------------------------------------- 1 | actions: 2 | black: 3 | run: black --check --diff {files} 4 | fix: black {files} 5 | include: "*.py" 6 | 7 | flake8: 8 | run: flake8 {files} 9 | include: "*.py" 10 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | premailer Changes 2 | ================= 3 | 4 | Peter's note: Unfortunately, ``premailer`` didn't use to keep a change log. But it's 5 | never too late to start, so let's start here and now. 6 | 7 | 3.10.0 8 | ------ 9 | 10 | * New option ``session=None`` to provide the session used for making http requests. 11 | 12 | * Bug fix: inlined styles are no longer sorted alphabetically. This preserves the input 13 | rule order so that premailer does not break style precedence where order is significant, e.g. 14 | 15 | .. code-block:: css 16 | 17 | div { 18 | /* Padding on all sides is 10px. */ 19 | padding-left: 5px; 20 | padding: 10px; 21 | } 22 | 23 | div { 24 | /* Padding on the left side is 5px, on other sides is 10px. */ 25 | padding: 10px; 26 | padding-left: 5px; 27 | } 28 | 29 | Prior to this fix premailer would swap the rules in the first example to look like the second. 30 | 31 | 32 | 3.9.0 33 | ----- 34 | 35 | * New option ``allow_loading_external_files=False`` when loading externally 36 | referenced file URLs. E.g. ```` 37 | Be careful to enable this if the HTML loaded isn't trusted. **Big security risk 38 | otherwise**. 39 | 40 | 3.8.0 41 | ----- 42 | 43 | * Add ``preserve_handlebar_syntax`` option. 44 | See https://github.com/peterbe/premailer/pull/252 45 | Thanks @CraigRobertWhite 46 | 47 | * Switch to GitHub Actions instead of TravisCI 48 | See https://github.com/peterbe/premailer/pull/253 49 | 50 | 3.7.0 51 | ----- 52 | 53 | * Drop support for Python 2.7 and 3.4. Add test support for 3.8 54 | 55 | 3.6.2 56 | ----- 57 | 58 | * Don't strip ``!important`` on stylesheets that are ignored 59 | See https://github.com/peterbe/premailer/pull/242 60 | Thanks @nshenkman 61 | 62 | 3.6.1 63 | ----- 64 | 65 | * The ``disable_validation`` wasn't passed to ``csstest_to_pairs`` 66 | See https://github.com/peterbe/premailer/pull/235 67 | Thanks @mbenedettini 68 | 69 | 3.6.0 70 | ----- 71 | 72 | * Add ``allow_insecure_ssl`` option for external URLs 73 | 74 | 3.5.0 75 | ----- 76 | 77 | * Change default ``cachetools`` implementation to ``cachetools.LFUCache``. 78 | 79 | * Now possible to change ``cachetools`` implementation with environment variables. 80 | See README.rst. 81 | 82 | * To avoid thread unsafe execution, the function caching decorator now employs a lock. 83 | See https://github.com/peterbe/premailer/issues/225 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2012, Peter Bengtsson 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of Peter Bengtsson nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 16 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, 17 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 18 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Peter Bengtsson OR CONTRIBUTORS 19 | BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 20 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 21 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 22 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 23 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 24 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 25 | POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | recursive-include premailer/tests/* 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | premailer 2 | ========= 3 | 4 | .. image:: https://travis-ci.org/peterbe/premailer.svg?branch=master 5 | :target: https://travis-ci.org/peterbe/premailer 6 | 7 | .. image:: https://badge.fury.io/py/premailer.svg 8 | :target: https://pypi.python.org/pypi/premailer 9 | 10 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 11 | :target: https://github.com/ambv/black 12 | 13 | Looking for sponsors 14 | -------------------- 15 | 16 | This project is actively looking for corporate sponsorship. If you want 17 | to help making this an active project consider `pinging 18 | Peter `__ and we can talk about putting 19 | up logos and links to your company. 20 | 21 | Python versions 22 | --------------- 23 | 24 | Our 25 | `tox.ini `__ 26 | makes sure premailer works in: 27 | 28 | - Python 3.4 29 | - Python 3.5 30 | - Python 3.6 31 | - Python 3.7 32 | - Python 3.8 33 | - PyPy 34 | 35 | Turns CSS blocks into style attributes 36 | -------------------------------------- 37 | 38 | When you send HTML emails you can't use style tags but instead you have 39 | to put inline ``style`` attributes on every element. So from this: 40 | 41 | .. code:: html 42 | 43 | 44 | 48 |

Peter

49 |

Hej

50 | 51 | 52 | You want this: 53 | 54 | .. code:: html 55 | 56 | 57 |

Peter

58 |

Hej

59 | 60 | 61 | premailer does this. It parses an HTML page, looks up ``style`` blocks 62 | and parses the CSS. It then uses the ``lxml.html`` parser to modify the 63 | DOM tree of the page accordingly. 64 | 65 | Warning! 66 | By default, premailer will attempt to download any external stylesheets by URL over the Internet. 67 | If you want to prevent this you can use the ``allow_network=False`` option. 68 | 69 | Getting started 70 | --------------- 71 | 72 | If you haven't already done so, install ``premailer`` first: 73 | 74 | :: 75 | 76 | $ pip install premailer 77 | 78 | Next, the most basic use is to use the shortcut function, like this: 79 | 80 | .. code:: python 81 | 82 | >>> from premailer import transform 83 | >>> print(transform(""" 84 | ... 85 | ... 90 | ... 93 | ...

Peter

94 | ...

Hej

95 | ... 96 | ... """)) 97 | 98 | 99 | 100 | 103 | 104 | 105 |

Peter

106 |

Hej

107 | 108 | 109 | 110 | The ``transform`` shortcut function transforms the given HTML using the defaults for all options: 111 | 112 | .. code:: python 113 | 114 | base_url=None, # Optional URL prepended to all relative links (both stylesheets and internal) 115 | disable_link_rewrites=False, # Allow link rewrites (e.g. using base_url) 116 | preserve_internal_links=False, # Do not preserve links to named anchors when using base_url 117 | preserve_inline_attachments=True, # Preserve links with cid: scheme when base_url is specified 118 | preserve_handlebar_syntax=False # Preserve handlebar syntax from being encoded 119 | exclude_pseudoclasses=True, # Ignore pseudoclasses when processing styles 120 | keep_style_tags=False, # Discard original style tag 121 | include_star_selectors=False, # Ignore star selectors when processing styles 122 | remove_classes=False, # Leave class attributes on HTML elements 123 | capitalize_float_margin=False, # Do not capitalize float and margin properties 124 | strip_important=True, # Remove !important from property values 125 | external_styles=None, # Optional list of URLs to load and parse 126 | css_text=None, # Optional CSS text to parse 127 | method="html", # Parse input as HTML (as opposed to "xml") 128 | base_path=None, # Optional base path to stylesheet in your file system 129 | disable_basic_attributes=None, # Optional list of attribute names to preserve on HTML elements 130 | disable_validation=False, # Validate CSS when parsing it with cssutils 131 | cache_css_parsing=True, # Do cache parsed output for CSS 132 | cssutils_logging_handler=None, # See "Capturing logging from cssutils" below 133 | cssutils_logging_level=None, 134 | disable_leftover_css=False, # Output CSS that was not inlined into the HEAD 135 | align_floating_images=True, # Add align attribute for floated images 136 | remove_unset_properties=True # Remove CSS properties if their value is unset when merged 137 | allow_network=True # allow network access to fetch linked css files 138 | allow_insecure_ssl=False # Don't allow unverified SSL certificates for external links 139 | allow_loading_external_files=False # Allow loading any non-HTTP external file URL 140 | session=None # Session used for http requests - supply your own for caching or to provide authentication 141 | 142 | For more advanced options, check out the code of the ``Premailer`` class 143 | and all its options in its constructor. 144 | 145 | You can also use premailer from the command line by using its main 146 | module. 147 | 148 | :: 149 | 150 | $ python -m premailer -h 151 | usage: python -m premailer [options] 152 | 153 | optional arguments: 154 | -h, --help show this help message and exit 155 | -f [INFILE], --file [INFILE] 156 | Specifies the input file. The default is stdin. 157 | -o [OUTFILE], --output [OUTFILE] 158 | Specifies the output file. The default is stdout. 159 | --base-url BASE_URL 160 | --remove-internal-links PRESERVE_INTERNAL_LINKS 161 | Remove links that start with a '#' like anchors. 162 | --exclude-pseudoclasses 163 | Pseudo classes like p:last-child', p:first-child, etc 164 | --preserve-style-tags 165 | Do not delete tags from the html 166 | document. 167 | --remove-star-selectors 168 | All wildcard selectors like '* {color: black}' will be 169 | removed. 170 | --remove-classes Remove all class attributes from all elements 171 | --strip-important Remove '!important' for all css declarations. 172 | --method METHOD The type of html to output. 'html' for HTML, 'xml' for 173 | XHTML. 174 | --base-path BASE_PATH 175 | The base path for all external stylsheets. 176 | --external-style EXTERNAL_STYLES 177 | The path to an external stylesheet to be loaded. 178 | --disable-basic-attributes DISABLE_BASIC_ATTRIBUTES 179 | Disable provided basic attributes (comma separated) 180 | --disable-validation Disable CSSParser validation of attributes and values 181 | --pretty Pretty-print the outputted HTML. 182 | --allow-insecure-ssl Skip SSL certificate verification for external URLs. 183 | --allow-loading-external-files Allow opening any non-HTTP external file URL. 184 | 185 | A basic example: 186 | 187 | :: 188 | 189 | $ python -m premailer --base-url=http://google.com/ -f newsletter.html 190 | 191 | 192 |

Title

193 | 194 | 195 | The command line interface supports standard input. 196 | 197 | :: 198 | 199 | $ echo '

Title

' | python -m premailer --base-url=http://google.com/ 200 | 201 | 202 |

Title

203 | 204 | 205 | Turning relative URLs into absolute URLs 206 | ---------------------------------------- 207 | 208 | Another thing premailer can do for you is to turn relative URLs (e.g. 209 | "/some/page.html" into "http://www.peterbe.com/some/page.html"). It does 210 | this to all ``href`` and ``src`` attributes that don't have a ``://`` 211 | part in it. For example, turning this: 212 | 213 | .. code:: html 214 | 215 | 216 | 217 | Home 218 | Page 219 | External 220 | Folder 221 | 222 | 223 | 224 | Into this: 225 | 226 | .. code:: html 227 | 228 | 229 | 230 | Home 231 | Page 232 | External 233 | Folder 234 | 235 | 236 | 237 | by using ``transform('...', base_url='http://www.peterbe.com/')``. 238 | 239 | Ignore certain `` 249 | 250 | 251 | 252 | That tag gets completely ignored except when the HTML is processed, the 253 | attribute ``data-premailer`` is removed. 254 | 255 | It works equally for a ```` tag like: 256 | 257 | .. code:: html 258 | 259 | 260 | 261 | 262 | 263 | HTML attributes created additionally 264 | ------------------------------------ 265 | 266 | Certain HTML attributes are also created on the HTML if the CSS contains 267 | any ones that are easily translated into HTML attributes. For example, 268 | if you have this CSS: ``td { background-color:#eee; }`` then this is 269 | transformed into ``style="background-color:#eee"`` and as an HTML 270 | attribute ``bgcolor="#eee"``. 271 | 272 | Having these extra attributes basically as a "back up" for really shit 273 | email clients that can't even take the style attributes. A lot of 274 | professional HTML newsletters such as Amazon's use this. You can disable 275 | some attributes in ``disable_basic_attributes``. 276 | 277 | 278 | Capturing logging from ``cssutils`` 279 | ----------------------------------- 280 | 281 | `cssutils `__ is the library that 282 | ``premailer`` uses to parse CSS. It will use the python ``logging`` module 283 | to mention all issues it has with parsing your CSS. If you want to capture 284 | this, you have to pass in ``cssutils_logging_handler`` and 285 | ``cssutils_logging_level`` (optional). For example like this: 286 | 287 | .. code:: python 288 | 289 | >>> import logging 290 | >>> import premailer 291 | >>> from io import StringIO 292 | >>> mylog = StringIO() 293 | >>> myhandler = logging.StreamHandler(mylog) 294 | >>> p = premailer.Premailer( 295 | ... cssutils_logging_handler=myhandler, 296 | ... cssutils_logging_level=logging.INFO 297 | ... ) 298 | >>> result = p.transform(""" 299 | ... 300 | ... 303 | ...

Hej

304 | ... 305 | ... """) 306 | >>> mylog.getvalue() 307 | 'CSSStylesheet: Unknown @rule found. [2:1: @keyframes]\n' 308 | 309 | 310 | If execution speed is on your mind 311 | ---------------------------------- 312 | 313 | If execution speed is important, it's very plausible that you're not just converting 314 | 1 HTML document but *a lot* of HTML documents. Then, the first thing you should do 315 | is avoid using the ``premailer.transform`` function because it creates a ``Premailer`` 316 | class instance every time. 317 | 318 | .. code:: python 319 | 320 | # WRONG WAY! 321 | from premailer import transform 322 | 323 | for html_string in get_html_documents(): 324 | transformed = transform(html_string, base_url=MY_BASE_URL) 325 | # do something with 'transformed' 326 | 327 | Instead... 328 | 329 | .. code:: python 330 | 331 | # RIGHT WAY 332 | from premailer import Premailer 333 | 334 | instance = Premailer(base_url=MY_BASE_URL) 335 | for html_string in get_html_documents(): 336 | transformed = instance.transform(html_string) 337 | # do something with 'transformed' 338 | 339 | Another thing to watch out for when you're reusing the same imported Python code 340 | and reusing it is that internal memoize function caches might build up. The 341 | environment variable to control is ``PREMAILER_CACHE_MAXSIZE``. This parameter 342 | requires a little bit of fine-tuning and calibration if your workload is really 343 | big and memory even becomes an issue. 344 | 345 | Advanced options 346 | ---------------- 347 | 348 | Below are some advanced configuration options that probably doesn't matter for 349 | most people with regular load. 350 | 351 | Choosing the cache implementation 352 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 353 | 354 | By default, ``premailer`` uses `LFUCache 355 | `__ to cache 356 | selectors, styles and parsed CSS strings. If LFU doesn't serve your purpose, it 357 | is possible to switch to an alternate implementation using below environment 358 | variables. 359 | 360 | - ``PREMAILER_CACHE``: Can be LRU, LFU or TTL. Default is LFU. 361 | - ``PREMAILER_CACHE_MAXSIZE``: Maximum no. of items to be stored in cache. Defaults to 128. 362 | - ``PREMAILER_CACHE_TTL``: Time to live for cache entries. Only applicable for TTL cache. Defaults to 1 hour. 363 | 364 | 365 | Getting coding 366 | -------------- 367 | 368 | First clone the code and create whatever virtualenv you need, then run: 369 | 370 | .. code:: bash 371 | 372 | pip install -e ".[dev]" 373 | 374 | 375 | Then to run the tests, run: 376 | 377 | .. code:: bash 378 | 379 | tox 380 | 381 | This will run the *whole test suite* for every possible version of Python 382 | it can find on your system. To run the tests more incrementally, open 383 | up the ``tox.ini`` and see how it works. 384 | 385 | Code style is all black 386 | ----------------------- 387 | 388 | All code has to be formatted with `Black `_ 389 | and the best tool for checking this is 390 | `therapist `_ since it can help you run 391 | all, help you fix things, and help you make sure linting is passing before 392 | you git commit. This project also uses ``flake8`` to check other things 393 | Black can't check. 394 | 395 | To check linting with ``tox`` use: 396 | 397 | .. code:: bash 398 | 399 | tox -e lint 400 | 401 | To install the ``therapist`` pre-commit hook simply run: 402 | 403 | .. code:: bash 404 | 405 | therapist install 406 | 407 | When you run ``therapist run`` it will only check the files you've touched. 408 | To run it for all files use: 409 | 410 | .. code:: bash 411 | 412 | therapist run --use-tracked-files 413 | 414 | And to fix all/any issues run: 415 | 416 | .. code:: bash 417 | 418 | therapist run --use-tracked-files --fix 419 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | 4 | # From https://pypi.org/project/twine/ 5 | 6 | rm -fr dist/ 7 | python setup.py sdist bdist_wheel 8 | twine upload dist/* 9 | -------------------------------------------------------------------------------- /premailer/__init__.py: -------------------------------------------------------------------------------- 1 | from .premailer import Premailer, transform # noqa 2 | 3 | __version__ = "3.10.0" 4 | -------------------------------------------------------------------------------- /premailer/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | 4 | from .premailer import Premailer 5 | 6 | 7 | def main(args): 8 | """Command-line tool to transform html style to inline css 9 | 10 | Usage:: 11 | 12 | $ echo '

Title

' | \ 13 | python -m premailer 14 |

15 | $ cat newsletter.html | python -m premailer 16 | """ 17 | 18 | parser = argparse.ArgumentParser(usage="python -m premailer [options]") 19 | 20 | parser.add_argument( 21 | "-f", 22 | "--file", 23 | nargs="?", 24 | type=argparse.FileType("r"), 25 | help="Specifies the input file. The default is stdin.", 26 | default=sys.stdin, 27 | dest="infile", 28 | ) 29 | 30 | parser.add_argument( 31 | "-o", 32 | "--output", 33 | nargs="?", 34 | type=argparse.FileType("w"), 35 | help="Specifies the output file. The default is stdout.", 36 | default=sys.stdout, 37 | dest="outfile", 38 | ) 39 | 40 | parser.add_argument("--base-url", default=None, type=str, dest="base_url") 41 | 42 | parser.add_argument( 43 | "--remove-internal-links", 44 | default=True, 45 | help="Remove links that start with a '#' like anchors.", 46 | dest="preserve_internal_links", 47 | ) 48 | 49 | parser.add_argument( 50 | "--exclude-pseudoclasses", 51 | default=False, 52 | help="Pseudo classes like p:last-child', p:first-child, etc", 53 | action="store_true", 54 | dest="exclude_pseudoclasses", 55 | ) 56 | 57 | parser.add_argument( 58 | "--preserve-style-tags", 59 | default=False, 60 | help="Do not delete tags from the html document.", 61 | action="store_true", 62 | dest="keep_style_tags", 63 | ) 64 | 65 | parser.add_argument( 66 | "--remove-star-selectors", 67 | default=True, 68 | help="All wildcard selectors like '* {color: black}' will be removed.", 69 | action="store_false", 70 | dest="include_star_selectors", 71 | ) 72 | 73 | parser.add_argument( 74 | "--remove-classes", 75 | default=False, 76 | help="Remove all class attributes from all elements", 77 | action="store_true", 78 | dest="remove_classes", 79 | ) 80 | 81 | parser.add_argument( 82 | "--capitalize-float-margin", 83 | default=False, 84 | help="Capitalize float and margin properties for outlook.com compat.", 85 | action="store_true", 86 | dest="capitalize_float_margin", 87 | ) 88 | 89 | parser.add_argument( 90 | "--strip-important", 91 | default=False, 92 | help="Remove '!important' for all css declarations.", 93 | action="store_true", 94 | dest="strip_important", 95 | ) 96 | 97 | parser.add_argument( 98 | "--method", 99 | default="html", 100 | dest="method", 101 | help="The type of html to output. 'html' for HTML, 'xml' for XHTML.", 102 | ) 103 | 104 | parser.add_argument( 105 | "--base-path", 106 | default=None, 107 | dest="base_path", 108 | help="The base path for all external stylsheets.", 109 | ) 110 | 111 | parser.add_argument( 112 | "--external-style", 113 | action="append", 114 | dest="external_styles", 115 | help="The path to an external stylesheet to be loaded.", 116 | ) 117 | 118 | parser.add_argument( 119 | "--css-text", 120 | action="append", 121 | dest="css_text", 122 | help="CSS text to be applied to the html.", 123 | ) 124 | 125 | parser.add_argument( 126 | "--disable-basic-attributes", 127 | dest="disable_basic_attributes", 128 | help="Disable provided basic attributes (comma separated)", 129 | default=[], 130 | ) 131 | 132 | parser.add_argument( 133 | "--disable-validation", 134 | default=False, 135 | action="store_true", 136 | dest="disable_validation", 137 | help="Disable CSSParser validation of attributes and values", 138 | ) 139 | 140 | parser.add_argument( 141 | "--pretty", 142 | default=False, 143 | action="store_true", 144 | help="Pretty-print the outputted HTML.", 145 | ) 146 | 147 | parser.add_argument( 148 | "--encoding", default="utf-8", help="Output encoding. The default is utf-8" 149 | ) 150 | 151 | parser.add_argument( 152 | "--allow-insecure-ssl", 153 | default=False, 154 | action="store_true", 155 | help="Skip SSL certificate verification for external URLs.", 156 | ) 157 | 158 | parser.add_argument( 159 | "--allow-loading-external-files", 160 | default=False, 161 | action="store_true", 162 | help="Allow opening any non-HTTP external file URL.", 163 | ) 164 | 165 | options = parser.parse_args(args) 166 | 167 | if options.disable_basic_attributes: 168 | options.disable_basic_attributes = options.disable_basic_attributes.split() 169 | 170 | html = options.infile.read() 171 | if hasattr(html, "decode"): # Forgive me: Python 2 compatability 172 | html = html.decode("utf-8") 173 | 174 | p = Premailer( 175 | html=html, 176 | base_url=options.base_url, 177 | preserve_internal_links=options.preserve_internal_links, 178 | exclude_pseudoclasses=options.exclude_pseudoclasses, 179 | keep_style_tags=options.keep_style_tags, 180 | include_star_selectors=options.include_star_selectors, 181 | remove_classes=options.remove_classes, 182 | strip_important=options.strip_important, 183 | external_styles=options.external_styles, 184 | css_text=options.css_text, 185 | method=options.method, 186 | base_path=options.base_path, 187 | disable_basic_attributes=options.disable_basic_attributes, 188 | disable_validation=options.disable_validation, 189 | allow_insecure_ssl=options.allow_insecure_ssl, 190 | allow_loading_external_files=options.allow_loading_external_files, 191 | ) 192 | options.outfile.write( 193 | p.transform(encoding=options.encoding, pretty_print=options.pretty) 194 | ) 195 | return 0 196 | 197 | 198 | if __name__ == "__main__": # pragma: no cover 199 | sys.exit(main(sys.argv[1:])) 200 | -------------------------------------------------------------------------------- /premailer/cache.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | import threading 4 | 5 | import cachetools 6 | 7 | 8 | # Available cache options. 9 | CACHE_IMPLEMENTATIONS = { 10 | "LFU": cachetools.LFUCache, 11 | "LRU": cachetools.LRUCache, 12 | "TTL": cachetools.TTLCache, 13 | } 14 | 15 | # Time to live (seconds) for entries in TTL cache. Defaults to 1 hour. 16 | TTL_CACHE_TIMEOUT = 1 * 60 * 60 17 | 18 | # Maximum no. of items to be saved in cache. 19 | DEFAULT_CACHE_MAXSIZE = 128 20 | 21 | # Lock to prevent multiple threads from accessing the cache at same time. 22 | cache_access_lock = threading.RLock() 23 | 24 | cache_type = os.environ.get("PREMAILER_CACHE", "LFU") 25 | if cache_type not in CACHE_IMPLEMENTATIONS: 26 | raise ValueError( 27 | "Unsupported cache implementation. Available options: %s" 28 | % "/".join(CACHE_IMPLEMENTATIONS.keys()) 29 | ) 30 | 31 | cache_init_options = { 32 | "maxsize": int(os.environ.get("PREMAILER_CACHE_MAXSIZE", DEFAULT_CACHE_MAXSIZE)) 33 | } 34 | if cache_type == "TTL": 35 | cache_init_options["ttl"] = int( 36 | os.environ.get("PREMAILER_CACHE_TTL", TTL_CACHE_TIMEOUT) 37 | ) 38 | 39 | cache = CACHE_IMPLEMENTATIONS[cache_type](**cache_init_options) 40 | 41 | 42 | def function_cache(**options): 43 | def decorator(func): 44 | @cachetools.cached(cache, lock=cache_access_lock) 45 | @functools.wraps(func) 46 | def inner(*args, **kwargs): 47 | return func(*args, **kwargs) 48 | 49 | return inner 50 | 51 | return decorator 52 | -------------------------------------------------------------------------------- /premailer/merge_style.py: -------------------------------------------------------------------------------- 1 | import cssutils 2 | import threading 3 | from collections import OrderedDict 4 | 5 | from premailer.cache import function_cache 6 | 7 | 8 | def format_value(prop): 9 | if prop.priority == "important": 10 | return prop.propertyValue.cssText.strip() + " !important" 11 | else: 12 | return prop.propertyValue.cssText.strip() 13 | 14 | 15 | @function_cache() 16 | def csstext_to_pairs(csstext, validate=True): 17 | """ 18 | csstext_to_pairs takes css text and make it to list of 19 | tuple of key,value. 20 | """ 21 | # The lock is required to avoid ``cssutils`` concurrency 22 | # issues documented in issue #65 23 | with csstext_to_pairs._lock: 24 | return [ 25 | (prop.name.strip(), format_value(prop)) 26 | for prop in cssutils.parseStyle(csstext, validate=validate) 27 | ] 28 | 29 | 30 | csstext_to_pairs._lock = threading.RLock() 31 | 32 | 33 | def merge_styles(inline_style, new_styles, classes, remove_unset_properties=False): 34 | """ 35 | This will merge all new styles where the order is important 36 | The last one will override the first 37 | When that is done it will apply old inline style again 38 | The old inline style is always important and override 39 | all new ones. The inline style must be valid. 40 | 41 | Args: 42 | inline_style(str): the old inline style of the element if there 43 | is one 44 | new_styles: a list of new styles, each element should be 45 | a list of tuple 46 | classes: a list of classes which maps new_styles, important! 47 | remove_unset_properties(bool): Allow us to remove certain CSS 48 | properties with rules that set their value to 'unset' 49 | 50 | Returns: 51 | str: the final style 52 | """ 53 | # building classes 54 | styles = OrderedDict([("", OrderedDict())]) 55 | for pc in set(classes): 56 | styles[pc] = OrderedDict() 57 | 58 | for i, style in enumerate(new_styles): 59 | for k, v in style: 60 | styles[classes[i]][k] = v 61 | 62 | # keep always the old inline style 63 | if inline_style: 64 | # inline should be a declaration list as I understand 65 | # ie property-name:property-value;... 66 | for k, v in csstext_to_pairs(inline_style): 67 | styles[""][k] = v 68 | 69 | normal_styles = [] 70 | pseudo_styles = [] 71 | for pseudoclass, kv in styles.items(): 72 | if remove_unset_properties: 73 | # Remove rules that we were going to have value 'unset' because 74 | # they effectively are the same as not saying anything about the 75 | # property when inlined 76 | kv = OrderedDict( 77 | (k, v) for (k, v) in kv.items() if not v.lower() == "unset" 78 | ) 79 | if not kv: 80 | continue 81 | if pseudoclass: 82 | pseudo_styles.append( 83 | "%s{%s}" 84 | % (pseudoclass, "; ".join("%s:%s" % (k, v) for k, v in kv.items())) 85 | ) 86 | else: 87 | normal_styles.append("; ".join("%s:%s" % (k, v) for k, v in kv.items())) 88 | 89 | if pseudo_styles: 90 | # if we do or code thing correct this should not happen 91 | # inline style definition: declarations without braces 92 | all_styles = ( 93 | (["{%s}" % "".join(normal_styles)] + pseudo_styles) 94 | if normal_styles 95 | else pseudo_styles 96 | ) 97 | else: 98 | all_styles = normal_styles 99 | 100 | return " ".join(all_styles).strip() 101 | -------------------------------------------------------------------------------- /premailer/premailer.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import operator 3 | import os 4 | import re 5 | import warnings 6 | from collections import OrderedDict 7 | from html import escape, unescape 8 | from urllib.parse import urljoin, urlparse, unquote 9 | 10 | import cssutils 11 | import requests 12 | from lxml import etree 13 | from lxml.cssselect import CSSSelector 14 | 15 | from premailer.cache import function_cache 16 | from premailer.merge_style import csstext_to_pairs, merge_styles 17 | 18 | 19 | __all__ = ["PremailerError", "Premailer", "transform"] 20 | 21 | 22 | class PremailerError(Exception): 23 | pass 24 | 25 | 26 | class ExternalNotFoundError(ValueError): 27 | pass 28 | 29 | 30 | class ExternalFileLoadingError(Exception): 31 | pass 32 | 33 | 34 | def make_important(bulk): 35 | """makes every property in a string !important.""" 36 | return ";".join( 37 | "%s !important" % p if not p.endswith("!important") else p 38 | for p in bulk.split(";") 39 | ) 40 | 41 | 42 | def get_or_create_head(root): 43 | """Ensures that `root` contains a element and returns it.""" 44 | head = _create_cssselector("head")(root) 45 | if not head: 46 | head = etree.Element("head") 47 | body = _create_cssselector("body")(root)[0] 48 | body.getparent().insert(0, head) 49 | return head 50 | else: 51 | return head[0] 52 | 53 | 54 | @function_cache() 55 | def _cache_parse_css_string(css_body, validate=True): 56 | """ 57 | This function will cache the result from cssutils 58 | It is a big gain when number of rules is big 59 | Maximum cache entries are 1000. This is mainly for 60 | protecting memory leak in case something gone wild. 61 | Be aware that you can turn the cache off in Premailer 62 | 63 | Args: 64 | css_body(str): css rules in string format 65 | validate(bool): if cssutils should validate 66 | 67 | Returns: 68 | cssutils.css.cssstylesheet.CSSStyleSheet 69 | 70 | """ 71 | return cssutils.parseString(css_body, validate=validate) 72 | 73 | 74 | @function_cache() 75 | def _create_cssselector(selector): 76 | return CSSSelector(selector) 77 | 78 | 79 | def capitalize_float_margin(css_body): 80 | """Capitalize float and margin CSS property names""" 81 | 82 | def _capitalize_property(match): 83 | return "{0}:{1}{2}".format( 84 | match.group("property").capitalize(), 85 | match.group("value"), 86 | match.group("terminator"), 87 | ) 88 | 89 | return _lowercase_margin_float_rule.sub(_capitalize_property, css_body) 90 | 91 | 92 | _element_selector_regex = re.compile(r"(^|\s)\w") 93 | _cdata_regex = re.compile(r"\<\!\[CDATA\[(.*?)\]\]\>", re.DOTALL) 94 | _lowercase_margin_float_rule = re.compile( 95 | r"""(?Pmargin(-(top|bottom|left|right))?|float) 96 | : 97 | (?P.*?) 98 | (?P$|;)""", 99 | re.IGNORECASE | re.VERBOSE, 100 | ) 101 | _importants = re.compile(r"\s*!important") 102 | #: The short (3-digit) color codes that cause issues for IBM Notes 103 | _short_color_codes = re.compile(r"^#([0-9a-f])([0-9a-f])([0-9a-f])$", re.I) 104 | 105 | # These selectors don't apply to all elements. Rather, they specify 106 | # which elements to apply to. 107 | FILTER_PSEUDOSELECTORS = [":last-child", ":first-child", ":nth-child"] 108 | 109 | 110 | class Premailer(object): 111 | 112 | attribute_name = "data-premailer" 113 | 114 | def __init__( 115 | self, 116 | html=None, 117 | base_url=None, 118 | disable_link_rewrites=False, 119 | preserve_internal_links=False, 120 | preserve_inline_attachments=True, 121 | preserve_handlebar_syntax=False, 122 | exclude_pseudoclasses=True, 123 | keep_style_tags=False, 124 | include_star_selectors=False, 125 | remove_classes=False, 126 | capitalize_float_margin=False, 127 | strip_important=True, 128 | external_styles=None, 129 | css_text=None, 130 | method="html", 131 | base_path=None, 132 | disable_basic_attributes=None, 133 | disable_validation=False, 134 | cache_css_parsing=True, 135 | cssutils_logging_handler=None, 136 | cssutils_logging_level=None, 137 | disable_leftover_css=False, 138 | align_floating_images=True, 139 | remove_unset_properties=True, 140 | allow_network=True, 141 | allow_insecure_ssl=False, 142 | allow_loading_external_files=False, 143 | session=None, 144 | ): 145 | self.html = html 146 | self.base_url = base_url 147 | 148 | # If base_url is specified, it is used for loading external stylesheets 149 | # via relative URLs. 150 | # 151 | # Also, if base_url is specified, premailer will transform all URLs by 152 | # joining them with the base_url. Setting preserve_internal_links to 153 | # True will disable this behavior for links to named anchors. Setting 154 | # preserve_inline_attachments to True will disable this behavior for 155 | # any links with cid: scheme. Setting disable_link_rewrites to True 156 | # will disable this behavior altogether. 157 | self.disable_link_rewrites = disable_link_rewrites 158 | self.preserve_internal_links = preserve_internal_links 159 | self.preserve_inline_attachments = preserve_inline_attachments 160 | self.preserve_handlebar_syntax = preserve_handlebar_syntax 161 | self.exclude_pseudoclasses = exclude_pseudoclasses 162 | # whether to delete the 724 | 725 | 726 |

Hi!

727 |

Yes!

728 | 729 | 730 | """ 731 | p = Premailer(html) 732 | print(p.transform()) 733 | -------------------------------------------------------------------------------- /premailer/tests/test-apple-newsletter.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Newsletter 5 | 6 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 98 | 99 |
21 | 22 | 23 | 24 |
26 | 27 | 28 | 80 | 81 | 82 |
30 | 31 | 34 | 37 | 38 |
32 |
Thanks for making a reservation.
33 |
35 |
36 |
40 | 41 | 44 | 45 |
42 |
43 |
47 | 48 | 49 | 59 | 66 | 67 |
50 | 51 | 52 |
Dear peter,
53 |
You are scheduled for a Genius Bar appointment.
54 |
Topic: iPhone
55 |
Date: Wednesday, Aug 26, 2009
56 |
Time: 11:10AM
57 |
Location: Apple Store, Regent Street
58 |
60 |
Apple Store,
61 |
Regent Street
62 | 63 |
If you are no longer able to attend this session, please cancel or reschedule your reservation.
64 | 65 |
69 | 70 | 71 | 77 | 78 |
72 |
73 |
We look forward to seeing you.
74 |
Your Apple Store team,
75 |
Regent Street
76 |
84 | 85 | 86 | 87 |
90 | 91 | 95 | 96 |
92 |
TM and copyright © 2008 Apple Inc. 1 Infinite Loop, MS 303-3DM, Cupertino, CA 95014.
93 | 94 |
100 | 101 | 102 | -------------------------------------------------------------------------------- /premailer/tests/test-external-links.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: blue; 3 | } 4 | h2 { 5 | color: green; 6 | } 7 | a { 8 | color: pink; 9 | } 10 | a:hover { 11 | color: purple; 12 | } 13 | -------------------------------------------------------------------------------- /premailer/tests/test-external-styles.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: brown; 3 | } 4 | h2::after { 5 | content: ""; 6 | display: block; 7 | } 8 | @media all and (max-width: 320px) { 9 | h1 { 10 | font-size: 12px; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /premailer/tests/test-issue78.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 17 | 18 |

h1

19 |

html

20 | 21 | 22 | -------------------------------------------------------------------------------- /premailer/tests/test-unicode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Unicode Test 6 | 11 | 12 | 13 |

問題

14 | 15 | 16 | -------------------------------------------------------------------------------- /premailer/tests/test_cache.py: -------------------------------------------------------------------------------- 1 | import imp 2 | import os 3 | import threading 4 | import time 5 | import unittest 6 | 7 | import cachetools 8 | 9 | 10 | class TestFunctionCache(unittest.TestCase): 11 | def tearDown(self): 12 | for key in ( 13 | "PREMAILER_CACHE", 14 | "PREMAILER_CACHE_MAXSIZE", 15 | "PREMAILER_CACHE_TTL", 16 | ): 17 | try: 18 | del os.environ[key] 19 | except KeyError: 20 | pass 21 | 22 | def test_unknown_cache(self): 23 | os.environ["PREMAILER_CACHE"] = "UNKNOWN" 24 | 25 | with self.assertRaises(Exception) as assert_context: 26 | imp.load_source("cache.py", os.path.join("premailer", "cache.py")) 27 | 28 | self.assertTrue( 29 | assert_context.exception.args[0].startswith( 30 | "Unsupported cache implementation" 31 | ) 32 | ) 33 | 34 | def test_ttl_cache(self): 35 | os.environ["PREMAILER_CACHE"] = "TTL" 36 | os.environ["PREMAILER_CACHE_TTL"] = "10" 37 | os.environ["PREMAILER_CACHE_MAXSIZE"] = "50" 38 | 39 | cache_module = imp.load_source( 40 | "cache.py", os.path.join("premailer", "cache.py") 41 | ) 42 | 43 | self.assertEquals(type(cache_module.cache), cachetools.TTLCache) 44 | self.assertEquals(cache_module.cache.maxsize, 50) 45 | self.assertEquals(cache_module.cache.ttl, 10) 46 | 47 | def test_cache_multithread_synchronization(self): 48 | """ 49 | Tests thread safety of internal cache access. 50 | 51 | The test will fail if function_cache would not have been thread safe. 52 | """ 53 | RULES_MAP = {"h1": "{ color: blue; }", "h2": "{ color: green; }"} 54 | 55 | class RuleMapper(threading.Thread): 56 | def __init__(self): 57 | super(RuleMapper, self).__init__() 58 | self.exception = None 59 | 60 | def run(self): 61 | try: 62 | for rule in RULES_MAP: 63 | get_styles(rule) 64 | except KeyError as e: 65 | self.exception = e 66 | 67 | class DelayedDeletionLRUCache(cachetools.LRUCache): 68 | """ 69 | Overrides base LRU implementation to introduce a small delay when 70 | removing elements from cache. 71 | 72 | The delay makes sure that multiple threads try to pop same item from 73 | cache, resulting in KeyError being raised. Reference to exception is 74 | kept to make assertions afterwards. 75 | """ 76 | 77 | def popitem(self): 78 | try: 79 | key = next(iter(self._LRUCache__order)) 80 | except StopIteration: 81 | raise KeyError("%s is empty" % self.__class__.__name__) 82 | else: 83 | time.sleep(0.01) 84 | return (key, self.pop(key)) 85 | 86 | cache_module = imp.load_source( 87 | "cache.py", os.path.join("premailer", "cache.py") 88 | ) 89 | 90 | # Set module cache to point to overridden implementation. 91 | cache_module.cache = DelayedDeletionLRUCache(maxsize=1) 92 | 93 | @cache_module.function_cache() 94 | def get_styles(rule): 95 | return RULES_MAP[rule] 96 | 97 | threads = [RuleMapper() for _ in range(2)] 98 | for thread in threads: 99 | thread.start() 100 | 101 | for thread in threads: 102 | thread.join() 103 | 104 | exceptions = [thread.exception for thread in threads if thread.exception] 105 | self.assertTrue( 106 | not exceptions, "Unexpected exception when accessing Premailer cache." 107 | ) 108 | -------------------------------------------------------------------------------- /premailer/tests/test_merge_style.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from premailer.merge_style import csstext_to_pairs, merge_styles 3 | 4 | 5 | class TestMergeStyle(unittest.TestCase): 6 | # test what is not cover in test_premailer 7 | # should move them here 8 | # smaller files are easier to work with 9 | def test_csstext_to_pairs(self): 10 | csstext = "font-size:1px" 11 | parsed_csstext = csstext_to_pairs(csstext) 12 | self.assertEqual(("font-size", "1px"), parsed_csstext[0]) 13 | 14 | def test_inline_invalid_syntax(self): 15 | # Invalid syntax does not raise 16 | inline = "{color:pink} :hover{color:purple} :active{color:red}" 17 | merge_styles(inline, [], []) 18 | -------------------------------------------------------------------------------- /premailer/tests/test_premailer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import sys 4 | import os 5 | import unittest 6 | from contextlib import contextmanager 7 | from io import StringIO 8 | import tempfile 9 | 10 | from lxml.etree import XMLSyntaxError, fromstring 11 | from requests.exceptions import HTTPError 12 | import mock 13 | import premailer.premailer # lint:ok 14 | from nose.tools import assert_raises, eq_, ok_ 15 | from premailer.__main__ import main 16 | from premailer.premailer import ( 17 | ExternalNotFoundError, 18 | ExternalFileLoadingError, 19 | Premailer, 20 | csstext_to_pairs, 21 | merge_styles, 22 | transform, 23 | ) 24 | 25 | 26 | whitespace_between_tags = re.compile(r">\s*<") 27 | 28 | 29 | @contextmanager 30 | def captured_output(): 31 | new_out, new_err = StringIO(), StringIO() 32 | old_out, old_err = sys.stdout, sys.stderr 33 | try: 34 | sys.stdout, sys.stderr = new_out, new_err 35 | yield sys.stdout, sys.stderr 36 | finally: 37 | sys.stdout, sys.stderr = old_out, old_err 38 | 39 | 40 | @contextmanager 41 | def provide_input(content): 42 | old_stdin = sys.stdin 43 | sys.stdin = StringIO(content) 44 | try: 45 | with captured_output() as (out, err): 46 | yield out, err 47 | finally: 48 | sys.stdin = old_stdin 49 | sys.stdin = StringIO(content) 50 | 51 | 52 | class MockResponse(object): 53 | def __init__(self, content, status_code=200): 54 | self.text = content 55 | self.status_code = status_code 56 | 57 | def raise_for_status(self): 58 | http_error_msg = "" 59 | 60 | if 400 <= self.status_code < 500: 61 | http_error_msg = "Client Error: %s" % (self.status_code,) 62 | 63 | elif 500 <= self.status_code < 600: 64 | http_error_msg = "Server Error: %s" % (self.status_code,) 65 | 66 | if http_error_msg: 67 | raise HTTPError(http_error_msg, response=self) 68 | 69 | 70 | def compare_html(one, two): 71 | one = one.strip() 72 | two = two.strip() 73 | one = whitespace_between_tags.sub(">\n<", one) 74 | two = whitespace_between_tags.sub(">\n<", two) 75 | one = one.replace("><", ">\n<") 76 | two = two.replace("><", ">\n<") 77 | for i, line in enumerate(one.splitlines()): 78 | other = two.splitlines()[i] 79 | if line.lstrip() != other.lstrip(): 80 | eq_(line.lstrip(), other.lstrip()) 81 | 82 | 83 | class Tests(unittest.TestCase): 84 | def shortDescription(self): 85 | # most annoying thing in the world about nose 86 | pass 87 | 88 | def test_merge_styles_basic(self): 89 | inline_style = "font-size:1px; color: red" 90 | new = "font-size:2px; font-weight: bold" 91 | expect = "font-size:1px;", "font-weight:bold;", "color:red" 92 | result = merge_styles(inline_style, [csstext_to_pairs(new)], [""]) 93 | for each in expect: 94 | ok_(each in result) 95 | 96 | def test_merge_styles_with_class(self): 97 | inline_style = "color:red; font-size:1px;" 98 | new, class_ = "font-size:2px; font-weight: bold", ":hover" 99 | 100 | # because we're dealing with dicts (random order) we have to 101 | # test carefully. 102 | # We expect something like this: 103 | # {color:red; font-size:1px} :hover{font-size:2px; font-weight:bold} 104 | 105 | result = merge_styles(inline_style, [csstext_to_pairs(new)], [class_]) 106 | ok_(result.startswith("{")) 107 | ok_(result.endswith("}")) 108 | ok_(" :hover{" in result) 109 | split_regex = re.compile("{([^}]+)}") 110 | eq_(len(split_regex.findall(result)), 2) 111 | expect_first = "color:red", "font-size:1px" 112 | expect_second = "font-weight:bold", "font-size:2px" 113 | for each in expect_first: 114 | ok_(each in split_regex.findall(result)[0]) 115 | for each in expect_second: 116 | ok_(each in split_regex.findall(result)[1]) 117 | 118 | def test_merge_styles_non_trivial(self): 119 | inline_style = 'background-image:url("data:image/png;base64,iVBORw0KGg")' 120 | new = "font-size:2px; font-weight: bold" 121 | expect = ( 122 | 'background-image:url("data:image/png;base64,iVBORw0KGg")', 123 | "font-size:2px;", 124 | "font-weight:bold", 125 | ) 126 | result = merge_styles(inline_style, [csstext_to_pairs(new)], [""]) 127 | for each in expect: 128 | ok_(each in result) 129 | 130 | def test_merge_styles_with_unset(self): 131 | inline_style = "color: red" 132 | new = "font-size: 10px; font-size: unset; font-weight: bold" 133 | expect = "font-weight:bold;", "color:red" 134 | css_new = csstext_to_pairs(new) 135 | result = merge_styles( 136 | inline_style, [css_new], [""], remove_unset_properties=True 137 | ) 138 | for each in expect: 139 | ok_(each in result) 140 | ok_("font-size" not in result) 141 | 142 | def test_basic_html(self): 143 | """test the simplest case""" 144 | 145 | html = """ 146 | 147 | Title 148 | 154 | 155 | 156 |

Hi!

157 |

Yes!

158 | 159 | """ 160 | 161 | expect_html = """ 162 | 163 | Title 164 | 165 | 166 |

Hi!

167 |

Yes!

168 | 169 | """ 170 | 171 | p = Premailer() 172 | result_html = p.transform(html) 173 | 174 | compare_html(expect_html, result_html) 175 | 176 | def test_basic_html_argument_wrong(self): 177 | """It used to be that you'd do: 178 | 179 | instance = Premailer(html, **options) 180 | print(instance.transform()) 181 | 182 | But the new way is: 183 | 184 | instance = Premailer(**options) 185 | print(instance.transform(html)) 186 | 187 | This test checks the handling for the backwards compatability checks. 188 | """ 189 | 190 | html = """ 191 | 192 | Title 193 | 199 | 200 | 201 |

Hi!

202 |

Yes!

203 | 204 | """ 205 | 206 | p = Premailer(html) 207 | assert_raises(TypeError, p.transform, html) 208 | 209 | p = Premailer() 210 | assert_raises(TypeError, p.transform) 211 | 212 | def test_instance_reuse(self): 213 | """test whether the premailer instance can be reused""" 214 | 215 | html_1 = """ 216 | 217 | Title 218 | 224 | 225 | 226 |

Hi!

227 |

Yes!

228 | 229 | """ 230 | 231 | html_2 = """ 232 | 233 | Another Title 234 | 240 | 241 | 242 |

Hello!

243 |

Nope!

244 | 245 | """ 246 | 247 | expect_html_1 = """ 248 | 249 | Title 250 | 251 | 252 |

Hi!

253 |

Yes!

254 | 255 | """ 256 | 257 | expect_html_2 = """ 258 | 259 | Another Title 260 | 261 | 262 |

Hello!

263 |

Nope!

264 | 265 | """ 266 | 267 | p = Premailer() 268 | result_html_1 = p.transform(html_1) 269 | result_html_2 = p.transform(html_2) 270 | 271 | compare_html(expect_html_1, result_html_1) 272 | compare_html(expect_html_2, result_html_2) 273 | 274 | def test_remove_classes(self): 275 | """test the simplest case""" 276 | 277 | html = """ 278 | 279 | Title 280 | 285 | 286 | 287 |

Yes!

288 | 289 | """ 290 | 291 | expect_html = """ 292 | 293 | Title 294 | 295 | 296 |

Yes!

297 | 298 | """ 299 | 300 | p = Premailer(html, remove_classes=True) 301 | result_html = p.transform() 302 | 303 | compare_html(expect_html, result_html) 304 | 305 | def test_basic_html_shortcut_function(self): 306 | """test the plain transform function""" 307 | html = """ 308 | 309 | Title 310 | 316 | 317 | 318 |

Hi!

319 |

Yes!

320 | 321 | """ 322 | 323 | expect_html = """ 324 | 325 | Title 326 | 327 | 328 |

Hi!

329 |

Yes!

330 | 331 | """ 332 | 333 | result_html = transform(html) 334 | compare_html(expect_html, result_html) 335 | 336 | def test_kwargs_html_shortcut_function(self): 337 | """test the transform function with kwargs passed""" 338 | html = """ 339 | 340 | Title 341 | 347 | 348 | 349 |

Hi!

350 |

Yes!

351 | 352 | """ 353 | 354 | expect_html = """ 355 | 356 | Title 357 | 363 | 364 | 365 |

Hi!

366 |

Yes!

367 | 368 | """ 369 | 370 | result_html = transform(html, keep_style_tags=True) 371 | compare_html(expect_html, result_html) 372 | 373 | def test_empty_style_tag(self): 374 | """empty style tag""" 375 | 376 | html = """ 377 | 378 | 379 | 380 | 381 | 382 | 383 | """ 384 | 385 | expect_html = """ 386 | 387 | 388 | 389 | 390 | 391 | """ 392 | 393 | p = Premailer(html) 394 | result_html = p.transform() 395 | 396 | compare_html(expect_html, result_html) 397 | 398 | def test_include_star_selector(self): 399 | """test the simplest case""" 400 | 401 | html = """ 402 | 403 | Title 404 | 407 | 408 | 409 |

Hi!

410 |

Yes!

411 | 412 | """ 413 | 414 | expect_html_not_included = """ 415 | 416 | Title 417 | 418 | 419 |

Hi!

420 |

Yes!

421 | 422 | """ 423 | 424 | p = Premailer(html) 425 | result_html = p.transform() 426 | 427 | compare_html(expect_html_not_included, result_html) 428 | 429 | expect_html_star_included = """ 430 | 431 | Title 432 | 433 | 434 |

Hi!

435 |

Yes!

436 | 437 | """ 438 | 439 | p = Premailer(html, include_star_selectors=True) 440 | result_html = p.transform() 441 | 442 | compare_html(expect_html_star_included, result_html) 443 | 444 | def test_mixed_pseudo_selectors(self): 445 | """mixing pseudo selectors with straight forward selectors""" 446 | 447 | html = """ 448 | 449 | Title 450 | 455 | 456 | 457 |

458 | Page 459 |

460 | 461 | """ 462 | 463 | expect_html = """ 464 | 465 | Title 466 | 467 | 468 | 469 |

Page

470 | 471 | """ 472 | 473 | p = Premailer(html) 474 | result_html = p.transform() 475 | 476 | compare_html(expect_html, result_html) 477 | 478 | def test_basic_html_with_pseudo_selector(self): 479 | """test the simplest case""" 480 | 481 | html = """ 482 | 483 | 488 |

Peter

489 |

Hej

490 | 491 | """ 492 | 493 | expect_html = """ 494 | 495 | 496 | 497 | 498 |

Peter

499 |

Hej

500 | 501 | """ 502 | 503 | p = Premailer(html) 504 | result_html = p.transform() 505 | 506 | compare_html(expect_html, result_html) 507 | 508 | def test_parse_style_rules(self): 509 | p = Premailer("html") # won't need the html 510 | func = p._parse_style_rules 511 | rules, leftover = func( 512 | """ 513 | h1, h2 { color:red; } 514 | /* ignore 515 | this */ 516 | strong { 517 | text-decoration:none 518 | } 519 | ul li { list-style: 2px; } 520 | a:hover { text-decoration: underline } 521 | """, 522 | 0, 523 | ) 524 | 525 | # 'rules' is a list, turn it into a dict for 526 | # easier assertion testing 527 | rules_dict = {} 528 | rules_specificity = {} 529 | for specificity, k, v in rules: 530 | rules_dict[k] = v 531 | rules_specificity[k] = specificity 532 | 533 | ok_("h1" in rules_dict) 534 | ok_("h2" in rules_dict) 535 | ok_("strong" in rules_dict) 536 | ok_("ul li" in rules_dict) 537 | 538 | eq_(rules_dict["h1"], "color:red") 539 | eq_(rules_dict["h2"], "color:red") 540 | eq_(rules_dict["strong"], "text-decoration:none") 541 | eq_(rules_dict["ul li"], "list-style:2px") 542 | ok_("a:hover" not in rules_dict) 543 | 544 | # won't need the html 545 | p = Premailer("html", exclude_pseudoclasses=True) 546 | func = p._parse_style_rules 547 | rules, leftover = func( 548 | """ 549 | ul li { list-style: 2px; } 550 | a:hover { text-decoration: underline } 551 | """, 552 | 0, 553 | ) 554 | 555 | eq_(len(rules), 1) 556 | specificity, k, v = rules[0] 557 | eq_(k, "ul li") 558 | eq_(v, "list-style:2px") 559 | 560 | eq_(len(leftover), 1) 561 | k, v = leftover[0] 562 | eq_((k, v), ("a:hover", "text-decoration:underline"), (k, v)) 563 | 564 | def test_precedence_comparison(self): 565 | p = Premailer("html") # won't need the html 566 | rules, leftover = p._parse_style_rules( 567 | """ 568 | #identified { color:blue; } 569 | h1, h2 { color:red; } 570 | ul li { list-style: 2px; } 571 | li.example { color:green; } 572 | strong { text-decoration:none } 573 | div li.example p.sample { color:black; } 574 | """, 575 | 0, 576 | ) 577 | 578 | # 'rules' is a list, turn it into a dict for 579 | # easier assertion testing 580 | rules_specificity = {} 581 | for specificity, k, v in rules: 582 | rules_specificity[k] = specificity 583 | 584 | # Last in file wins 585 | ok_(rules_specificity["h1"] < rules_specificity["h2"]) 586 | # More elements wins 587 | ok_(rules_specificity["strong"] < rules_specificity["ul li"]) 588 | # IDs trump everything 589 | ok_( 590 | rules_specificity["div li.example p.sample"] 591 | < rules_specificity["#identified"] 592 | ) 593 | 594 | # Classes trump multiple elements 595 | ok_(rules_specificity["ul li"] < rules_specificity["li.example"]) 596 | 597 | def test_base_url_fixer(self): 598 | """if you leave some URLS as /foo and set base_url to 599 | 'http://www.google.com' the URLS become 'http://www.google.com/foo' 600 | """ 601 | html = """ 602 | 603 | Title 604 | 605 | 606 | 607 | 608 | 609 | 610 | Home 611 | External 612 | Subpage 613 | Internal Link 614 | 615 | 616 | """ 617 | 618 | expect_html = """ 619 | 620 | Title 621 | 622 | 623 | 624 | 625 | 626 | 627 | Home 628 | External 629 | Subpage 630 | Internal Link 631 | 632 | """ 633 | 634 | p = Premailer( 635 | html, base_url="http://kungfupeople.com", preserve_internal_links=True 636 | ) 637 | result_html = p.transform() 638 | 639 | compare_html(expect_html, result_html) 640 | 641 | def test_base_url_with_path(self): 642 | """if you leave some URLS as /foo and set base_url to 643 | 'http://www.google.com' the URLS become 'http://www.google.com/foo' 644 | """ 645 | 646 | html = """ 647 | 648 | Title 649 | 650 | 651 | 652 | 653 | Home 654 | External 655 | External 2 656 | Subpage 657 | Internal Link 658 | 659 | 660 | """ 661 | 662 | expect_html = """ 663 | 664 | Title 665 | 666 | 667 | 668 | 669 | Home 670 | External 671 | External 2 672 | Subpage 673 | Internal Link 674 | 675 | """ 676 | 677 | p = Premailer( 678 | html, base_url="http://kungfupeople.com/base/", preserve_internal_links=True 679 | ) 680 | result_html = p.transform() 681 | 682 | compare_html(expect_html, result_html) 683 | 684 | def test_style_block_with_external_urls(self): 685 | """ 686 | From http://github.com/peterbe/premailer/issues/#issue/2 687 | 688 | If you have 689 | body { background:url(http://example.com/bg.png); } 690 | the ':' inside '://' is causing a problem 691 | """ 692 | 693 | html = """ 694 | 695 | Title 696 | 703 | 704 | 705 |

Hi!

706 | 707 | """ 708 | 709 | expect_html = """ 710 | 711 | Title 712 | 713 | 715 |

Hi!

716 | 717 | """.replace( 718 | "exam\nple", "example" 719 | ) 720 | 721 | p = Premailer(html) 722 | result_html = p.transform() 723 | 724 | compare_html(expect_html, result_html) 725 | 726 | def test_base_url_ignore_links(self): 727 | """if you leave some URLS as /foo, set base_url to 728 | 'http://www.google.com' and set disable_link_rewrites to True, the URLS 729 | should not be changed. 730 | """ 731 | 732 | html = """ 733 | 734 | Title 735 | 736 | 737 | 738 | 739 | Home 740 | External 741 | External 2 742 | Subpage 743 | Internal Link 744 | 745 | 746 | """ 747 | 748 | expect_html = """ 749 | 750 | Title 751 | 752 | 753 | 754 | 755 | Home 756 | External 757 | External 2 758 | Subpage 759 | Internal Link 760 | 761 | """ 762 | 763 | p = Premailer( 764 | html, base_url="http://kungfupeople.com/base/", disable_link_rewrites=True 765 | ) 766 | result_html = p.transform() 767 | 768 | compare_html(expect_html, result_html) 769 | 770 | def fragment_in_html(self, fragment, html, fullMessage=False): 771 | if fullMessage: 772 | message = '"{0}" not in\n{1}'.format(fragment, html) 773 | else: 774 | message = '"{0}" not in HTML'.format(fragment) 775 | ok_(fragment in html, message) 776 | 777 | def test_css_with_pseudoclasses_included(self): 778 | "Pick up the pseudoclasses too and include them" 779 | 780 | html = """ 781 | 782 | 790 | 791 | 792 | Special! 793 | Page 794 |

Paragraph

795 | 796 | """ 797 | 798 | p = Premailer(html, exclude_pseudoclasses=False) 799 | result_html = p.transform() 800 | # because we're dealing with random dicts here we can't predict what 801 | # order the style attribute will be written in so we'll look for 802 | # things manually. 803 | e = '

' "Paragraph

" 804 | self.fragment_in_html(e, result_html, True) 805 | 806 | e = 'style="{color:red; border:1px solid green}' 807 | self.fragment_in_html(e, result_html) 808 | e = " :visited{border:1px solid green}" 809 | self.fragment_in_html(e, result_html) 810 | e = " :hover{text-decoration:none; border:1px solid green}" 811 | self.fragment_in_html(e, result_html) 812 | 813 | def test_css_with_pseudoclasses_excluded(self): 814 | "Skip things like `a:hover{}` and keep them in the style block" 815 | 816 | html = """ 817 | 818 | 825 | 826 | 827 | Page 828 |

Paragraph

829 | 830 | """ 831 | 832 | expect_html = """ 833 | 834 | 838 | 839 | 840 | Page 841 |

Paragraph

842 | 843 | """ 844 | 845 | p = Premailer(html, exclude_pseudoclasses=True) 846 | result_html = p.transform() 847 | 848 | expect_html = whitespace_between_tags.sub("><", expect_html).strip() 849 | result_html = whitespace_between_tags.sub("><", result_html).strip() 850 | 851 | expect_html = re.sub(r"}\s+", "}", expect_html) 852 | result_html = result_html.replace("}\n", "}") 853 | 854 | eq_(expect_html, result_html) 855 | # XXX 856 | 857 | def test_css_with_html_attributes(self): 858 | """Some CSS styles can be applied as normal HTML attribute like 859 | 'background-color' can be turned into 'bgcolor' 860 | """ 861 | 862 | html = """ 863 | 864 | 869 | 870 | 871 |

Text

872 | 873 | 874 | 875 | 876 | 877 |
Cell 1Cell 2
878 | 879 | """ 880 | 881 | expect_html = """ 882 | 883 | 884 | 885 |

Text

886 | 887 | 888 | 890 | 892 | 893 |
Cell 1Cell 2
894 | 895 | """.replace( 896 | "vert\nical", "vertical" 897 | ) 898 | 899 | p = Premailer(html, exclude_pseudoclasses=True) 900 | result_html = p.transform() 901 | 902 | expect_html = re.sub(r"}\s+", "}", expect_html) 903 | result_html = result_html.replace("}\n", "}") 904 | 905 | compare_html(expect_html, result_html) 906 | 907 | def test_css_disable_basic_html_attributes(self): 908 | """Some CSS styles can be applied as normal HTML attribute like 909 | 'background-color' can be turned into 'bgcolor' 910 | """ 911 | 912 | html = """ 913 | 914 | 919 | 920 | 921 |

Text

922 | 923 | 924 | 925 | 926 | 927 |
Cell 1Cell 2
928 | 929 | """ 930 | 931 | expect_html = """ 932 | 933 | 934 | 935 |

Text

936 | 937 | 938 | 939 | 940 | 941 |
Cell 1Cell 2
942 | 943 | """ 944 | 945 | p = Premailer( 946 | html, 947 | exclude_pseudoclasses=True, 948 | disable_basic_attributes=["align", "width", "height"], 949 | ) 950 | result_html = p.transform() 951 | 952 | expect_html = re.sub(r"}\s+", "}", expect_html) 953 | result_html = result_html.replace("}\n", "}") 954 | 955 | compare_html(expect_html, result_html) 956 | 957 | def test_apple_newsletter_example(self): 958 | # stupidity test 959 | import os 960 | 961 | html_file = os.path.join("premailer", "tests", "test-apple-newsletter.html") 962 | html = open(html_file).read() 963 | 964 | p = Premailer( 965 | html, 966 | exclude_pseudoclasses=False, 967 | keep_style_tags=True, 968 | strip_important=False, 969 | ) 970 | result_html = p.transform() 971 | ok_("" in result_html) 972 | ok_( 973 | '" in result_html 978 | ) 979 | 980 | def test_mailto_url(self): 981 | """if you use URL with mailto: protocol, they should stay as mailto: 982 | when baseurl is used 983 | """ 984 | 985 | html = """ 986 | 987 | Title 988 | 989 | 990 | e-mail@example.com 991 | 992 | """ 993 | 994 | expect_html = """ 995 | 996 | Title 997 | 998 | 999 | e-mail@example.com 1000 | 1001 | """ 1002 | 1003 | p = Premailer(html, base_url="http://kungfupeople.com") 1004 | result_html = p.transform() 1005 | 1006 | compare_html(expect_html, result_html) 1007 | 1008 | def test_tel_url(self): 1009 | """if you use URL with tel: protocol, it should stay as tel: 1010 | when baseurl is used 1011 | """ 1012 | 1013 | html = """ 1014 | 1015 | Title 1016 | 1017 | 1018 | 202-555-0113 1019 | 1020 | """ 1021 | 1022 | p = Premailer(html, base_url="http://kungfupeople.com") 1023 | result_html = p.transform() 1024 | 1025 | compare_html(result_html, html) 1026 | 1027 | def test_uppercase_margin(self): 1028 | """Option to comply with outlook.com 1029 | 1030 | https://emailonacid.com/blog/article/email-development/outlook.com-does-support-margins 1031 | """ 1032 | 1033 | html = """ 1034 | 1035 | Title 1036 | 1037 | 1041 | 1042 |

a

1043 |

1044 | b 1045 |

1046 | 1047 | """ 1048 | 1049 | expect_html = """ 1050 | 1051 | Title 1052 | 1053 | 1054 |

a

1055 |

1056 | b 1057 |

1058 | 1059 | """ 1060 | 1061 | p = Premailer(html, capitalize_float_margin=True) 1062 | result_html = p.transform() 1063 | 1064 | compare_html(expect_html, result_html) 1065 | 1066 | def test_strip_important(self): 1067 | """Get rid of !important. Makes no sense inline.""" 1068 | html = """ 1069 | 1070 | 1076 | 1077 | 1078 |

Paragraph

1079 | 1080 | 1081 | """ 1082 | expect_html = """ 1083 | 1084 | 1085 | 1086 |

Paragraph

1087 | 1088 | """ 1089 | 1090 | p = Premailer(html, strip_important=True) 1091 | result_html = p.transform() 1092 | 1093 | compare_html(expect_html, result_html) 1094 | 1095 | def test_inline_wins_over_external(self): 1096 | html = """ 1097 | 1098 | 1108 | 1109 | 1110 |
Some text
1111 | 1112 | """ 1113 | 1114 | expect_html = """ 1115 | 1116 | 1117 | 1118 |
Some text
1119 | 1120 | """ 1121 | 1122 | p = Premailer(html) 1123 | result_html = p.transform() 1124 | 1125 | compare_html(expect_html, result_html) 1126 | 1127 | def test_last_child(self): 1128 | html = """ 1129 | 1130 | 1138 | 1139 | 1140 |
First child
1141 |
Last child
1142 | 1143 | """ 1144 | 1145 | expect_html = """ 1146 | 1147 | 1148 | 1149 |
First child
1150 |
Last child
1151 | 1152 | """ 1153 | 1154 | p = Premailer(html) 1155 | result_html = p.transform() 1156 | 1157 | compare_html(expect_html, result_html) 1158 | 1159 | def test_last_child_exclude_pseudo(self): 1160 | html = """ 1161 | 1162 | 1170 | 1171 | 1172 |
First child
1173 |
Last child
1174 | 1175 | """ 1176 | 1177 | expect_html = """ 1178 | 1179 | 1180 | 1181 |
First child
1182 |
Last child
1183 | 1184 | """ 1185 | 1186 | p = Premailer(html, exclude_pseudoclasses=True) 1187 | result_html = p.transform() 1188 | 1189 | compare_html(expect_html, result_html) 1190 | 1191 | def test_mediaquery(self): 1192 | html = """ 1193 | 1194 | 1208 | 1209 | 1210 |
First div
1211 | 1212 | """ 1213 | 1214 | expect_html = """ 1215 | 1216 | 1225 | 1226 | 1227 |
First div
1228 | 1229 | """ 1230 | 1231 | p = Premailer(html, strip_important=False) 1232 | result_html = p.transform() 1233 | 1234 | compare_html(expect_html, result_html) 1235 | 1236 | def test_child_selector(self): 1237 | html = """ 1238 | 1239 | 1244 | 1245 | 1246 |
First div
1247 | 1248 | """ 1249 | 1250 | expect_html = """ 1251 | 1252 | 1253 | 1254 |
First div
1255 | 1256 | """ 1257 | 1258 | p = Premailer(html) 1259 | result_html = p.transform() 1260 | 1261 | compare_html(expect_html, result_html) 1262 | 1263 | def test_css_ordering_preserved(self): 1264 | """For cases like these padding rules, it's important that the style that 1265 | should be applied comes last so that premailer follows the same rules that 1266 | browsers use to determine precedence.""" 1267 | 1268 | html = """ 1269 | 1270 | 1277 | 1278 | 1279 |
Some text
1280 | 1281 | """ 1282 | 1283 | expect_html = """ 1284 | 1285 | 1286 | 1287 |
Some text
1288 | 1289 | """ 1290 | 1291 | p = Premailer(html) 1292 | result_html = p.transform() 1293 | 1294 | compare_html(expect_html, result_html) 1295 | 1296 | def test_doctype(self): 1297 | html = ( 1298 | '' 1300 | """ 1301 | 1302 | 1303 | 1304 | 1305 | """ 1306 | ) 1307 | 1308 | expect_html = ( 1309 | '' 1311 | """ 1312 | 1313 | 1314 | 1315 | 1316 | """ 1317 | ) 1318 | 1319 | p = Premailer(html) 1320 | result_html = p.transform() 1321 | 1322 | compare_html(expect_html, result_html) 1323 | 1324 | def test_prefer_inline_to_class(self): 1325 | html = """ 1326 | 1327 | 1332 | 1333 | 1334 |
1335 | 1336 | """ 1337 | 1338 | expect_html = """ 1339 | 1340 | 1341 | 1342 |
1343 | 1344 | """ 1345 | 1346 | p = Premailer(html) 1347 | result_html = p.transform() 1348 | 1349 | compare_html(expect_html, result_html) 1350 | 1351 | def test_favour_rule_with_element_over_generic(self): 1352 | html = """ 1353 | 1354 | 1362 | 1363 | 1364 |
1365 | 1366 | """ 1367 | 1368 | expect_html = """ 1369 | 1370 | 1371 | 1372 |
1373 | 1374 | """ 1375 | 1376 | p = Premailer(html) 1377 | result_html = p.transform() 1378 | 1379 | compare_html(expect_html, result_html) 1380 | 1381 | def test_favour_rule_with_class_over_generic(self): 1382 | html = """ 1383 | 1384 | 1392 | 1393 | 1394 |
1395 | 1396 | """ 1397 | 1398 | expect_html = """ 1399 | 1400 | 1401 | 1402 |
1403 | 1404 | """ 1405 | 1406 | p = Premailer(html) 1407 | result_html = p.transform() 1408 | 1409 | compare_html(expect_html, result_html) 1410 | 1411 | def test_favour_rule_with_id_over_others(self): 1412 | html = """ 1413 | 1414 | 1422 | 1423 | 1424 |
1425 | 1426 | """ 1427 | 1428 | expect_html = """ 1429 | 1430 | 1431 | 1432 |
1433 | 1434 | """ 1435 | 1436 | p = Premailer(html) 1437 | result_html = p.transform() 1438 | 1439 | compare_html(expect_html, result_html) 1440 | 1441 | def test_favour_rule_with_important_over_others(self): 1442 | html = """ 1443 | 1444 | 1457 | 1458 | 1459 |
1460 | 1461 | """ 1462 | 1463 | expect_html = """ 1464 | 1465 | 1466 | 1467 |
1468 | 1469 | """ 1470 | 1471 | p = Premailer(html) 1472 | result_html = p.transform() 1473 | 1474 | compare_html(expect_html, result_html) 1475 | 1476 | def test_multiple_style_elements(self): 1477 | """Asserts that rules from multiple style elements 1478 | are inlined correctly.""" 1479 | 1480 | html = """ 1481 | 1482 | Title 1483 | 1489 | 1495 | 1496 | 1497 |

Hi!

1498 |

Yes!

1499 | 1500 | """ 1501 | 1502 | expect_html = """ 1503 | 1504 | Title 1505 | 1506 | 1507 |

Hi!

1508 |

Yes!

1510 | 1511 | """.replace( 1512 | "deco\nration", "decoration" 1513 | ) 1514 | 1515 | p = Premailer(html) 1516 | result_html = p.transform() 1517 | 1518 | compare_html(expect_html, result_html) 1519 | 1520 | def test_style_attribute_specificity(self): 1521 | """Stuff already in style attributes beats style tags.""" 1522 | 1523 | html = """ 1524 | 1525 | Title 1526 | 1530 | 1531 | 1532 |

Hi!

1533 | 1534 | """ 1535 | 1536 | expect_html = """ 1537 | 1538 | Title 1539 | 1540 | 1541 |

Hi!

1542 | 1543 | """ 1544 | 1545 | p = Premailer(html) 1546 | result_html = p.transform() 1547 | 1548 | compare_html(expect_html, result_html) 1549 | 1550 | def test_ignore_style_elements_with_media_attribute(self): 1551 | """Asserts that style elements with media attributes other than 1552 | 'screen' are ignored.""" 1553 | 1554 | html = """ 1555 | 1556 | Title 1557 | 1563 | 1569 | 1575 | 1576 | 1577 |

Hi!

1578 |

Yes!

1579 | 1580 | """ 1581 | 1582 | expect_html = """ 1583 | 1584 | Title 1585 | 1591 | 1592 | 1593 |

Hi!

1594 |

Yes!

1596 | 1597 | """.replace( 1598 | "deco\nration", "decoration" 1599 | ) 1600 | 1601 | p = Premailer(html) 1602 | result_html = p.transform() 1603 | 1604 | compare_html(expect_html, result_html) 1605 | 1606 | def test_leftover_important(self): 1607 | """Asserts that leftover styles should be marked as !important.""" 1608 | 1609 | html = """ 1610 | 1611 | Title 1612 | 1617 | 1618 | 1619 | Hi! 1620 | 1621 | """ 1622 | 1623 | expect_html = """ 1624 | 1625 | Title 1626 | 1631 | 1632 | 1633 | Hi! 1634 | 1635 | """ 1636 | 1637 | p = Premailer(html, keep_style_tags=True, strip_important=False) 1638 | result_html = p.transform() 1639 | 1640 | compare_html(expect_html, result_html) 1641 | 1642 | def test_basic_xml(self): 1643 | """Test the simplest case with xml""" 1644 | 1645 | html = """ 1646 | 1647 | Title 1648 | 1651 | 1652 | 1653 | test 1654 | 1655 | 1656 | """ 1657 | 1658 | expect_html = """ 1659 | 1660 | Title 1661 | 1662 | 1663 | test 1664 | 1665 | 1666 | """ 1667 | 1668 | p = Premailer(html, method="xml") 1669 | result_html = p.transform() 1670 | 1671 | compare_html(expect_html, result_html) 1672 | 1673 | def test_broken_xml(self): 1674 | """Test the simplest case with xml""" 1675 | 1676 | html = """ 1677 | 1678 | Title 1679 | <style type="text/css"> 1680 | img { border: none; } 1681 | </style> 1682 | </head> 1683 | <body> 1684 | <img src="test.png" alt="test"/> 1685 | </body> 1686 | """ 1687 | 1688 | p = Premailer(html, method="xml") 1689 | assert_raises(XMLSyntaxError, p.transform) 1690 | 1691 | def test_xml_cdata(self): 1692 | """Test that CDATA is set correctly on remaining styles""" 1693 | 1694 | html = """<html> 1695 | <head> 1696 | <title>Title 1697 | 1700 | 1701 | 1702 | Test 1703 | 1704 | 1705 | """ 1706 | 1707 | expect_html = """ 1708 | 1709 | Title 1710 | 1712 | 1713 | 1714 | Test 1715 | 1716 | 1717 | """.replace( 1718 | "back\nground", "background" 1719 | ) 1720 | 1721 | p = Premailer(html, method="xml") 1722 | result_html = p.transform() 1723 | 1724 | compare_html(expect_html, result_html) 1725 | 1726 | def test_command_line_fileinput_from_stdin(self): 1727 | html = "

Title

" 1728 | expect_html = """ 1729 | 1730 | 1731 |

Title

1732 | 1733 | """ 1734 | 1735 | with provide_input(html) as (out, err): 1736 | main([]) 1737 | result_html = out.getvalue().strip() 1738 | 1739 | compare_html(expect_html, result_html) 1740 | 1741 | def test_command_line_fileinput_from_argument(self): 1742 | with captured_output() as (out, err): 1743 | main( 1744 | [ 1745 | "-f", 1746 | "premailer/tests/test-apple-newsletter.html", 1747 | "--disable-basic-attributes=bgcolor", 1748 | ] 1749 | ) 1750 | 1751 | result_html = out.getvalue().strip() 1752 | 1753 | ok_("" in result_html) 1754 | ok_( 1755 | '" in result_html 1760 | ) 1761 | 1762 | def test_command_line_preserve_style_tags(self): 1763 | with captured_output() as (out, err): 1764 | main( 1765 | [ 1766 | "-f", 1767 | "premailer/tests/test-issue78.html", 1768 | "--preserve-style-tags", 1769 | "--external-style=premailer/tests/test-external-styles.css", 1770 | "--allow-loading-external-files", 1771 | ] 1772 | ) 1773 | 1774 | result_html = out.getvalue().strip() 1775 | 1776 | expect_html = """ 1777 | 1778 | 1779 | 1792 | 1794 | 1805 | 1818 | 1819 | 1820 |

h1

1821 |

html

1823 | 1824 | 1825 | """.replace( 1826 | "col\nor", "color" 1827 | ).replace( 1828 | "applic\nation", "application" 1829 | ) 1830 | 1831 | compare_html(expect_html, result_html) 1832 | 1833 | # for completeness, test it once without 1834 | with captured_output() as (out, err): 1835 | main( 1836 | [ 1837 | "-f", 1838 | "premailer/tests/test-issue78.html", 1839 | "--external-style=premailer/tests/test-external-styles.css", 1840 | "--allow-loading-external-files", 1841 | ] 1842 | ) 1843 | 1844 | result_html = out.getvalue().strip() 1845 | expect_html = """ 1846 | 1847 | 1848 | 1850 | 1855 | 1860 | 1861 | 1862 |

h1

1863 |

html

1865 | 1866 | 1867 | """.replace( 1868 | "co\nlor", "color" 1869 | ).replace( 1870 | "applic\nation", "application" 1871 | ) 1872 | 1873 | compare_html(expect_html, result_html) 1874 | 1875 | def test_multithreading(self): 1876 | """The test tests thread safety of merge_styles function which employs 1877 | thread non-safe cssutils calls. 1878 | The test would fail if merge_styles would have not been thread-safe""" 1879 | 1880 | import threading 1881 | import logging 1882 | 1883 | THREADS = 30 1884 | REPEATS = 100 1885 | 1886 | class RepeatMergeStylesThread(threading.Thread): 1887 | """The thread is instantiated by test and run multiple 1888 | times in parallel.""" 1889 | 1890 | exc = None 1891 | 1892 | def __init__(self, old, new, class_): 1893 | """The constructor just stores merge_styles parameters""" 1894 | super(RepeatMergeStylesThread, self).__init__() 1895 | self.old, self.new, self.class_ = old, new, class_ 1896 | 1897 | def run(self): 1898 | """Calls merge_styles in a loop and sets exc attribute 1899 | if merge_styles raises an exception.""" 1900 | for _ in range(0, REPEATS): 1901 | try: 1902 | merge_styles(self.old, self.new, self.class_) 1903 | except Exception as e: 1904 | logging.exception("Exception in thread %s", self.name) 1905 | self.exc = e 1906 | 1907 | inline_style = "background-color:#ffffff;" 1908 | new = "background-color:#dddddd;" 1909 | class_ = "" 1910 | 1911 | # start multiple threads concurrently; each 1912 | # calls merge_styles many times 1913 | threads = [ 1914 | RepeatMergeStylesThread(inline_style, [csstext_to_pairs(new)], [class_]) 1915 | for _ in range(0, THREADS) 1916 | ] 1917 | for t in threads: 1918 | t.start() 1919 | 1920 | # wait until all threads are done 1921 | for t in threads: 1922 | t.join() 1923 | 1924 | # check if any thread raised exception while in merge_styles call 1925 | exceptions = [t.exc for t in threads if t.exc is not None] 1926 | eq_(exceptions, []) 1927 | 1928 | def test_external_links(self): 1929 | """Test loading stylesheets via link tags""" 1930 | 1931 | html = """ 1932 | 1933 | Title 1934 | 1938 | 1940 | 1942 | 1945 | 1946 | 1947 |

Hello

1948 |

World

1949 |

Test

1950 | Link 1951 | 1952 | """.replace( 1953 | "applic\naction", "application" 1954 | ).replace( 1955 | "style\nsheet", "stylesheet" 1956 | ) 1957 | 1958 | expect_html = """ 1959 | 1960 | Title 1961 | 1962 | 1964 | 1965 | 1966 |

Hello

1967 |

World

1968 |

Test

1969 | Link 1970 | 1971 | """.replace( 1972 | "applic\naction", "application" 1973 | ) 1974 | 1975 | p = Premailer(html, strip_important=False, allow_loading_external_files=True) 1976 | result_html = p.transform() 1977 | 1978 | compare_html(expect_html, result_html) 1979 | 1980 | def test_external_links_disallow_network(self): 1981 | """Test loading stylesheets via link tags with disallowed network access""" 1982 | 1983 | html = """ 1984 | 1985 | Title 1986 | 1990 | 1992 | 1994 | 1997 | 1998 | 1999 |

Hello

2000 |

World

2001 |

Test

2002 | Link 2003 | 2004 | """.replace( 2005 | "applic\naction", "application" 2006 | ).replace( 2007 | "style\nsheet", "stylesheet" 2008 | ) 2009 | 2010 | expect_html = """ 2011 | 2012 | Title 2013 | 2015 | 2017 | 2018 | 2019 |

Hello

2020 |

World

2021 |

Test

2022 | Link 2023 | 2024 | """.replace( 2025 | "applic\naction", "application" 2026 | ) 2027 | 2028 | p = Premailer(html, strip_important=False, allow_network=False) 2029 | result_html = p.transform() 2030 | 2031 | compare_html(expect_html, result_html) 2032 | 2033 | def test_external_links_unfindable(self): 2034 | """Test loading stylesheets that can't be found""" 2035 | 2036 | html = """ 2037 | 2038 | Title 2039 | 2043 | 2044 | 2047 | 2048 | 2049 |

Hello

2050 |

World

2051 |

Test

2052 | Link 2053 | 2054 | """ 2055 | 2056 | p = Premailer(html, strip_important=False, allow_loading_external_files=True) 2057 | assert_raises(ExternalNotFoundError, p.transform) 2058 | 2059 | def test_external_styles_and_links(self): 2060 | """Test loading stylesheets via both the 'external_styles' 2061 | argument and link tags""" 2062 | 2063 | html = """ 2064 | 2065 | 2066 | 2069 | 2070 | 2071 |

Hello

2072 |

Hello

2073 | Hello 2074 | 2075 | """ 2076 | 2077 | expect_html = """ 2078 | 2079 | 2080 | 2087 | 2088 | 2089 |

Hello

2090 |

Hello

2091 | Hello 2092 | 2093 | """.replace( 2094 | "cont\nent", "content" 2095 | ) 2096 | 2097 | p = Premailer( 2098 | html, 2099 | strip_important=False, 2100 | external_styles="test-external-styles.css", 2101 | base_path="premailer/tests/", 2102 | allow_loading_external_files=True, 2103 | ) 2104 | result_html = p.transform() 2105 | 2106 | compare_html(expect_html, result_html) 2107 | 2108 | @mock.patch("premailer.premailer.requests") 2109 | def test_load_external_url(self, mocked_requests): 2110 | "Test premailer.premailer.Premailer._load_external_url" 2111 | faux_response = "This is not a response" 2112 | faux_uri = "https://example.com/site.css" 2113 | mocked_requests.get.return_value = MockResponse(faux_response) 2114 | p = premailer.premailer.Premailer("

A paragraph

") 2115 | r = p._load_external_url(faux_uri) 2116 | 2117 | mocked_requests.get.assert_called_once_with(faux_uri, verify=True) 2118 | eq_(faux_response, r) 2119 | 2120 | def test_load_external_url_with_custom_session(self): 2121 | mocked_session = mock.MagicMock() 2122 | faux_response = "This is not a response" 2123 | faux_uri = "https://example.com/site.css" 2124 | mocked_session.get.return_value = MockResponse(faux_response) 2125 | p = premailer.premailer.Premailer("

A paragraph

", session=mocked_session) 2126 | r = p._load_external_url(faux_uri) 2127 | 2128 | mocked_session.get.assert_called_once_with(faux_uri, verify=True) 2129 | eq_(faux_response, r) 2130 | 2131 | @mock.patch("premailer.premailer.requests") 2132 | def test_load_external_url_no_insecure_ssl(self, mocked_requests): 2133 | "Test premailer.premailer.Premailer._load_external_url" 2134 | faux_response = "This is not a response" 2135 | faux_uri = "https://example.com/site.css" 2136 | mocked_requests.get.return_value = MockResponse(faux_response) 2137 | p = premailer.premailer.Premailer( 2138 | "

A paragraph

", allow_insecure_ssl=False 2139 | ) 2140 | r = p._load_external_url(faux_uri) 2141 | 2142 | mocked_requests.get.assert_called_once_with(faux_uri, verify=True) 2143 | eq_(faux_response, r) 2144 | 2145 | @mock.patch("premailer.premailer.requests") 2146 | def test_load_external_url_with_insecure_ssl(self, mocked_requests): 2147 | "Test premailer.premailer.Premailer._load_external_url" 2148 | faux_response = "This is not a response" 2149 | faux_uri = "https://example.com/site.css" 2150 | mocked_requests.get.return_value = MockResponse(faux_response) 2151 | p = premailer.premailer.Premailer("

A paragraph

", allow_insecure_ssl=True) 2152 | r = p._load_external_url(faux_uri) 2153 | 2154 | mocked_requests.get.assert_called_once_with(faux_uri, verify=False) 2155 | eq_(faux_response, r) 2156 | 2157 | @mock.patch("premailer.premailer.requests") 2158 | def test_load_external_url_404(self, mocked_requests): 2159 | "Test premailer.premailer.Premailer._load_external_url" 2160 | faux_response = "This is not a response" 2161 | faux_uri = "https://example.com/site.css" 2162 | mocked_requests.get.return_value = MockResponse(faux_response, status_code=404) 2163 | p = premailer.premailer.Premailer("

A paragraph

") 2164 | assert_raises(HTTPError, p._load_external_url, faux_uri) 2165 | 2166 | def test_css_text(self): 2167 | """Test handling css_text passed as a string""" 2168 | 2169 | html = """ 2170 | 2171 | 2172 | 2173 |

Hello

2174 |

Hello

2175 | Hello 2176 | 2177 | """ 2178 | 2179 | expect_html = """ 2180 | 2181 | 2186 | 2187 | 2188 |

Hello

2189 |

Hello

2190 | Hello 2191 | 2192 | """ 2193 | 2194 | css_text = """ 2195 | h1 { 2196 | color: brown; 2197 | } 2198 | h2 { 2199 | color: green; 2200 | } 2201 | a { 2202 | color: pink; 2203 | } 2204 | @media all and (max-width: 320px) { 2205 | h1 { 2206 | color: black; 2207 | } 2208 | } 2209 | 2210 | """ 2211 | 2212 | p = Premailer(html, strip_important=False, css_text=[css_text]) 2213 | result_html = p.transform() 2214 | 2215 | compare_html(expect_html, result_html) 2216 | 2217 | def test_css_text_with_only_body_present(self): 2218 | """Test handling css_text passed as a string when no or 2219 | is present""" 2220 | 2221 | html = """ 2222 |

Hello

2223 |

Hello

2224 | Hello 2225 | """ 2226 | 2227 | expect_html = """ 2228 | 2229 | 2234 | 2235 | 2236 |

Hello

2237 |

Hello

2238 | Hello 2239 | 2240 | """ 2241 | 2242 | css_text = """ 2243 | h1 { 2244 | color: brown; 2245 | } 2246 | h2 { 2247 | color: green; 2248 | } 2249 | a { 2250 | color: pink; 2251 | } 2252 | @media all and (max-width: 320px) { 2253 | h1 { 2254 | color: black; 2255 | } 2256 | } 2257 | """ 2258 | 2259 | p = Premailer(html, strip_important=False, css_text=css_text) 2260 | result_html = p.transform() 2261 | 2262 | compare_html(expect_html, result_html) 2263 | 2264 | def test_css_disable_leftover_css(self): 2265 | """Test handling css_text passed as a string when no or 2266 | is present""" 2267 | 2268 | html = """ 2269 |

Hello

2270 |

Hello

2271 | Hello 2272 | """ 2273 | 2274 | expect_html = """ 2275 | 2276 |

Hello

2277 |

Hello

2278 | Hello 2279 | 2280 | """ 2281 | 2282 | css_text = """ 2283 | h1 { 2284 | color: brown; 2285 | } 2286 | h2 { 2287 | color: green; 2288 | } 2289 | a { 2290 | color: pink; 2291 | } 2292 | @media all and (max-width: 320px) { 2293 | h1 { 2294 | color: black; 2295 | } 2296 | } 2297 | """ 2298 | 2299 | p = Premailer( 2300 | html, strip_important=False, css_text=css_text, disable_leftover_css=True 2301 | ) 2302 | result_html = p.transform() 2303 | 2304 | compare_html(expect_html, result_html) 2305 | 2306 | @staticmethod 2307 | def mocked_urlopen(url): 2308 | 'The standard "response" from the "server".' 2309 | retval = "" 2310 | if "style1.css" in url: 2311 | retval = "h1 { color: brown }" 2312 | elif "style2.css" in url: 2313 | retval = "h2 { color: pink }" 2314 | elif "style3.css" in url: 2315 | retval = "h3 { color: red }" 2316 | return retval 2317 | 2318 | @mock.patch.object(Premailer, "_load_external_url") 2319 | def test_external_styles_on_http(self, mocked_pleu): 2320 | """Test loading styles that are genuinely external""" 2321 | 2322 | html = """ 2323 | 2324 | 2325 | 2326 | 2327 | 2328 | 2329 |

Hello

2330 |

World

2331 |

World

2332 | 2333 | """ 2334 | mocked_pleu.side_effect = self.mocked_urlopen 2335 | p = Premailer(html) 2336 | result_html = p.transform() 2337 | 2338 | # Expected values are tuples of the positional values (as another 2339 | # tuple) and the ketword arguments (which are all null), hence the 2340 | # following Lisp-like explosion of brackets and commas. 2341 | expected_args = [ 2342 | (("https://www.com/style1.css",),), 2343 | (("http://www.com/style2.css",),), 2344 | (("http://www.com/style3.css",),), 2345 | ] 2346 | eq_(expected_args, mocked_pleu.call_args_list) 2347 | 2348 | expect_html = """ 2349 | 2350 | 2351 | 2352 |

Hello

2353 |

World

2354 |

World

2355 | 2356 | """ 2357 | compare_html(expect_html, result_html) 2358 | 2359 | @mock.patch.object(Premailer, "_load_external_url") 2360 | def test_external_styles_on_https(self, mocked_pleu): 2361 | """Test loading styles that are genuinely external""" 2362 | 2363 | html = """ 2364 | 2365 | 2366 | 2367 | 2368 | 2369 | 2370 |

Hello

2371 |

World

2372 |

World

2373 | 2374 | """ 2375 | 2376 | mocked_pleu.side_effect = self.mocked_urlopen 2377 | p = Premailer( 2378 | html, base_url="https://www.peterbe.com", allow_loading_external_files=True 2379 | ) 2380 | result_html = p.transform() 2381 | 2382 | expected_args = [ 2383 | (("https://www.com/style1.css",),), 2384 | (("https://www.com/style2.css",),), 2385 | (("https://www.peterbe.com/style3.css",),), 2386 | ] 2387 | eq_(expected_args, mocked_pleu.call_args_list) 2388 | expect_html = """ 2389 | 2390 | 2391 | 2392 |

Hello

2393 |

World

2394 |

World

2395 | 2396 | """ 2397 | compare_html(expect_html, result_html) 2398 | 2399 | @mock.patch.object(Premailer, "_load_external_url") 2400 | def test_external_styles_with_base_url(self, mocked_pleu): 2401 | """Test loading styles that are genuinely external if you use 2402 | the base_url""" 2403 | 2404 | html = """ 2405 | 2406 | 2407 | 2408 | 2409 |

Hello

2410 | 2411 | """ 2412 | mocked_pleu.return_value = "h1 { color: brown }" 2413 | p = Premailer( 2414 | html, base_url="http://www.peterbe.com/", allow_loading_external_files=True 2415 | ) 2416 | result_html = p.transform() 2417 | expected_args = [(("http://www.peterbe.com/style.css",),)] 2418 | eq_(expected_args, mocked_pleu.call_args_list) 2419 | 2420 | expect_html = """ 2421 | 2422 | 2423 | 2424 |

Hello

2425 | 2426 | """ 2427 | compare_html(expect_html, result_html) 2428 | 2429 | def test_disabled_validator(self): 2430 | """test disabled_validator""" 2431 | 2432 | html = """ 2433 | 2434 | Title 2435 | 2442 | 2443 | 2444 |

Hi!

2445 |

Yes!

2446 | 2447 | """ 2448 | 2449 | expect_html = """ 2450 | 2451 | Title 2452 | 2453 | 2454 |

Hi!

2455 |

Yes!

2456 | 2457 | """ 2458 | 2459 | p = Premailer(html, disable_validation=True) 2460 | result_html = p.transform() 2461 | 2462 | compare_html(expect_html, result_html) 2463 | 2464 | def test_comments_in_media_queries(self): 2465 | """CSS comments inside a media query block should not be a problem""" 2466 | html = """ 2467 | 2468 | 2469 | 2470 | Document 2471 | 2476 | 2477 | 2478 | """ 2479 | 2480 | p = Premailer(html, disable_validation=True) 2481 | result_html = p.transform() 2482 | ok_("/* comment */" in result_html) 2483 | 2484 | def test_unknown_in_media_queries(self): 2485 | """CSS unknown rule inside a media query block should not be a problem""" 2486 | html = """ 2487 | 2488 | 2489 | 2490 | Document 2491 | 2498 | 2499 | 2500 | """ 2501 | 2502 | p = Premailer(html, disable_validation=True) 2503 | result_html = p.transform() 2504 | ok_("/* unknown rule */" in result_html) 2505 | 2506 | def test_fontface_selectors_with_no_selectortext(self): 2507 | """ 2508 | @font-face selectors are weird. 2509 | This is a fix for https://github.com/peterbe/premailer/issues/71 2510 | """ 2511 | html = """ 2512 | 2513 | 2514 | 2515 | Document 2516 | 2527 | 2528 | 2529 | """ 2530 | 2531 | p = Premailer(html, disable_validation=True) 2532 | p.transform() # it should just work 2533 | 2534 | def test_keyframe_selectors(self): 2535 | """ 2536 | keyframes shouldn't be a problem. 2537 | """ 2538 | html = """ 2539 | 2540 | 2541 | 2542 | Document 2543 | 2573 | 2574 | 2575 | """ 2576 | 2577 | p = Premailer(html, disable_validation=True) 2578 | p.transform() # it should just work 2579 | 2580 | def test_capture_cssutils_logging(self): 2581 | """you can capture all the warnings, errors etc. from cssutils 2582 | with your own logging.""" 2583 | html = """ 2584 | 2585 | 2586 | 2587 | Document 2588 | 2594 | 2595 | 2596 | """ 2597 | 2598 | mylog = StringIO() 2599 | myhandler = logging.StreamHandler(mylog) 2600 | p = Premailer(html, cssutils_logging_handler=myhandler) 2601 | p.transform() # it should work 2602 | eq_( 2603 | mylog.getvalue(), "CSSStylesheet: Unknown @rule found. [2:13: @keyframes]\n" 2604 | ) 2605 | 2606 | # only log errors now 2607 | mylog = StringIO() 2608 | myhandler = logging.StreamHandler(mylog) 2609 | p = Premailer( 2610 | html, 2611 | cssutils_logging_handler=myhandler, 2612 | cssutils_logging_level=logging.ERROR, 2613 | ) 2614 | p.transform() # it should work 2615 | eq_(mylog.getvalue(), "") 2616 | 2617 | def test_type_test(self): 2618 | """test the correct type is returned""" 2619 | 2620 | html = """ 2621 | 2622 | Title 2623 | 2629 | 2630 | 2631 |

Hi!

2632 |

Yes!

2633 | 2634 | """ 2635 | 2636 | p = Premailer(html) 2637 | result = p.transform() 2638 | eq_(type(result), type("")) 2639 | 2640 | html = fromstring(html) 2641 | etree_type = type(html) 2642 | 2643 | p = Premailer(html) 2644 | result = p.transform() 2645 | ok_(type(result) != etree_type) 2646 | 2647 | def test_ignore_some_inline_stylesheets(self): 2648 | """test that it's possible to put a `data-premailer="ignore"` 2649 | attribute on a 2658 | 2661 | 2662 | 2663 |

Hello

2664 |

World

2665 | 2666 | """ 2667 | 2668 | expect_html = """ 2669 | 2670 | Title 2671 | 2674 | 2675 | 2676 |

Hello

2677 |

World

2678 | 2679 | """ 2680 | 2681 | p = Premailer(html, disable_validation=True) 2682 | result_html = p.transform() 2683 | compare_html(expect_html, result_html) 2684 | 2685 | def test_ignore_does_not_strip_importants(self): 2686 | """test that it's possible to put a `data-premailer="ignore"` 2687 | attribute on a 2695 | 2698 | 2699 | 2700 |

Hello

2701 |

World

2702 | 2703 | """ 2704 | 2705 | expect_html = """ 2706 | 2707 | Title 2708 | 2711 | 2712 | 2713 |

Hello

2714 |

World

2715 | 2716 | """ 2717 | 2718 | p = Premailer(html, disable_validation=True) 2719 | result_html = p.transform() 2720 | compare_html(expect_html, result_html) 2721 | 2722 | @mock.patch("premailer.premailer.warnings") 2723 | def test_ignore_some_incorrectly(self, warnings_mock): 2724 | """You can put `data-premailer="ignore"` but if the attribute value 2725 | is something we don't recognize you get a warning""" 2726 | 2727 | html = """ 2728 | 2729 | Title 2730 | 2733 | 2734 | 2735 |

Hello

2736 |

World

2737 | 2738 | """ 2739 | 2740 | expect_html = """ 2741 | 2742 | Title 2743 | 2744 | 2745 |

Hello

2746 |

World

2747 | 2748 | """ 2749 | 2750 | p = Premailer(html, disable_validation=True) 2751 | result_html = p.transform() 2752 | warnings_mock.warn.assert_called_with( 2753 | "Unrecognized data-premailer attribute ('blah')" 2754 | ) 2755 | 2756 | compare_html(expect_html, result_html) 2757 | 2758 | def test_ignore_some_external_stylesheets(self): 2759 | """test that it's possible to put a `data-premailer="ignore"` 2760 | attribute on a tag and it gets left alone (except that 2761 | the attribute gets removed)""" 2762 | 2763 | # Know thy fixtures! 2764 | # The test-external-links.css has a `h1{color:blue}` 2765 | # And the test-external-styles.css has a `h1{color:brown}` 2766 | html = """ 2767 | 2768 | Title 2769 | 2771 | 2774 | 2775 | 2776 |

Hello

2777 | 2778 | """ 2779 | 2780 | # Note that the `test-external-links.css` gets converted to a inline 2781 | # style sheet. 2782 | expect_html = """ 2783 | 2784 | Title 2785 | 2786 | 2788 | 2789 | 2790 |

Hello

2791 | 2792 | """.replace( 2793 | "style\nsheet", "stylesheet" 2794 | ) 2795 | 2796 | p = Premailer(html, disable_validation=True, allow_loading_external_files=True) 2797 | result_html = p.transform() 2798 | compare_html(expect_html, result_html) 2799 | 2800 | def test_turnoff_cache_works_as_expected(self): 2801 | html = """ 2802 | 2803 | 2811 | 2812 | 2813 |
2814 | 2815 | """ 2816 | 2817 | expect_html = """ 2818 | 2819 | 2820 | 2821 |
2822 | 2823 | """ 2824 | 2825 | p = Premailer(html, cache_css_parsing=False) 2826 | self.assertFalse(p.cache_css_parsing) 2827 | # run one time first 2828 | p.transform() 2829 | result_html = p.transform() 2830 | 2831 | compare_html(expect_html, result_html) 2832 | 2833 | def test_links_without_protocol(self): 2834 | """If you the base URL is set to https://example.com and your html 2835 | contains ... then the URL to point to 2836 | is "https://otherdomain.com/" not "https://example.com/file.css" 2837 | """ 2838 | html = """ 2839 | 2840 | 2841 | 2842 | 2843 | 2844 | """ 2845 | 2846 | expect_html = """ 2847 | 2848 | 2849 | 2850 | 2851 | 2852 | """ 2853 | 2854 | p = Premailer(html, base_url="https://www.peterbe.com") 2855 | result_html = p.transform() 2856 | compare_html(expect_html.format(protocol="https"), result_html) 2857 | 2858 | p = Premailer(html, base_url="http://www.peterbe.com") 2859 | result_html = p.transform() 2860 | compare_html(expect_html.format(protocol="http"), result_html) 2861 | 2862 | # Because you can't set a base_url without a full protocol 2863 | p = Premailer(html, base_url="www.peterbe.com") 2864 | assert_raises(ValueError, p.transform) 2865 | 2866 | def test_align_float_images(self): 2867 | 2868 | html = """ 2869 | 2870 | Title 2871 | 2876 | 2877 | 2878 |

text 2879 | text 2880 | text 2881 | 2882 | """ 2883 | 2884 | expect_html = """ 2885 | 2886 | Title 2887 | 2888 | 2889 |

text 2890 | text 2891 | text 2892 |

2893 | 2894 | """ 2895 | 2896 | p = Premailer(html, align_floating_images=True) 2897 | result_html = p.transform() 2898 | compare_html(expect_html, result_html) 2899 | 2900 | def test_remove_unset_properties(self): 2901 | html = """ 2902 | 2903 | 2914 | 2915 | 2916 |
2917 | 2918 | """ 2919 | 2920 | expect_html = """ 2921 | 2922 | 2923 | 2924 |
2925 |
2926 | 2927 | """ 2928 | 2929 | p = Premailer(html, remove_unset_properties=True) 2930 | self.assertTrue(p.remove_unset_properties) 2931 | result_html = p.transform() 2932 | compare_html(expect_html, result_html) 2933 | 2934 | def test_six_color(self): 2935 | r = Premailer.six_color("#cde") 2936 | e = "#ccddee" 2937 | self.assertEqual(e, r) 2938 | 2939 | def test_3_digit_color_expand(self): 2940 | "Are 3-digit color values expanded into 6-digits for IBM Notes" 2941 | html = """ 2942 | 2947 | 2948 |

color test

2949 |

2950 | This is a test of color handling. 2951 |

2952 | 2953 | """ 2954 | expect_html = """ 2955 | 2956 | 2957 | 2958 |

color test

2959 |

2960 | This is a test of color handling. 2961 |

2962 | 2963 | """ 2964 | p = Premailer(html, remove_unset_properties=True) 2965 | result_html = p.transform() 2966 | compare_html(expect_html, result_html) 2967 | 2968 | def test_inline_important(self): 2969 | "Are !important tags preserved inline." 2970 | 2971 | html = """ 2972 | 2973 | 2974 | 2975 | 2976 | 2977 |
blah
2978 | 2979 | """ 2980 | 2981 | expect_html = """ 2982 | 2983 | 2984 | 2985 | 2986 | 2987 |
blah
2988 | 2989 | """ 2990 | p = Premailer( 2991 | html, remove_classes=False, keep_style_tags=True, strip_important=False 2992 | ) 2993 | result_html = p.transform() 2994 | compare_html(expect_html, result_html) 2995 | 2996 | def test_pseudo_selectors_without_selector(self): 2997 | """Happens when you have pseudo selectors without an actual selector. 2998 | Which means it's not possible to find it in the DOM. 2999 | 3000 | For example: 3001 | 3002 | 3005 | 3006 | Semantic-UI uses this in its normalizer. 3007 | 3008 | Original issue: https://github.com/peterbe/premailer/issues/184 3009 | """ 3010 | 3011 | html = """ 3012 | 3013 | 3019 |

Hey

3020 | 3021 | """ 3022 | 3023 | expect_html = """ 3024 | 3025 | 3026 | 3032 | 3033 | 3034 |

Hey

3035 | 3036 | 3037 | """ 3038 | p = Premailer(html, exclude_pseudoclasses=False, keep_style_tags=True) 3039 | result_html = p.transform() 3040 | compare_html(expect_html, result_html) 3041 | 3042 | def test_preserve_handlebar_syntax(self): 3043 | """Demonstrate encoding of handlebar syntax with preservation. 3044 | 3045 | Original issue: https://github.com/peterbe/premailer/issues/248 3046 | """ 3047 | 3048 | html = """ 3049 | 3050 | 3051 | " }}"> 3052 | 3053 | """ 3054 | 3055 | expected_preserved_html = """ 3056 | 3057 | 3058 | 3059 | 3060 | 3061 | " }}"> 3062 | 3063 | 3064 | """ 3065 | 3066 | expected_neglected_html = """ 3067 | 3068 | 3069 | 3070 | 3071 | 3072 | " }}"> 3073 | 3074 | 3075 | """ 3076 | p = Premailer(html, preserve_handlebar_syntax=True) 3077 | result_preserved_html = p.transform() 3078 | compare_html(expected_preserved_html, result_preserved_html) 3079 | 3080 | p = Premailer(html) 3081 | result_neglected_html = p.transform() 3082 | compare_html(expected_neglected_html, result_neglected_html) 3083 | 3084 | def test_allow_loading_external_files(self): 3085 | """Demonstrate the risks of allow_loading_external_files""" 3086 | external_content = "foo { bar:buz }" 3087 | with tempfile.TemporaryDirectory() as tmpdirname: 3088 | tmp_file = os.path.join(tmpdirname, "external.css") 3089 | with open(tmp_file, "w") as f: 3090 | f.write(external_content) 3091 | html = """ 3092 | 3093 | 3094 | 3095 | 3096 | 3097 | """.format( 3098 | tmp_file 3099 | ) 3100 | 3101 | p = Premailer(html) 3102 | assert_raises(ExternalFileLoadingError, p.transform) 3103 | 3104 | # Imagine if `allow_loading_external_files` and `keep_style_tags` where 3105 | # both on, in some configuration or instance, but the HTML being 3106 | # sent in, this program will read that file unconditionally and include 3107 | # it in the file rendered HTML output. 3108 | # E.g. `` 3109 | p = Premailer(html, allow_loading_external_files=True, keep_style_tags=True) 3110 | out = p.transform() 3111 | assert external_content in out 3112 | -------------------------------------------------------------------------------- /premailer/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from premailer.premailer import capitalize_float_margin 4 | 5 | 6 | class UtilsTestCase(unittest.TestCase): 7 | def testcapitalize_float_margin(self): 8 | self.assertEqual(capitalize_float_margin("margin:1em"), "Margin:1em") 9 | self.assertEqual(capitalize_float_margin("margin-left:1em"), "Margin-left:1em") 10 | self.assertEqual(capitalize_float_margin("float:right;"), "Float:right;") 11 | self.assertEqual( 12 | capitalize_float_margin("float:right;color:red;margin:0"), 13 | "Float:right;color:red;Margin:0", 14 | ) 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import re 3 | 4 | from setuptools import setup, find_packages 5 | 6 | 7 | README = os.path.join(os.path.dirname(__file__), "README.rst") 8 | long_description = open(README).read().strip() + "\n\n" 9 | 10 | 11 | def find_version(*file_paths): 12 | version_file_path = os.path.join(os.path.dirname(__file__), *file_paths) 13 | with open(version_file_path) as f: 14 | version_file = f.read() 15 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 16 | if version_match: 17 | return version_match.group(1) 18 | raise RuntimeError("Unable to find version string.") 19 | 20 | 21 | install_requires = ["lxml", "cssselect", "cssutils", "requests", "cachetools"] 22 | 23 | tests_require = ["nose", "mock"] 24 | 25 | setup( 26 | name="premailer", 27 | version=find_version("premailer", "__init__.py"), 28 | description="Turns CSS blocks into style attributes", 29 | long_description=long_description, 30 | keywords="html lxml email mail style", 31 | author="Peter Bengtsson", 32 | author_email="mail@peterbe.com", 33 | url="http://github.com/peterbe/premailer", 34 | license="Python", 35 | classifiers=[ 36 | "Development Status :: 5 - Production/Stable", 37 | "Environment :: Other Environment", 38 | "Environment :: Web Environment", 39 | "Intended Audience :: Developers", 40 | "License :: OSI Approved :: Python Software Foundation License", 41 | "Operating System :: OS Independent", 42 | "Programming Language :: Python", 43 | "Programming Language :: Python :: 3", 44 | "Programming Language :: Python :: 3.5", 45 | "Programming Language :: Python :: 3.6", 46 | "Programming Language :: Python :: 3.7", 47 | "Programming Language :: Python :: 3.8", 48 | "Programming Language :: Python :: Implementation :: CPython", 49 | "Programming Language :: Python :: Implementation :: PyPy", 50 | "Topic :: Communications", 51 | "Topic :: Internet :: WWW/HTTP", 52 | "Topic :: Other/Nonlisted Topic", 53 | "Topic :: Software Development :: Libraries :: Python Modules", 54 | ], 55 | packages=find_packages(), 56 | include_package_data=True, 57 | test_suite="nose.collector", 58 | tests_require=tests_require, 59 | extras_require={ 60 | "dev": ["tox", "twine", "therapist", "black", "flake8", "wheel"], 61 | "test": tests_require, 62 | }, 63 | zip_safe=False, 64 | install_requires=install_requires, 65 | ) 66 | -------------------------------------------------------------------------------- /stresstest/README.md: -------------------------------------------------------------------------------- 1 | The objective here is to bombard this with examples. This can then be 2 | used to execute stress tests to see where code is being overspent and 3 | what we can do to optimize such areas. 4 | 5 | Each directory is supposed to have a `input.html` and an 6 | `output.html` and an optional `options.json`. 7 | 8 | At the time of writing, Oct 2018, this is work-in-progress. 9 | -------------------------------------------------------------------------------- /stresstest/run.py: -------------------------------------------------------------------------------- 1 | import re 2 | import argparse 3 | import json 4 | import random 5 | import os 6 | 7 | from premailer import transform 8 | 9 | _root = os.path.join(os.path.dirname(__file__), "samples") 10 | samples = [ 11 | os.path.join(_root, x) 12 | for x in os.listdir(_root) 13 | if os.path.isdir(os.path.join(_root, x)) 14 | ] 15 | 16 | 17 | def run(iterations): 18 | def raw(s): 19 | return re.sub(r">\s+<", "><", s.replace("\n", "")) 20 | 21 | print(samples) 22 | for i in range(iterations): 23 | random.shuffle(samples) 24 | for sample in samples: 25 | with open(os.path.join(sample, "input.html")) as f: 26 | input_html = f.read() 27 | with open(os.path.join(sample, "output.html")) as f: 28 | output_html = f.read() 29 | try: 30 | with open(os.path.join(sample, "options.json")) as f: 31 | options = json.load(f) 32 | except FileNotFoundError: 33 | options = {} 34 | 35 | options["pretty_print"] = False 36 | got_html = transform(input_html, **options) 37 | got_html_raw = raw(got_html) 38 | output_html_raw = raw(output_html) 39 | if got_html_raw != output_html_raw: 40 | print("FAIL!", sample) 41 | print("GOT ".ljust(80, "-")) 42 | print(got_html) 43 | # print(repr(got_html_raw)) 44 | print("EXPECTED ".ljust(80, "-")) 45 | print(output_html) 46 | # print(repr(output_html_raw)) 47 | print() 48 | assert 0, sample 49 | 50 | 51 | def main(args): 52 | parser = argparse.ArgumentParser(usage="python run.py [options]") 53 | 54 | parser.add_argument("--iterations", default=10, type=int) 55 | 56 | options = parser.parse_args(args) 57 | 58 | run(options.iterations) 59 | return 0 60 | 61 | 62 | if __name__ == "__main__": # pragma: no cover 63 | import sys 64 | 65 | sys.exit(main(sys.argv[1:])) 66 | -------------------------------------------------------------------------------- /stresstest/samples/001/input.html: -------------------------------------------------------------------------------- 1 | 2 | 11 |

Peter

12 |

Hej

13 | 14 | 15 | -------------------------------------------------------------------------------- /stresstest/samples/001/output.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Peter

7 |

Hej

8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /stresstest/samples/002/input.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Title 5 | 15 | 16 | 17 | 18 |

Hi!

19 |

Yes!

20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /stresstest/samples/002/output.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Title 5 | 6 | 7 | 8 |

Hi!

9 |

Yes!

10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /stresstest/samples/003/input.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Title 5 | 10 | 11 | 12 | 13 |

Yes!

14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /stresstest/samples/003/options.json: -------------------------------------------------------------------------------- 1 | {"remove_classes": true} 2 | -------------------------------------------------------------------------------- /stresstest/samples/003/output.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Title 5 | 6 | 7 | 8 |

Yes!

9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /stresstest/samples/004/input.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Title 5 | 15 | 16 | 17 | 18 |

Hi!

19 |

Yes!

20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /stresstest/samples/004/options.json: -------------------------------------------------------------------------------- 1 | {"keep_style_tags": true} 2 | -------------------------------------------------------------------------------- /stresstest/samples/004/output.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Title 5 | 15 | 16 | 17 | 18 |

Hi!

19 |

Yes!

20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /stresstest/samples/005/input.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 21 | 22 | 23 | 26 | 29 | 30 | 31 | 34 | 37 | 38 | 39 |
AB
16 | A 17 | 19 | B 20 |
24 | A 25 | 27 | B 28 |
32 | A 33 | 35 | B 36 |
40 | 41 | -------------------------------------------------------------------------------- /stresstest/samples/005/output.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 16 | 17 | 18 | 21 | 24 | 25 | 26 | 29 | 32 | 33 | 34 |
AB
11 | A 12 | 14 | B 15 |
19 | A 20 | 22 | B 23 |
27 | A 28 | 30 | B 31 |
35 | 36 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint-py39, 4 | py35, 5 | py36, 6 | py37, 7 | py38, 8 | py39, 9 | pypy3, 10 | 11 | [gh-actions] 12 | python = 13 | 3.5: py35 14 | 3.6: py36 15 | 3.7: py37 16 | 3.8: py38 17 | 3.9: py39, lint 18 | pypy3: pypy3 19 | 20 | [testenv] 21 | usedevelop = true 22 | install_command = 23 | pip install {opts} {packages} 24 | extras = 25 | test 26 | commands = 27 | nosetests --with-coverage --cover-package=premailer 28 | 29 | [testenv:lint-py39] 30 | extras = dev 31 | commands=therapist run --use-tracked-files 32 | --------------------------------------------------------------------------------