Repository: mrf345/flask_minify

Issue:
Would be very useful as a decorator
I have an app which builds js assets at runtime. It would be very useful if I could apply it to a single endpoint that generates those, rather than the whole app and then try and regex all the other endpoints of the app.

Either a decorator to apply to an endpoint or a positive list vs only a negative one would be great.

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index c542f23..99ddd7b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -10,6 +10,6 @@ python:
 install:
   - pip install -r requirements/test.txt
 # command to run tests
-script: flake8 ./flask_minify/* && py.test --cov=./flask_minify tests/*
+script: flake8 ./flask_minify/* && py.test --cov=./flask_minify ./tests/integration.py ./tests/units.py
 after_success:
   - coveralls
diff --git a/README.md b/README.md
index 7c0b7af..754e4fa 100644
--- a/README.md
+++ b/README.md
@@ -25,48 +25,37 @@
 > - `python setup.py install`
 
 ## Setup:
-#### - Inside Flask app:
-
+> With this setup the extension will minify every HTML request, unless it's explicitly bypassed.
 ```python
 from flask import Flask
 from flask_minify import minify
 
 app = Flask(__name__)
-minify(app=app)
+minify(app=app, html=True, js=True, cssless=True)
 ```
 
-#### - Result:
-
-> Before:
-```html
-<html>
-  <head>
-    <script>
-      if (true) {
-      	console.log('working !')
-      }
-    </script>
-    <style>
-      body {
-      	background-color: red;
-      }
-    </style>
-  </head>
-  <body>
-    <h1>Flask-Less Example !</h1>
-  </body>
-</html>
-```
-> After:
-```html
-<html> <head><script>if(true){console.log('working !')}</script><style>body{background-color:red;}</style></head> <body> <h1>Flask-Less Example !</h1> </body> </html>
+#### - Using a decorator instead:
+> You can set the extension to be passive so it will minify only the decorated routes.
+
+```python
+from flask import Flask
+from flask_minify import minify, decorators
+
+app = Flask(__name__)
+minify(app=app, passive=True)
+
+@app.route('/')
+@decorators.minify(html=True, js=True, cssless=True)
+def example():
+  return '<h1>Example...</h1>'
 ```
 
 ## Options:
 ```python
 def __init__(
         self, app=None, html=True, js=True, cssless=True,
-        fail_safe=True, bypass=[], bypass_caching=[], caching_limit=1
+        fail_safe=True, bypass=[], bypass_caching=[], caching_limit=1,
+        passive=False
     ):
         ''' Extension to minify flask response for html, javascript, css and less.
 
@@ -86,6 +75,8 @@ def __init__(
                 list of endpoints to bypass caching for. (Regex)
             caching_limit: int
                 to limit the number of minified response variations.
+            passive: bool
+                to disable active minifying.
 
             NOTE: if `caching_limit` is set to 0, we'll not cache any endpoint
                   response, so if you want to disable caching just do that.
diff --git a/flask_minify/__init__.py b/flask_minify/__init__.py
index cce2655..5321e96 100644
--- a/flask_minify/__init__.py
+++ b/flask_minify/__init__.py
@@ -1,4 +1,5 @@
 from .main import minify # noqa
+from . import decorators # noqa
 
 __version__ = '0.17'
 __doc__ = 'Flask extension to minify html, css, js and less.'
diff --git a/flask_minify/decorators.py b/flask_minify/decorators.py
new file mode 100644
index 0000000..a44d6e0
--- /dev/null
+++ b/flask_minify/decorators.py
@@ -0,0 +1,60 @@
+from functools import wraps
+
+from .main import hashing, minify as main
+from .utils import get_tag_contents
+
+
+def minify(html=False, js=False, cssless=False, cache=True, fail_safe=True):
+    ''' Decorator to minify endpoint HTML output.
+
+    Parameters
+    ----------
+        html: bool
+            enable minifying HTML content.
+        js: bool
+            enable minifying JavaScript content.
+        cssless: bool
+            enable minifying CSS/LESS content.
+        cache: bool
+            enable caching minifed response.
+        failsafe: bool
+            silence encountered exceptions.
+
+    Returns
+    -------
+        String of minified HTML content.
+    '''
+    def wrapper(function):
+        @wraps(function)
+        def decorator(*args, **kwargs):
+            response_text = function(*args, **kwargs)
+            text = response_text
+            key = None
+            cache_key, cached = function.__dict__.get('minify', (None, None))
+            should_minify = isinstance(text, str) and any([html, js, cssless])
+
+            if should_minify:
+                if cache:
+                    key = hashing(text).hexdigest()
+
+                if cache_key != key or not cache:
+                    for tag, enabled in main.iter_tags_to_minify(cssless, js):
+                        for content in get_tag_contents(text, tag):
+                            text = text.replace(
+                                content,
+                                main.get_minified(content,
+                                                  tag,
+                                                  fail_safe))
+
+                    if html:
+                        text = main.get_minified(text, 'html', fail_safe)
+
+                    if cache:
+                        function.__dict__['minify'] = (hashing(response_text)
+                                                       .hexdigest(), text)
+
+            return cache if all([cache_key == key,
+                                 cache,
+                                 should_minify]) else text
+        return decorator
+    return wrapper
diff --git a/flask_minify/main.py b/flask_minify/main.py
index 6a8d38c..deb6933 100644
--- a/flask_minify/main.py
+++ b/flask_minify/main.py
@@ -1,13 +1,13 @@
 from sys import maxsize
 from six import StringIO
-from re import compile as compile_re, DOTALL
+from re import compile as compile_re
 from flask import request, _app_ctx_stack
 from xxhash import xxh64, xxh32
 from htmlmin import minify as minify_html
 from lesscpy import compile as compile_less
 from jsmin import jsmin
 
-from flask_minify.utils import is_empty
+from flask_minify.utils import get_tag_contents, is_html
 
 # optimized hashing speed based on cpu architecture
 hashing = xxh64 if maxsize > 2**32 else xxh32
@@ -16,7 +16,8 @@ hashing = xxh64 if maxsize > 2**32 else xxh32
 class minify(object):
     def __init__(
         self, app=None, html=True, js=True, cssless=True,
-        fail_safe=True, bypass=[], bypass_caching=[], caching_limit=1
+        fail_safe=True, bypass=[], bypass_caching=[], caching_limit=1,
+        passive=False
     ):
         ''' Extension to minify flask response for html, javascript, css and less.
 
@@ -36,6 +37,8 @@ class minify(object):
                 list of endpoints to bypass caching for. (Regex)
             caching_limit: int
                 to limit the number of minifed response variations.
+            passive: bool
+                to disable active minifying.
 
             NOTE: if `caching_limit` is set to 0, we'll not cache any endpoint
                   response, so if you want to disable caching just do that.
@@ -67,6 +70,7 @@ class minify(object):
         self.caching_limit = caching_limit
         self.cache = {}
         self._app = app
+        self.passive = passive
 
         app and self.init_app(app)
 
@@ -91,13 +95,6 @@ class minify(object):
         with self.app.app_context():
             return getattr(request, 'endpoint', '') or ''
 
-    @property
-    def iter_tags_to_minify(self):
-        ''' Safely iterate html tags to minify, if tag's enabled '''
-        tags = {'style': self.cssless, 'script': self.js}
-
-        return getattr(tags, 'iteritems', tags.items)()
-
     def init_app(self, app):
         ''' Handle initiation of multiple apps NOTE:Factory Method'''
         app.after_request(self.main)
@@ -107,7 +104,27 @@ class minify(object):
         ''' Nothing todo on app context teardown XXX:Factory Method'''
         pass
 
-    def get_minified(self, content, style):
+    @classmethod
+    def iter_tags_to_minify(cls, cssless, js):
+        ''' Safely iterate html tags to minify, if tag's enabled.
+
+        Parameters
+        ----------
+            cssless: bool
+                to enable css and less.
+            js: bool
+                to enable javascript.
+
+        Returns
+        -------
+            Iterable of html's tag and its status.
+        '''
+        tags = {'style': cssless, 'script': js}
+
+        return getattr(tags, 'iteritems', tags.items)()
+
+    @classmethod
+    def get_minified(cls, content, tag, fail_safe=False):
         ''' To minify css/less or javascript and failsafe that.
 
         Parameters
@@ -116,17 +133,27 @@ class minify(object):
                 a script or style html tag content.
             style: bool
                 if the content belongs to a style html tag.
+            failsafe: bool
+                to avoid raising error while minifying.
 
         Returns
         -------
             String of minified passed content.
         '''
         try:
-            return compile_less(StringIO(content), minify=True, xminify=True)\
-                   if style else jsmin(content).replace('\n', ';')
+            if tag == 'style':
+                return compile_less(StringIO(content),
+                                    minify=True,
+                                    xminify=True)
+            elif tag == 'script':
+                return jsmin(content).replace('\n', ';')
+            else:
+                return minify_html(content)
+
         except Exception as exception:
-            if self.fail_safe:
+            if fail_safe:
                 return content
+
             raise exception
 
     def get_endpoint_matches(self, patterns):
@@ -147,15 +174,15 @@ class minify(object):
 
         return matches
 
-    def store_or_restore_cache(self, content, style):
+    def store_or_restore_cache(self, content, tag):
         ''' Check if the content is already cached and restore or store it.
 
         Parameters
         ----------
             content: str
                 a script or style html tag content.
-            style: bool
-                if the content belongs to a style html tag.
+            tag: bool
+                html tag the content belongs to.
 
         Returns
         -------
@@ -172,9 +199,11 @@ class minify(object):
         if not cache() and should_cache:
             self.cache\
                 .setdefault(self.endpoint, {})\
-                .update({key: self.get_minified(content, style)})
+                .update({key: self.get_minified(content,
+                                                tag,
+                                                self.fail_safe)})
 
-        return cache() or self.get_minified(content, style)
+        return cache() or self.get_minified(content, tag, self.fail_safe)
 
     def main(self, response):
         ''' Where a dragon once lived!
@@ -188,25 +217,20 @@ class minify(object):
         -------
             Minified flask response if it fits the requirements.
         '''
-        valid_html = response.content_type == u'text/html; charset=utf-8'
-        should_bypass = self.get_endpoint_matches(self.bypass)
+        should_bypass = self.get_endpoint_matches(self.bypass) or self.passive
 
-        if valid_html and not should_bypass:
+        if is_html(response) and not should_bypass:
             response.direct_passthrough = False
             text = response.get_data(as_text=True)
 
-            for tag, enabled in self.iter_tags_to_minify:
+            for tag, enabled in self.iter_tags_to_minify(self.cssless,
+                                                         self.js):
                 if enabled:
-                    tag_contents = compile_re(
-                        r'<{0}[^>]*>(.+?)</{0}>'
-                        .format(tag), DOTALL).findall(text)
-
-                    for content in tag_contents:
-                        if not is_empty(content):
-                            text = text.replace(
-                                content,
-                                self.store_or_restore_cache
-                                (content, tag == 'style'))
-
-            response.set_data(minify_html(text) if self.html else text)
+                    for content in get_tag_contents(text, tag):
+                        text = text.replace(content,
+                                            self.store_or_restore_cache
+                                            (content, tag))
+
+            response.set_data(self.get_minified(text, 'html', self.fail_safe)
+                              if self.html else text)
         return response
diff --git a/flask_minify/utils.py b/flask_minify/utils.py
index 66fb0f7..3e6becb 100644
--- a/flask_minify/utils.py
+++ b/flask_minify/utils.py
@@ -1,8 +1,8 @@
-from re import sub
+from re import sub, compile as compile_re, DOTALL
 
 
 def is_empty(content):
-    ''' check if the content is truly empty.
+    ''' Check if the content is truly empty.
 
     Paramaters
     ----------
@@ -14,3 +14,39 @@ def is_empty(content):
         Boolean True if empty False if not.
     '''
     return not bool(len(sub(r'[ |\n|\t]', '', content or '').strip()))
+
+
+def get_tag_contents(html, tag):
+    ''' Get list of html tag contents.
+
+    Parameters
+    ----------
+        html: string
+            html flask response content.
+        tag: string
+            tag to retrieve its specific content.
+
+    Returns
+    -------
+        String of specific tag's inner content.
+    '''
+    contents = compile_re(r'<{0}[^>]*>(.+?)</{0}>'
+                          .format(tag), DOTALL).findall(html)
+
+    return [content for content in contents if not is_empty(content)]
+
+
+def is_html(response):
+    ''' Check if Flask response if of HTML type.
+
+    Parameters
+    ----------
+        response: Flask response
+
+    Returns
+    -------
+        True if valid False if not.
+    '''
+    content_type = getattr(response, 'content_type', '')
+
+    return 'text/html' in content_type.lower()
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..b0bcc19
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,13 @@
+[tox]
+envlist=flake8,py27,py34,py35,py36,py37,py38
+skipsdist = True
+skip_missing_interpreters= True
+
+[testenv]
+commands=py.test --cov=./flask_minify ./tests/integration.py ./tests/units.py
+deps= -rrequirements/test.txt
+
+[testenv:flake8]
+basepython=python3.8
+commands= flake8 flask_minify
+deps= flake8


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/integration.py b/tests/integration.py
index 0da2219..c9c8960 100644
--- a/tests/integration.py
+++ b/tests/integration.py
@@ -1,55 +1,8 @@
-from sys import path as sys_path
-from os import path
-from importlib import import_module
 from pytest import fixture
-from flask import Flask
 
-from constants import (
-    HTML, JS, LESS, MINIFED_HTML, MINIFIED_JS,
-    MINIFED_LESS, FALSE_LESS, MINIFED_STRIPED)
-
-
-sys_path.append(path.dirname(path.dirname(__file__)))
-minify = import_module('flask_minify').minify
-
-
-app = Flask(__name__)
-store_minify = minify(app=app, fail_safe=False)
-
-
-@app.route('/html')
-def html():
-    return HTML
-
-
-@app.route('/bypassed')
-def bypassed():
-    return HTML
-
-
-@app.route('/js')
-def js():
-    return JS
-
-
-@app.route('/cssless')
-def cssless():
-    return LESS
-
-
-@app.route('/js/<addition>')
-def js_addition(addition=None):
-    return '''<script>
-        ["J", "S"].reduce(
-            function (a, r) {
-                return a + r
-            })
-    ''' + (addition + '\n</script>')
-
-
-@app.route('/cssless_false')
-def cssless_false():
-    return FALSE_LESS
+from .setup import store_minify, app
+from .constants import (HTML, LESS, FALSE_LESS, MINIFED_HTML, MINIFIED_JS,
+                        MINIFED_LESS, MINIFED_STRIPED)
 
 
 @fixture
@@ -60,39 +13,40 @@ def client():
     store_minify.caching_limit = 1
     store_minify.bypass_caching = []
     store_minify.cache = {}
+    store_minify.passive = False
     app.config['TESTING'] = True
     client = app.test_client()
     yield client
 
 
 def test_html_minify(client):
-    """ testing HTML minify option """
+    ''' testing HTML minify option '''
     resp = client.get('/html')
     assert MINIFED_HTML == resp.data
 
 
 def test_html_bypassing(client):
-    """ testing HTML route bypassing """
+    ''' testing HTML route bypassing '''
     store_minify.bypass.append('html')
     resp = client.get('/html')
     assert bytes(HTML.encode('utf-8')) == resp.data
 
 
 def test_javascript_minify(client):
-    """ testing JavaScript minify option """
+    ''' testing JavaScript minify option '''
     resp = client.get('/js')
     assert MINIFIED_JS == resp.data
 
 
 def test_lesscss_minify(client):
-    """ testing css and less minify option """
+    ''' testing css and less minify option '''
     store_minify.cssless = True
     resp = client.get('/cssless')
     assert MINIFED_LESS == resp.data
 
 
 def test_minify_cache(client):
-    """ testing caching minifed response """
+    ''' testing caching minifed response '''
     store_minify.caching_limit = 10
     client.get('/cssless')  # hit it twice, to get the cached minified response
     resp = client.get('/cssless').data
@@ -103,7 +57,7 @@ def test_minify_cache(client):
 
 
 def test_fail_safe(client):
-    """ testing fail safe enabled with false input """
+    ''' testing fail safe enabled with false input '''
     store_minify.fail_safe = True
     store_minify.cssless = False
     resp = client.get('/cssless_false')
@@ -112,7 +66,7 @@ def test_fail_safe(client):
 
 
 def test_fail_safe_false_input(client):
-    """testing fail safe disabled with false input """
+    '''testing fail safe disabled with false input '''
     try:
         client.get('/cssless_false')
     except Exception as e:
@@ -120,7 +74,7 @@ def test_fail_safe_false_input(client):
 
 
 def test_caching_limit_only_when_needed(client):
-    """test caching limit without any variations """
+    '''test caching limit without any variations '''
     store_minify.caching_limit = 5
     store_minify.cssless = True
     resp = [client.get('/cssless').data for i in range(10)]
@@ -131,7 +85,7 @@ def test_caching_limit_only_when_needed(client):
 
 
 def test_caching_limit_exceeding(client):
-    """test caching limit with multiple variations """
+    '''test caching limit with multiple variations '''
     resp = [client.get('/js/{}'.format(i)).data for i in range(10)]
 
     assert len(store_minify.cache.get('js_addition', {}))\
@@ -146,7 +100,7 @@ def test_caching_limit_exceeding(client):
 
 
 def test_bypass_caching(client):
-    """test endpoint bypassed not caching"""
+    '''test endpoint bypassed not caching'''
     store_minify.bypass_caching.append('cssless')
     resp = client.get('/cssless')
     resp_values = [
@@ -158,7 +112,7 @@ def test_bypass_caching(client):
 
 
 def test_bypassing_with_regex(client):
-    """test endpoint bypassed not minifying and not caching regex"""
+    '''test endpoint bypassed not minifying and not caching regex'''
     store_minify.bypass.append('css*')
     store_minify.bypass_caching.append('css*')
     store_minify.fail_safe = True
@@ -169,5 +123,37 @@ def test_bypassing_with_regex(client):
     assert resp_false == FALSE_LESS
 
 
+def test_passive_flag(client):
+    '''test disabling active minifying'''
+    store_minify.passive = True
+    resp = client.get('/html').data.decode('utf-8')
+
+    assert resp == HTML
+
+
+def test_html_minify_decorated(client):
+    '''test minifying html decorator'''
+    store_minify.passive = True
+    resp = client.get('/html_decorated').data
+
+    assert resp == MINIFED_HTML
+
+
+def test_javascript_minify_decorated(client):
+    '''test minifying javascript decorator'''
+    store_minify.passive = True
+    resp = client.get('/js_decorated').data
+
+    assert resp == MINIFIED_JS
+
+
+def test_minify_less_decorated(client):
+    '''test minifying css/less decorator'''
+    store_minify.passive = True
+    resp = client.get('/less_decorated').data
+
+    assert resp == MINIFED_LESS
+
+
 if __name__ == '__main__':
     app.run()
diff --git a/tests/setup.py b/tests/setup.py
new file mode 100644
index 0000000..de131b1
--- /dev/null
+++ b/tests/setup.py
@@ -0,0 +1,69 @@
+from sys import path as sys_path
+from os import path
+from importlib import import_module
+from flask import Flask
+
+from .constants import HTML, JS, LESS, FALSE_LESS
+
+
+sys_path.append(path.dirname(path.dirname(__file__)))
+module = import_module('flask_minify')
+minify = module.minify
+decorator = module.decorators.minify
+
+
+app = Flask(__name__)
+store_minify = minify(app=app, fail_safe=False)
+
+
+@app.route('/html')
+def html():
+    return HTML
+
+
+@app.route('/bypassed')
+def bypassed():
+    return HTML
+
+
+@app.route('/js')
+def js():
+    return JS
+
+
+@app.route('/cssless')
+def cssless():
+    return LESS
+
+
+@app.route('/js/<addition>')
+def js_addition(addition=None):
+    return '''<script>
+        ["J", "S"].reduce(
+            function (a, r) {
+                return a + r
+            })
+    ''' + (addition + '\n</script>')
+
+
+@app.route('/cssless_false')
+def cssless_false():
+    return FALSE_LESS
+
+
+@app.route('/html_decorated')
+@decorator(html=True)
+def html_decorated():
+    return HTML
+
+
+@app.route('/js_decorated')
+@decorator(html=True)
+def js_decorated():
+    return JS
+
+
+@app.route('/less_decorated')
+@decorator(html=True)
+def less_decorated():
+    return LESS
diff --git a/tests/units.py b/tests/units.py
index 60a2753..520dba3 100644
--- a/tests/units.py
+++ b/tests/units.py
@@ -2,7 +2,7 @@ from sys import path as sys_path
 from os import path
 from importlib import import_module
 
-from flask_minify.utils import (is_empty)
+from flask_minify.utils import (is_empty, is_html)
 
 
 sys_path.append(path.dirname(path.dirname(__file__)))
@@ -20,6 +20,11 @@ class TestUtils:
         assert is_empty('Not empty at all') is False
         assert is_empty('\n\t  \t\n\n' * 10) is True
 
+    def test_is_html(self):
+        ''' Test is_html check is correct '''
+        assert is_html(mock.Mock('application/json')) is False
+        assert is_html(mock.Mock(content_type='text/html')) is True
+
 
 class TestMinifyRequest:
     def setup(self):