Repository: PlaidWeb/Publ

Issue:
Publ could really use some actual automated tests
Unit tests where possible, integration tests in general.

Integration tests could be in the form of a test runner which is able to run through all of the test pages to capture their output and compare it with a reference output (with some added stuff for scenario testing).

Discussion:

Patch:
diff --git a/Makefile b/Makefile
index 5ba02f1..7c22cdf 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-all: setup format mypy test pylint flake8
+all: setup format mypy cov pylint flake8
 
 .PHONY: setup
 	pipenv run which coverage || pipenv install --dev
diff --git a/publ/entry.py b/publ/entry.py
index 900f452..cd54ae5 100644
--- a/publ/entry.py
+++ b/publ/entry.py
@@ -12,12 +12,13 @@ import uuid
 
 import arrow
 import flask
+import slugify
 from pony import orm
 from werkzeug.utils import cached_property
 
 from . import (caching, cards, config, html_entry, links, markdown, model,
                path_alias, queries, tokens, user, utils)
-from .utils import CallableProxy, CallableValue, TrueCallableProxy, make_slug
+from .utils import CallableProxy, CallableValue, TrueCallableProxy
 
 LOGGER = logging.getLogger(__name__)
 
@@ -47,7 +48,7 @@ class Entry(caching.Memoizable):
 
         self._fingerprint = model.FileFingerprint.get(file_path=record.file_path)
         LOGGER.debug('loaded entry %d, fingerprint=%s', record.id,
-            self._fingerprint.fingerprint if self._fingerprint else None)
+                     self._fingerprint.fingerprint if self._fingerprint else None)
 
         # maps (section,footnotes_enabled) -> toc/footnote counter
         self._counters: typing.Dict[typing.Tuple[str, bool], markdown.ItemCounter] = {}
@@ -729,7 +730,7 @@ def scan_file(fullpath: str, relpath: typing.Optional[str], assign_id: bool) ->
         'category': entry.get('Category', utils.get_category(relpath)),
         'status': model.PublishStatus[entry.get('Status', 'SCHEDULED').upper()].value,
         'entry_type': entry.get('Entry-Type', ''),
-        'slug_text': make_slug(entry.get('Slug-Text', title)),
+        'slug_text': slugify.slugify(entry.get('Slug-Text', title)),
         'redirect_url': entry.get('Redirect-To', ''),
         'title': title,
         'sort_title': entry.get('Sort-Title', title),
diff --git a/publ/image/local.py b/publ/image/local.py
index 6d2ff74..0ec1f6c 100644
--- a/publ/image/local.py
+++ b/publ/image/local.py
@@ -10,6 +10,7 @@ import typing
 
 import flask
 import PIL.Image
+import slugify
 from atomicwrites import atomic_write
 from werkzeug.utils import cached_property
 
@@ -95,7 +96,7 @@ class LocalImage(Image):
         """ implements get_rendition and returns tuple of out_rel_path,size,pending """
         basename, ext = os.path.splitext(
             os.path.basename(self._record.file_path))
-        basename = utils.make_slug(basename)
+        basename = slugify.slugify(basename)
 
         if kwargs.get('format'):
             ext = '.' + kwargs['format']
diff --git a/publ/utils.py b/publ/utils.py
index 16d46de..2b2c802 100644
--- a/publ/utils.py
+++ b/publ/utils.py
@@ -12,15 +12,12 @@ import urllib.parse
 
 import arrow
 import flask
-import slugify
 import werkzeug.routing
 
 from . import config
 
 LOGGER = logging.getLogger(__name__)
 
-LOG_TRIVIAL = logging.DEBUG - 1
-
 T = typing.TypeVar('T')  # pylint:disable=invalid-name
 ArgDict = typing.Dict[str, typing.Any]
 ListLike = typing.Union[typing.List[T],
@@ -42,10 +39,8 @@ class CallableProxy:
         self._func: typing.Callable[..., T] = func
 
     @functools.lru_cache()
-    def _cached_default(self, *cache_params):
+    def _cached_default(self, *_):
         """ caching wrapper to memoize call against everything that might affect it """
-        LOGGER.log(LOG_TRIVIAL, '%s %s _cached_default %s', self.__class__.__name__, self._func,
-                   cache_params)
         return self._func()
 
     def _default(self):
@@ -54,36 +49,38 @@ class CallableProxy:
 
     def __call__(self, *args, **kwargs) -> T:
         # use the new kwargs to override the defaults
-        LOGGER.log(LOG_TRIVIAL, '%s %s __call__ %s %s', self.__class__.__name__, self._func,
-                   args, kwargs)
         return self._func(*args, **kwargs)
 
     def __getattr__(self, name):
-        LOGGER.log(LOG_TRIVIAL, '%s %s __getattr__ %s', self.__class__.__name__, self._func,
-                   name)
         return getattr(self._default(), name)
 
     def __bool__(self) -> bool:
-        LOGGER.log(LOG_TRIVIAL, '%s %s __bool__', self.__class__.__name__, self._func)
         return bool(self._default())
 
     def __len__(self) -> int:
-        LOGGER.log(LOG_TRIVIAL, '%s %s __len__', self.__class__.__name__, self._func)
         return len(self._default())
 
     def __str__(self) -> str:
-        LOGGER.log(LOG_TRIVIAL, '%s %s __str__', self.__class__.__name__, self._func)
         return str(self._default())
 
     def __iter__(self):
-        LOGGER.log(LOG_TRIVIAL, '%s %s __iter__', self.__class__.__name__, self._func)
         return self._default().__iter__()
 
     def __getitem__(self, key):
-        LOGGER.log(LOG_TRIVIAL, '%s %s __getitem__ %s', self.__class__.__name__, self._func,
-                   key)
         return self._default().__getitem__(key)
 
+    def __hash__(self):
+        return hash((CallableProxy, self._func))
+
+    def __eq__(self, other):
+        return self._default() == other
+
+    def __contains__(self, item):
+        return item in self._default()
+
+    def __add__(self, other):
+        return self._default() + other
+
 
 class TrueCallableProxy(CallableProxy):
     """ A version of CallableProxy that is always truthy """
@@ -128,7 +125,7 @@ def parse_date(datestr: str) -> typing.Tuple[arrow.Arrow, str, str]:
     if not match:
         return (arrow.get(datestr,
                           tzinfo=config.timezone).replace(tzinfo=config.timezone),
-                'day', 'YYYY-MM-DD')
+                'day', DAY_FORMAT)
 
     year, month, day, week = match.group(1, 3, 5, 6)
     start = arrow.Arrow(year=int(year), month=int(
@@ -136,14 +133,14 @@ def parse_date(datestr: str) -> typing.Tuple[arrow.Arrow, str, str]:
 
     if week:
         return start.span('week')[0], 'week', WEEK_FORMAT
+
     if day:
         return start, 'day', DAY_FORMAT
+
     if month:
         return start, 'month', MONTH_FORMAT
-    if year:
-        return start, 'year', YEAR_FORMAT
 
-    raise ValueError("Could not parse date: {}".format(datestr))
+    return start, 'year', YEAR_FORMAT
 
 
 def find_file(path: str, search_path: typing.Union[str, ListLike[str]]) -> typing.Optional[str]:
@@ -163,11 +160,6 @@ def find_file(path: str, search_path: typing.Union[str, ListLike[str]]) -> typin
     return None
 
 
-def make_slug(title: str) -> str:
-    """ convert a title into a URL-friendly slug """
-    return slugify.slugify(title)
-
-
 def static_url(path: str, absolute: bool = False) -> str:
     """ Shorthand for returning a URL for the requested static file.
 
@@ -239,7 +231,7 @@ def file_fingerprint(fullpath: str) -> str:
 
 
 def remap_args(input_args: typing.Dict[str, typing.Any],
-               remap: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
+               remap: typing.Dict[str, str]) -> typing.Dict[str, typing.Any]:
     """ Generate a new argument list by remapping keys. The 'remap'
     dict maps from destination key -> priority list of source keys
     """
@@ -336,7 +328,7 @@ def prefix_normalize(kwargs: ArgDict) -> ArgDict:
 
 def is_list(item: typing.Any) -> bool:
     """ Return if this is a list-type thing """
-    return isinstance(item, (list, tuple, set, TagSet))
+    return getattr(item, '__iter__', None) and not isinstance(item, str)
 
 
 def as_list(item: typing.Any) -> ListLike:
@@ -429,11 +421,14 @@ def stash(key: str) -> typing.Callable:
     return decorator
 
 
-def parse_tuple_string(argument, type_func=int) -> typing.Tuple:
+def parse_tuple_string(argument: typing.Union[str, typing.Tuple, typing.List],
+                       type_func=int) -> typing.Optional[typing.Tuple]:
     """ Return a tuple from parsing 'a,b,c,d' -> (a,b,c,d) """
+    if argument is None:
+        return None
     if isinstance(argument, str):
         return tuple(type_func(p.strip()) for p in argument.split(','))
-    return argument
+    return tuple(argument)
 
 
 class TagSet(typing.Set[str]):


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 0a79255..25678a3 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,10 +1,113 @@
 """ tests of publ.utils class """
+# pylint:disable=missing-function-docstring
 
-from publ.utils import TagSet
+import flask
+import pytest
+
+from publ import utils
+
+
+def test_callable_proxy():
+    """ Tests of the CallableProxy class """
+
+    def wrapped_func(retval="default"):
+        return retval
+
+    proxy = utils.CallableProxy(wrapped_func)
+
+    assert proxy() == "default"
+    assert proxy(1) == 1
+    assert proxy(True) is True
+    assert proxy(False) is False
+
+    app = flask.Flask(__name__)
+    with app.test_request_context("/"):
+        assert proxy.upper() == "DEFAULT"
+        assert bool(proxy)
+        assert len(proxy) == 7
+        assert str(proxy) == "default"
+        assert list(proxy) == ['d', 'e', 'f', 'a', 'u', 'l', 't']
+        assert proxy[3] == 'a'
+
+        assert proxy == "default"
+        assert 't' in proxy
+        assert proxy + "foo" == "defaultfoo"
+
+
+def test_callable_proxy_caching():
+    """ Test the caching-ness of CallableProxy's default """
+
+    app = flask.Flask(__name__)
+    app.secret_key = 'bogus'
+
+    stash = {'call_count': 0}
+
+    def wrapped_func():
+        stash['call_count'] += 1
+        return flask.request.path
+
+    proxy = utils.CallableProxy(wrapped_func)
+
+    with app.test_request_context("/foo/bar"):
+        # within same path the call should only happen once
+        assert str(proxy) == "/foo/bar"
+        assert stash['call_count'] == 1
+        assert str(proxy) == "/foo/bar"
+        assert stash['call_count'] == 1
+
+    with app.test_request_context("/foo/bar"):
+        # cached value should remain cached
+        assert str(proxy) == "/foo/bar"
+        assert stash['call_count'] == 1
+
+    with app.test_request_context("/foo/baz"):
+        # different path should call it again
+        assert str(proxy) == "/foo/baz"
+        assert stash['call_count'] == 2
+
+    with app.test_request_context("/foo/baz"):
+        # different user should call it again
+        flask.session['me'] = 'test:cachebuster'
+        assert str(proxy) == "/foo/baz"
+        assert stash['call_count'] == 3
+
+
+def test_truecallable_proxy():
+    """ Tests of TrueCallableProxy """
+
+    stash = {'call_count': 0}
+
+    def wrapped_func():
+        stash['call_count'] += 1
+        return False
+
+    proxy = utils.TrueCallableProxy(wrapped_func)
+
+    app = flask.Flask(__name__)
+    with app.test_request_context('/foo'):
+        assert stash['call_count'] == 0
+        assert proxy
+        assert stash['call_count'] == 0
+
+
+def test_callablevalue():
+    """ Tests of CallableValue """
+    proxy = utils.CallableValue("flonk")
+    falsy = utils.CallableValue(False)
+
+    app = flask.Flask(__name__)
+    with app.test_request_context("/bar"):
+        assert proxy == "flonk"
+        assert len(proxy) == 5
+        assert proxy + "foo" == "flonkfoo"
+        assert proxy
+        assert not falsy
 
 
 def test_tagset_membership():
     """ Membership tests for TagSet """
+    from publ.utils import TagSet
+
     items = ('a', 'S', 'd', 'F')
     others = ('Q', 'w', 'E', 'r')
     tags = TagSet(items)
@@ -38,6 +141,8 @@ def test_tagset_membership():
 
 def test_tagset_operators():
     """ Test operators  on TagSet """
+    from publ.utils import TagSet
+
     assert TagSet(('a', 's', 'D', 'F')) == TagSet(('A', 'S', 'd', 'f'))
     assert TagSet(('a', 's', 'D', 'F')) == {'A', 'S', 'd', 'f'}
     assert TagSet(('a', 's', 'd', 'f')) != TagSet(('a', 's', 'd'))
@@ -65,3 +170,147 @@ def test_tagset_operators():
     assert not TagSet(('a', 's', 'd')) < TagSet(('a', 's', 'd'))
     assert not TagSet(('a', 's', 'd')) < TagSet(('q', 'w', 'e'))
     assert not TagSet(('a', 's', 'd')) <= TagSet(('q', 'w', 'e'))
+
+
+def test_parse_tuple_string():
+    """ test of parse_tuple_string() """
+    assert utils.parse_tuple_string((1, 2, 3)) == (1, 2, 3)
+    assert utils.parse_tuple_string([1, 2, 3]) == (1, 2, 3)
+    assert utils.parse_tuple_string("1,2,3") == (1, 2, 3)
+    assert utils.parse_tuple_string("a,,", bool) == (True, False, False)
+    with pytest.raises(Exception):
+        utils.parse_tuple_string("a,b,c", float)
+    assert utils.parse_tuple_string(None) is None
+
+
+def test_make_tag():
+    """ test of utils.make_tag """
+
+    assert utils.make_tag('a', {'href': 'foo'}, False) == '<a href="foo">'
+    assert utils.make_tag('a',
+                          [('href', 'foo'), ('href', 'bar')],
+                          True) == '<a href="foo" href="bar" />'
+    with pytest.raises(Exception):
+        utils.make_tag('a', (('href', 'foo')))
+
+    app = flask.Flask(__name__)
+    with app.test_request_context():
+        proxy = utils.CallableValue("<hello>")
+        assert utils.make_tag('a', {'href': proxy}) == '<a href="&lt;hello&gt;">'
+
+    escaped = flask.Markup("&amp;")
+    assert utils.make_tag('a', {'href': escaped}) == '<a href="&amp;">'
+
+
+def test_listlike():
+    """ test functions involving the ListLike inference class """
+    assert utils.is_list((1, 2, 3))
+    assert utils.is_list([])
+    assert utils.is_list([1, 2, 3])
+    assert utils.is_list({1, 2, 3})
+    assert utils.is_list(frozenset((1, 2, 3)))
+    assert utils.is_list(utils.TagSet(('1', '2', 'A', 'a')))
+    assert not utils.is_list("")
+    assert not utils.is_list("foo")
+    assert not utils.is_list(123)
+    assert not utils.is_list(True)
+    assert not utils.is_list(None)
+
+    assert utils.as_list([1, 2, 3]) == [1, 2, 3]
+    assert utils.as_list((1, 2, 3)) == (1, 2, 3)
+    assert utils.as_list("foo") == ("foo",)
+    assert utils.as_list(True) == (True,)
+    assert utils.as_list(None) == ()
+
+
+def test_parse_date():
+    """ tests for the date parser """
+    import arrow
+    from publ import config
+
+    def make_date(year=None, month=None, day=None):
+        return arrow.Arrow(year=year or 1,
+                           month=month or 1,
+                           day=day or 1,
+                           tzinfo=config.timezone)
+
+    with pytest.raises(ValueError):
+        utils.parse_date("not a date")
+    with pytest.raises(ValueError):
+        utils.parse_date("12345678")
+    with pytest.raises(ValueError):
+        utils.parse_date("")
+
+    assert utils.parse_date('1978-06-14') == (make_date(1978, 6, 14), 'day', utils.DAY_FORMAT)
+    assert utils.parse_date('19780614') == (make_date(1978, 6, 14), 'day', utils.DAY_FORMAT)
+
+    assert utils.parse_date('1983-07') == (make_date(1983, 7), 'month', utils.MONTH_FORMAT)
+    assert utils.parse_date('198307') == (make_date(1983, 7), 'month', utils.MONTH_FORMAT)
+
+    assert utils.parse_date("1979") == (make_date(1979), 'year', utils.YEAR_FORMAT)
+
+    assert utils.parse_date('19810505_w') == (make_date(1981, 5, 4), 'week', utils.WEEK_FORMAT)
+
+
+def test_find_file():
+    """ tests for the file finder """
+    assert utils.find_file("anything", []) is None
+
+    assert utils.find_file("anything", ["tests/templates"]) is None
+    assert utils.find_file("auth", ["tests/templates"]) is None
+
+    assert utils.find_file("index.html",
+                           ("tests/templates")
+                           ) == "tests/templates/index.html"
+    assert utils.find_file("index.html",
+                           ("tests/templates", "tests/templates/auth")
+                           ) == "tests/templates/index.html"
+    assert utils.find_file("index.html",
+                           ("tests/templates/auth", "tests/templates")
+                           ) == "tests/templates/auth/index.html"
+
+
+def test_static_url():
+    """ tests for the static URL builder """
+    app = flask.Flask("tests", static_folder="asdf")
+    with app.test_request_context("https://foo.bar/poiupoiupoiu"):
+        assert utils.static_url("thing", absolute=False) == "/asdf/thing"
+        assert utils.static_url("thong", absolute=True) == "https://foo.bar/asdf/thong"
+
+
+def test_remap_link_target():
+    """ test the target remapper """
+    app = flask.Flask("tests", static_folder="feedme")
+    with app.test_request_context("https://feed.me/blah/merry"):
+        assert utils.remap_link_target("@fred") == "/feedme/fred"
+        assert utils.remap_link_target("@fred", absolute=True) == "https://feed.me/feedme/fred"
+        assert utils.remap_link_target("daphne") == "daphne"
+        assert utils.remap_link_target("daphne", absolute=True) == "https://feed.me/blah/daphne"
+
+
+def test_get_category():
+    import os.path
+    assert utils.get_category(os.path.join("foo", "bar", "entry.html")) == "foo/bar"
+
+
+def test_remap_args():
+    """ test the argument parser remapper """
+    args = {'foo': 'bar', 'quuz': 'quux'}
+    assert utils.remap_args(args, {}) == args
+    assert utils.remap_args(args, {"foo": "poiu"}) == args
+    assert utils.remap_args(args, {"foo": "quuz"})['foo'] == 'quux'
+
+
+def test_prefix_normalize():
+    """ test the prefix remapping thing """
+    args = {'here': '1',
+            'prefix_here':
+            '2', 'prefix_there': '3',
+            'anywhere': '4',
+            'prefix': 'prefix_'}
+    assert utils.prefix_normalize(args) == {
+        'here': '2',
+        'there': '3',
+        'anywhere': '4',
+        'prefix': 'prefix_'
+    }