diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7126fd7..9b5b295 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to WuttJamaican will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.13.0 (2024-08-26)
+
+### Feat
+
+- add basic email handler support
+- add `util.resource_path()` function
+- add app handler method, `get_appdir()`
+- add basic support for progress indicators
+- add table/model for app upgrades
+
## v0.12.1 (2024-08-22)
### Fix
diff --git a/docs/api/wuttjamaican/db.model.upgrades.rst b/docs/api/wuttjamaican/db.model.upgrades.rst
new file mode 100644
index 0000000..f89fcf2
--- /dev/null
+++ b/docs/api/wuttjamaican/db.model.upgrades.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.db.model.upgrades``
+==================================
+
+.. automodule:: wuttjamaican.db.model.upgrades
+ :members:
diff --git a/docs/api/wuttjamaican/email.handler.rst b/docs/api/wuttjamaican/email.handler.rst
new file mode 100644
index 0000000..4e4900f
--- /dev/null
+++ b/docs/api/wuttjamaican/email.handler.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.email.handler``
+==============================
+
+.. automodule:: wuttjamaican.email.handler
+ :members:
diff --git a/docs/api/wuttjamaican/email.message.rst b/docs/api/wuttjamaican/email.message.rst
new file mode 100644
index 0000000..1656196
--- /dev/null
+++ b/docs/api/wuttjamaican/email.message.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.email.message``
+==============================
+
+.. automodule:: wuttjamaican.email.message
+ :members:
diff --git a/docs/api/wuttjamaican/email.rst b/docs/api/wuttjamaican/email.rst
new file mode 100644
index 0000000..d187d98
--- /dev/null
+++ b/docs/api/wuttjamaican/email.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.email``
+======================
+
+.. automodule:: wuttjamaican.email
+ :members:
diff --git a/docs/api/wuttjamaican/enum.rst b/docs/api/wuttjamaican/enum.rst
new file mode 100644
index 0000000..12b0081
--- /dev/null
+++ b/docs/api/wuttjamaican/enum.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.enum``
+=====================
+
+.. automodule:: wuttjamaican.enum
+ :members:
diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst
index 43b1642..69a754e 100644
--- a/docs/api/wuttjamaican/index.rst
+++ b/docs/api/wuttjamaican/index.rst
@@ -15,8 +15,14 @@
db.model
db.model.auth
db.model.base
+ db.model.upgrades
db.sess
+ email
+ email.handler
+ email.message
+ enum
exc
people
+ progress
testing
util
diff --git a/docs/api/wuttjamaican/progress.rst b/docs/api/wuttjamaican/progress.rst
new file mode 100644
index 0000000..7a14cb3
--- /dev/null
+++ b/docs/api/wuttjamaican/progress.rst
@@ -0,0 +1,6 @@
+
+``wuttjamaican.progress``
+=========================
+
+.. automodule:: wuttjamaican.progress
+ :members:
diff --git a/docs/conf.py b/docs/conf.py
index baf9505..23fc2cf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -22,6 +22,7 @@ extensions = [
'sphinxcontrib.programoutput',
'sphinx.ext.viewcode',
'sphinx.ext.todo',
+ 'enum_tools.autoenum',
]
templates_path = ['_templates']
diff --git a/docs/glossary.rst b/docs/glossary.rst
index c9b2f94..3b87762 100644
--- a/docs/glossary.rst
+++ b/docs/glossary.rst
@@ -25,6 +25,14 @@ Glossary
Usually this is named ``app`` and is located at the root of the
virtual environment.
+ Can be retrieved via
+ :meth:`~wuttjamaican.app.AppHandler.get_appdir()`.
+
+ app enum
+ Python module whose namespace contains all the "enum" values
+ used by the :term:`app`. Available on the :term:`app handler`
+ as :attr:`~wuttjamaican.app.AppHandler.enum`.
+
app handler
Python object representing the core :term:`handler` for the
:term:`app`. There is normally just one "global" app handler;
@@ -116,6 +124,12 @@ Glossary
In practice this generally refers to a
:class:`~wuttjamaican.db.sess.Session` instance.
+ email handler
+ The :term:`handler` responsible for sending email on behalf of
+ the :term:`app`.
+
+ Default is :class:`~wuttjamaican.email.handler.EmailHandler`.
+
entry point
This refers to a "setuptools-style" entry point specifically,
which is a mechanism used to register "plugins" and the like.
diff --git a/pyproject.toml b/pyproject.toml
index dab36b5..af34925 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttJamaican"
-version = "0.12.1"
+version = "0.13.0"
description = "Base package for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -27,13 +27,16 @@ classifiers = [
requires-python = ">= 3.8"
dependencies = [
'importlib-metadata; python_version < "3.10"',
+ "importlib_resources ; python_version < '3.9'",
+ "progress",
"python-configuration",
]
[project.optional-dependencies]
-db = ["SQLAlchemy<2", "alembic", "passlib"]
-docs = ["Sphinx", "sphinxcontrib-programoutput", "furo"]
+db = ["SQLAlchemy<2", "alembic", "alembic-postgresql-enum", "passlib"]
+email = ["Mako"]
+docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"]
tests = ["pytest-cov", "tox"]
diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py
index 0d1eb77..5d67df2 100644
--- a/src/wuttjamaican/app.py
+++ b/src/wuttjamaican/app.py
@@ -26,9 +26,12 @@ WuttJamaican - app handler
import importlib
import os
+import sys
import warnings
-from wuttjamaican.util import load_entry_points, load_object, make_title, make_uuid, parse_bool
+from wuttjamaican.util import (load_entry_points, load_object,
+ make_title, make_uuid, parse_bool,
+ progress_loop)
class AppHandler:
@@ -59,6 +62,16 @@ class AppHandler:
need to call :meth:`get_model()` yourself - that part will
happen automatically.
+ .. attribute:: enum
+
+ Reference to the :term:`app enum` module.
+
+ Note that :meth:`get_enum()` is responsible for determining
+ which module this will point to. However you can always get
+ the model using this attribute (e.g. ``app.enum``) and do not
+ need to call :meth:`get_enum()` yourself - that part will
+ happen automatically.
+
.. attribute:: providers
Dictionary of :class:`AppProvider` instances, as returned by
@@ -66,7 +79,9 @@ class AppHandler:
"""
default_app_title = "WuttJamaican"
default_model_spec = 'wuttjamaican.db.model'
+ default_enum_spec = 'wuttjamaican.enum'
default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler'
+ default_email_handler_spec = 'wuttjamaican.email:EmailHandler'
default_people_handler_spec = 'wuttjamaican.people:PeopleHandler'
def __init__(self, config):
@@ -103,6 +118,9 @@ class AppHandler:
if name == 'model':
return self.get_model()
+ if name == 'enum':
+ return self.get_enum()
+
if name == 'providers':
self.providers = self.get_all_providers()
return self.providers
@@ -298,6 +316,30 @@ class AppHandler:
self.model = importlib.import_module(spec)
return self.model
+ def get_enum(self):
+ """
+ Returns the :term:`app enum` module.
+
+ Note that you don't actually need to call this method; you can
+ get the module by simply accessing :attr:`enum`
+ (e.g. ``app.enum``) instead.
+
+ By default this will return :mod:`wuttjamaican.enum` unless
+ the config class or some :term:`config extension` has provided
+ another default.
+
+ A custom app can override the default like so (within a config
+ extension)::
+
+ config.setdefault('wutta.enum_spec', 'poser.enum')
+ """
+ if 'enum' not in self.__dict__:
+ spec = self.config.get(f'{self.appname}.enum_spec',
+ usedb=False,
+ default=self.default_enum_spec)
+ self.enum = importlib.import_module(spec)
+ return self.enum
+
def load_object(self, spec):
"""
Import and/or load and return the object designated by the
@@ -313,6 +355,54 @@ class AppHandler:
"""
return load_object(spec)
+ def get_appdir(self, *args, **kwargs):
+ """
+ Returns path to the :term:`app dir`.
+
+ This does not check for existence of the path, it only reads
+ it from config or (optionally) provides a default path.
+
+ :param configured_only: Pass ``True`` here if you only want
+ the configured path and ignore the default path.
+
+ :param create: Pass ``True`` here if you want to ensure the
+ returned path exists, creating it if necessary.
+
+ :param \*args: Any additional args will be added as child
+ paths for the final value.
+
+ For instance, assuming ``/srv/envs/poser`` is the virtual
+ environment root::
+
+ app.get_appdir() # => /srv/envs/poser/app
+
+ app.get_appdir('data') # => /srv/envs/poser/app/data
+ """
+ configured_only = kwargs.pop('configured_only', False)
+ create = kwargs.pop('create', False)
+
+ # maybe specify default path
+ if not configured_only:
+ path = os.path.join(sys.prefix, 'app')
+ kwargs.setdefault('default', path)
+
+ # get configured path
+ kwargs.setdefault('usedb', False)
+ path = self.config.get(f'{self.appname}.appdir', **kwargs)
+
+ # add any subpath info
+ if path and args:
+ path = os.path.join(path, *args)
+
+ # create path if requested/needed
+ if create:
+ if not path:
+ raise ValueError("appdir path unknown! so cannot create it.")
+ if not os.path.exists(path):
+ os.makedirs(path)
+
+ return path
+
def make_appdir(self, path, subfolders=None, **kwargs):
"""
Establish an :term:`app dir` at the given path.
@@ -379,6 +469,18 @@ class AppHandler:
"""
return make_uuid()
+ def progress_loop(self, *args, **kwargs):
+ """
+ Convenience method to iterate over a set of items, invoking
+ logic for each, and updating a progress indicator along the
+ way.
+
+ This is a wrapper around
+ :func:`wuttjamaican.util.progress_loop()`; see those docs for
+ param details.
+ """
+ return progress_loop(*args, **kwargs)
+
def get_session(self, obj):
"""
Returns the SQLAlchemy session with which the given object is
@@ -505,6 +607,21 @@ class AppHandler:
self.handlers['auth'] = factory(self.config, **kwargs)
return self.handlers['auth']
+ def get_email_handler(self, **kwargs):
+ """
+ Get the configured :term:`email handler`.
+
+ See also :meth:`send_email()`.
+
+ :rtype: :class:`~wuttjamaican.email.handler.EmailHandler`
+ """
+ if 'email' not in self.handlers:
+ spec = self.config.get(f'{self.appname}.email.handler',
+ default=self.default_email_handler_spec)
+ factory = self.load_object(spec)
+ self.handlers['email'] = factory(self.config, **kwargs)
+ return self.handlers['email']
+
def get_people_handler(self, **kwargs):
"""
Get the configured "people" :term:`handler`.
@@ -533,6 +650,15 @@ class AppHandler:
"""
return self.get_people_handler().get_person(obj, **kwargs)
+ def send_email(self, *args, **kwargs):
+ """
+ Send an email message.
+
+ This is a convenience wrapper around
+ :meth:`~wuttjamaican.email.handler.EmailHandler.send_email()`.
+ """
+ self.get_email_handler().send_email(*args, **kwargs)
+
class AppProvider:
"""
diff --git a/src/wuttjamaican/conf.py b/src/wuttjamaican/conf.py
index 6c3adf5..c04b603 100644
--- a/src/wuttjamaican/conf.py
+++ b/src/wuttjamaican/conf.py
@@ -440,8 +440,8 @@ class WuttaConfig:
# raise error if required value not found
if require:
- message = message or "missing or invalid config"
- raise ConfigurationError(f"{message}; please set config value for: {key}")
+ message = message or "missing config"
+ raise ConfigurationError(f"{message}; set value for: {key}")
# give the default value if specified
if default is not UNSPECIFIED:
diff --git a/src/wuttjamaican/db/alembic/env.py b/src/wuttjamaican/db/alembic/env.py
index 2bc674c..4a20bd5 100644
--- a/src/wuttjamaican/db/alembic/env.py
+++ b/src/wuttjamaican/db/alembic/env.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8; -*-
+import alembic_postgresql_enum
from alembic import context
from wuttjamaican.conf import make_config
diff --git a/src/wuttjamaican/db/alembic/versions/ebd75b9feaa7_add_upgrades.py b/src/wuttjamaican/db/alembic/versions/ebd75b9feaa7_add_upgrades.py
new file mode 100644
index 0000000..1ccbd66
--- /dev/null
+++ b/src/wuttjamaican/db/alembic/versions/ebd75b9feaa7_add_upgrades.py
@@ -0,0 +1,46 @@
+"""add upgrades
+
+Revision ID: ebd75b9feaa7
+Revises: 3abcc44f7f91
+Create Date: 2024-08-24 09:42:21.199679
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = 'ebd75b9feaa7'
+down_revision: Union[str, None] = '3abcc44f7f91'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+
+ # upgrade
+ sa.Enum('PENDING', 'EXECUTING', 'SUCCESS', 'FAILURE', name='upgradestatus').create(op.get_bind())
+ op.create_table('upgrade',
+ sa.Column('uuid', sa.String(length=32), nullable=False),
+ sa.Column('created', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('created_by_uuid', sa.String(length=32), nullable=False),
+ sa.Column('description', sa.String(length=255), nullable=False),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('executing', sa.Boolean(), nullable=False),
+ sa.Column('status', postgresql.ENUM('PENDING', 'EXECUTING', 'SUCCESS', 'FAILURE', name='upgradestatus', create_type=False), nullable=False),
+ sa.Column('executed', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('executed_by_uuid', sa.String(length=32), nullable=True),
+ sa.Column('exit_code', sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_upgrade_created_by_uuid_user')),
+ sa.ForeignKeyConstraint(['executed_by_uuid'], ['user.uuid'], name=op.f('fk_upgrade_executed_by_uuid_user')),
+ sa.PrimaryKeyConstraint('uuid', name=op.f('pk_upgrade'))
+ )
+
+
+def downgrade() -> None:
+
+ # upgrade
+ op.drop_table('upgrade')
+ sa.Enum('PENDING', 'EXECUTING', 'SUCCESS', 'FAILURE', name='upgradestatus').drop(op.get_bind())
diff --git a/src/wuttjamaican/db/model/__init__.py b/src/wuttjamaican/db/model/__init__.py
index 760e3a6..267738c 100644
--- a/src/wuttjamaican/db/model/__init__.py
+++ b/src/wuttjamaican/db/model/__init__.py
@@ -36,7 +36,9 @@ The ``wuttjamaican.db.model`` namespace contains the following:
* :class:`~wuttjamaican.db.model.auth.Permission`
* :class:`~wuttjamaican.db.model.auth.User`
* :class:`~wuttjamaican.db.model.auth.UserRole`
+* :class:`~wuttjamaican.db.model.upgrades.Upgrade`
"""
from .base import uuid_column, uuid_fk_column, Base, Setting, Person
from .auth import Role, Permission, User, UserRole
+from .upgrades import Upgrade
diff --git a/src/wuttjamaican/db/model/upgrades.py b/src/wuttjamaican/db/model/upgrades.py
new file mode 100644
index 0000000..c8f3666
--- /dev/null
+++ b/src/wuttjamaican/db/model/upgrades.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# WuttJamaican -- Base package for Wutta Framework
+# Copyright © 2023-2024 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see
hello from foo html template
diff --git a/tests/email/templates/test_foo.txt.mako b/tests/email/templates/test_foo.txt.mako new file mode 100644 index 0000000..dcbc4c6 --- /dev/null +++ b/tests/email/templates/test_foo.txt.mako @@ -0,0 +1 @@ +hello from foo txt template diff --git a/tests/email/test_handler.py b/tests/email/test_handler.py new file mode 100644 index 0000000..63c4874 --- /dev/null +++ b/tests/email/test_handler.py @@ -0,0 +1,403 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from wuttjamaican.email import handler as mod +from wuttjamaican.email import Message +from wuttjamaican.conf import WuttaConfig +from wuttjamaican.util import resource_path +from wuttjamaican.exc import ConfigurationError + + +class TestEmailHandler(TestCase): + + def setUp(self): + self.config = WuttaConfig() + self.app = self.config.get_app() + + def make_handler(self, **kwargs): + return mod.EmailHandler(self.config, **kwargs) + + def test_constructor_lookups(self): + + # empty lookup paths by default, if no providers + with patch.object(self.app, 'providers', new={}): + handler = self.make_handler() + self.assertEqual(handler.txt_templates.directories, []) + self.assertEqual(handler.html_templates.directories, []) + + # provider may specify paths as list + providers = { + 'wuttatest': MagicMock(email_templates=['wuttjamaican.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + path = resource_path('wuttjamaican.email:templates') + self.assertEqual(handler.txt_templates.directories, [path]) + self.assertEqual(handler.html_templates.directories, [path]) + + # provider may specify paths as string + providers = { + 'wuttatest': MagicMock(email_templates='wuttjamaican.email:templates'), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + path = resource_path('wuttjamaican.email:templates') + self.assertEqual(handler.txt_templates.directories, [path]) + self.assertEqual(handler.html_templates.directories, [path]) + + def test_make_message(self): + handler = self.make_handler() + msg = handler.make_message() + self.assertIsInstance(msg, Message) + + def test_make_auto_message(self): + handler = self.make_handler() + + # error if default sender not defined + self.assertRaises(ConfigurationError, handler.make_auto_message, 'foo') + + # so let's define that + self.config.setdefault('wutta.email.default.sender', 'bob@example.com') + + # message is empty by default + msg = handler.make_auto_message('foo') + self.assertIsInstance(msg, Message) + self.assertEqual(msg.key, 'foo') + self.assertEqual(msg.sender, 'bob@example.com') + self.assertEqual(msg.subject, "Automated message") + self.assertEqual(msg.to, []) + self.assertEqual(msg.cc, []) + self.assertEqual(msg.bcc, []) + self.assertIsNone(msg.replyto) + self.assertIsNone(msg.txt_body) + self.assertIsNone(msg.html_body) + + # but if there is a proper email profile configured for key, + # then we should get back a more complete message + self.config.setdefault('wutta.email.test_foo.subject', "hello foo") + self.config.setdefault('wutta.email.test_foo.to', 'sally@example.com') + self.config.setdefault('wutta.email.templates', 'tests.email:templates') + handler = self.make_handler() + msg = handler.make_auto_message('test_foo') + self.assertEqual(msg.key, 'test_foo') + self.assertEqual(msg.sender, 'bob@example.com') + self.assertEqual(msg.subject, "hello foo") + self.assertEqual(msg.to, ['sally@example.com']) + self.assertEqual(msg.cc, []) + self.assertEqual(msg.bcc, []) + self.assertIsNone(msg.replyto) + self.assertEqual(msg.txt_body, "hello from foo txt template\n") + self.assertEqual(msg.html_body, "hello from foo html template
\n") + + # *some* auto methods get skipped if caller specifies the + # kwarg at all; others get skipped if kwarg is empty + + # sender + with patch.object(handler, 'get_auto_sender') as get_auto_sender: + msg = handler.make_auto_message('foo', sender=None) + get_auto_sender.assert_not_called() + msg = handler.make_auto_message('foo') + get_auto_sender.assert_called_once_with('foo') + + # subject + with patch.object(handler, 'get_auto_subject') as get_auto_subject: + msg = handler.make_auto_message('foo', subject=None) + get_auto_subject.assert_not_called() + msg = handler.make_auto_message('foo') + get_auto_subject.assert_called_once_with('foo', {}) + + # to + with patch.object(handler, 'get_auto_to') as get_auto_to: + msg = handler.make_auto_message('foo', to=None) + get_auto_to.assert_not_called() + get_auto_to.return_value = None + msg = handler.make_auto_message('foo') + get_auto_to.assert_called_once_with('foo') + + # cc + with patch.object(handler, 'get_auto_cc') as get_auto_cc: + msg = handler.make_auto_message('foo', cc=None) + get_auto_cc.assert_not_called() + get_auto_cc.return_value = None + msg = handler.make_auto_message('foo') + get_auto_cc.assert_called_once_with('foo') + + # bcc + with patch.object(handler, 'get_auto_bcc') as get_auto_bcc: + msg = handler.make_auto_message('foo', bcc=None) + get_auto_bcc.assert_not_called() + get_auto_bcc.return_value = None + msg = handler.make_auto_message('foo') + get_auto_bcc.assert_called_once_with('foo') + + # txt_body + with patch.object(handler, 'get_auto_txt_body') as get_auto_txt_body: + msg = handler.make_auto_message('foo', txt_body=None) + get_auto_txt_body.assert_not_called() + msg = handler.make_auto_message('foo') + get_auto_txt_body.assert_called_once_with('foo', {}) + + # html_body + with patch.object(handler, 'get_auto_html_body') as get_auto_html_body: + msg = handler.make_auto_message('foo', html_body=None) + get_auto_html_body.assert_not_called() + msg = handler.make_auto_message('foo') + get_auto_html_body.assert_called_once_with('foo', {}) + + def test_get_auto_sender(self): + handler = self.make_handler() + + # error if none configured + self.assertRaises(ConfigurationError, handler.get_auto_sender, 'foo') + + # can set global default + self.config.setdefault('wutta.email.default.sender', 'bob@example.com') + self.assertEqual(handler.get_auto_sender('foo'), 'bob@example.com') + + # can set for key + self.config.setdefault('wutta.email.foo.sender', 'sally@example.com') + self.assertEqual(handler.get_auto_sender('foo'), 'sally@example.com') + + def test_get_auto_subject_template(self): + handler = self.make_handler() + + # global default + template = handler.get_auto_subject_template('foo') + self.assertEqual(template, "Automated message") + + # can configure alternate global default + self.config.setdefault('wutta.email.default.subject', "Wutta Message") + template = handler.get_auto_subject_template('foo') + self.assertEqual(template, "Wutta Message") + + # can configure just for key + self.config.setdefault('wutta.email.foo.subject', "Foo Message") + template = handler.get_auto_subject_template('foo') + self.assertEqual(template, "Foo Message") + + def test_get_auto_subject(self): + handler = self.make_handler() + + # global default + subject = handler.get_auto_subject('foo') + self.assertEqual(subject, "Automated message") + + # can configure alternate global default + self.config.setdefault('wutta.email.default.subject', "Wutta Message") + subject = handler.get_auto_subject('foo') + self.assertEqual(subject, "Wutta Message") + + # can configure just for key + self.config.setdefault('wutta.email.foo.subject', "Foo Message") + subject = handler.get_auto_subject('foo') + self.assertEqual(subject, "Foo Message") + + # proper template is rendered + self.config.setdefault('wutta.email.bar.subject', "${foo} Message") + subject = handler.get_auto_subject('bar', {'foo': "FOO"}) + self.assertEqual(subject, "FOO Message") + + # unless we ask it not to + subject = handler.get_auto_subject('bar', {'foo': "FOO"}, rendered=False) + self.assertEqual(subject, "${foo} Message") + + def test_get_auto_recips(self): + handler = self.make_handler() + + # error if bad type requested + self.assertRaises(ValueError, handler.get_auto_recips, 'foo', 'doesnotexist') + + # can configure global default + self.config.setdefault('wutta.email.default.to', 'admin@example.com') + recips = handler.get_auto_recips('foo', 'to') + self.assertEqual(recips, ['admin@example.com']) + + # can configure just for key + self.config.setdefault('wutta.email.foo.to', 'bob@example.com') + recips = handler.get_auto_recips('foo', 'to') + self.assertEqual(recips, ['bob@example.com']) + + def test_get_auto_body_template(self): + from mako.template import Template + + handler = self.make_handler() + + # error if bad request + self.assertRaises(ValueError, handler.get_auto_body_template, 'foo', 'BADTYPE') + + # empty by default + template = handler.get_auto_body_template('foo', 'txt') + self.assertIsNone(template) + + # but returns a template if it exists + providers = { + 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + template = handler.get_auto_body_template('test_foo', 'txt') + self.assertIsInstance(template, Template) + self.assertEqual(template.uri, 'test_foo.txt.mako') + + def test_get_auto_txt_body(self): + handler = self.make_handler() + + # empty by default + body = handler.get_auto_txt_body('some-random-email') + self.assertIsNone(body) + + # but returns body if template exists + providers = { + 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + body = handler.get_auto_txt_body('test_foo') + self.assertEqual(body, 'hello from foo txt template\n') + + def test_get_auto_html_body(self): + handler = self.make_handler() + + # empty by default + body = handler.get_auto_html_body('some-random-email') + self.assertIsNone(body) + + # but returns body if template exists + providers = { + 'wuttatest': MagicMock(email_templates=['tests.email:templates']), + } + with patch.object(self.app, 'providers', new=providers): + handler = self.make_handler() + body = handler.get_auto_html_body('test_foo') + self.assertEqual(body, 'hello from foo html template
\n') + + def test_deliver_message(self): + handler = self.make_handler() + + msg = handler.make_message(sender='bob@example.com', to='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + + # no smtp session since sending email is disabled by default + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_not_called() + session.login.assert_not_called() + session.sendmail.assert_not_called() + + # now let's enable sending + self.config.setdefault('wutta.mail.send_emails', 'true') + + # smtp login not attempted by default + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.login.assert_not_called() + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # but login attempted if config has credentials + self.config.setdefault('wutta.mail.smtp.username', 'bob') + self.config.setdefault('wutta.mail.smtp.password', 'seekrit') + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.login.assert_called_once_with('bob', 'seekrit') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # error if no sender + msg = handler.make_message(to='sally@example.com') + self.assertRaises(ValueError, handler.deliver_message, msg) + + # error if no recips + msg = handler.make_message(sender='bob@example.com') + self.assertRaises(ValueError, handler.deliver_message, msg) + + # can set recips as list + msg = handler.make_message(sender='bob@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg, recips=['sally@example.com']) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips as string + msg = handler.make_message(sender='bob@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg, recips='sally@example.com') + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips via to + msg = handler.make_message(sender='bob@example.com', to='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips via cc + msg = handler.make_message(sender='bob@example.com', cc='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + # can set recips via bcc + msg = handler.make_message(sender='bob@example.com', bcc='sally@example.com') + with patch.object(msg, 'as_string', return_value='msg-str'): + with patch.object(mod, 'smtplib') as smtplib: + session = MagicMock() + smtplib.SMTP.return_value = session + handler.deliver_message(msg) + smtplib.SMTP.assert_called_once_with('localhost') + session.sendmail.assert_called_once_with('bob@example.com', {'sally@example.com'}, 'msg-str') + + def test_sending_is_enabled(self): + handler = self.make_handler() + + # off by default + self.assertFalse(handler.sending_is_enabled()) + + # but can be turned on + self.config.setdefault('wutta.mail.send_emails', 'true') + self.assertTrue(handler.sending_is_enabled()) + + def test_send_email(self): + with patch.object(mod.EmailHandler, 'deliver_message') as deliver_message: + handler = self.make_handler() + + # deliver_message() is called + handler.send_email('foo', sender='bob@example.com', to='sally@example.com', + txt_body='hello world') + deliver_message.assert_called_once() + + # make_auto_message() called only if needed + with patch.object(handler, 'make_auto_message') as make_auto_message: + + msg = handler.make_message() + handler.send_email(message=msg) + make_auto_message.assert_not_called() + + handler.send_email('foo', sender='bob@example.com', to='sally@example.com', + txt_body='hello world') + make_auto_message.assert_called_once_with('foo', {}, + sender='bob@example.com', + to='sally@example.com', + txt_body='hello world') diff --git a/tests/email/test_message.py b/tests/email/test_message.py new file mode 100644 index 0000000..f8ff67a --- /dev/null +++ b/tests/email/test_message.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +from wuttjamaican.email import message as mod + + +class TestMessage(TestCase): + + def make_message(self, **kwargs): + return mod.Message(**kwargs) + + def test_set_recips(self): + msg = self.make_message() + self.assertEqual(msg.to, []) + + # set as list + msg.set_recips('to', ['sally@example.com']) + self.assertEqual(msg.to, ['sally@example.com']) + + # set as tuple + msg.set_recips('to', ('barney@example.com',)) + self.assertEqual(msg.to, ['barney@example.com']) + + # set as string + msg.set_recips('to', 'wilma@example.com') + self.assertEqual(msg.to, ['wilma@example.com']) + + # set as null + msg.set_recips('to', None) + self.assertEqual(msg.to, []) + + # otherwise error + self.assertRaises(ValueError, msg.set_recips, 'to', {'foo': 'foo@example.com'}) + + def test_as_string(self): + + # error if no body + msg = self.make_message() + self.assertRaises(ValueError, msg.as_string) + + # txt body + msg = self.make_message(sender='bob@example.com', + txt_body="hello world") + complete = msg.as_string() + self.assertIn('From: bob@example.com', complete) + + # html body + msg = self.make_message(sender='bob@example.com', + html_body="hello world
") + complete = msg.as_string() + self.assertIn('From: bob@example.com', complete) + + # txt + html body + msg = self.make_message(sender='bob@example.com', + txt_body="hello world", + html_body="hello world
") + complete = msg.as_string() + self.assertIn('From: bob@example.com', complete) + + # everything + msg = self.make_message(sender='bob@example.com', + subject='meeting follow-up', + to='sally@example.com', + cc='marketing@example.com', + bcc='bob@example.com', + replyto='sales@example.com', + txt_body="hello world", + html_body="hello world
") + complete = msg.as_string() + self.assertIn('From: bob@example.com', complete) + self.assertIn('Subject: meeting follow-up', complete) + self.assertIn('To: sally@example.com', complete) + self.assertIn('Cc: marketing@example.com', complete) + self.assertIn('Bcc: bob@example.com', complete) + self.assertIn('Reply-To: sales@example.com', complete) diff --git a/tests/test_app.py b/tests/test_app.py index 35ec466..ef4f254 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -10,14 +10,18 @@ from unittest.mock import patch, MagicMock import pytest +import wuttjamaican.enum from wuttjamaican import app +from wuttjamaican.progress import ProgressBase from wuttjamaican.conf import WuttaConfig from wuttjamaican.util import UNSPECIFIED +from wuttjamaican.testing import FileConfigTestCase -class TestAppHandler(TestCase): +class TestAppHandler(FileConfigTestCase): def setUp(self): + self.setup_files() self.config = WuttaConfig(appname='wuttatest') self.app = app.AppHandler(self.config) self.config.app = self.app @@ -27,6 +31,9 @@ class TestAppHandler(TestCase): self.assertEqual(self.app.handlers, {}) self.assertEqual(self.app.appname, 'wuttatest') + def test_get_enum(self): + self.assertIs(self.app.get_enum(), wuttjamaican.enum) + def test_load_object(self): # just confirm the method works on a basic level; the @@ -34,6 +41,40 @@ class TestAppHandler(TestCase): obj = self.app.load_object('wuttjamaican.util:UNSPECIFIED') self.assertIs(obj, UNSPECIFIED) + def test_get_appdir(self): + + mockdir = self.mkdir('mockdir') + + # default appdir + with patch.object(sys, 'prefix', new=mockdir): + + # default is returned by default + appdir = self.app.get_appdir() + self.assertEqual(appdir, os.path.join(mockdir, 'app')) + + # but not if caller wants config only + appdir = self.app.get_appdir(configured_only=True) + self.assertIsNone(appdir) + + # also, cannot create if appdir path not known + self.assertRaises(ValueError, self.app.get_appdir, configured_only=True, create=True) + + # configured appdir + self.config.setdefault('wuttatest.appdir', mockdir) + appdir = self.app.get_appdir() + self.assertEqual(appdir, mockdir) + + # appdir w/ subpath + appdir = self.app.get_appdir('foo', 'bar') + self.assertEqual(appdir, os.path.join(mockdir, 'foo', 'bar')) + + # subpath is created + self.assertEqual(len(os.listdir(mockdir)), 0) + appdir = self.app.get_appdir('foo', 'bar', create=True) + self.assertEqual(appdir, os.path.join(mockdir, 'foo', 'bar')) + self.assertEqual(os.listdir(mockdir), ['foo']) + self.assertEqual(os.listdir(os.path.join(mockdir, 'foo')), ['bar']) + def test_make_appdir(self): # appdir is created, and 3 subfolders added by default @@ -311,6 +352,19 @@ class TestAppHandler(TestCase): uuid = self.app.make_uuid() self.assertEqual(len(uuid), 32) + def test_progress_loop(self): + + def act(obj, i): + pass + + # with progress + self.app.progress_loop(act, [1, 2, 3], ProgressBase, + message="whatever") + + # without progress + self.app.progress_loop(act, [1, 2, 3], None, + message="whatever") + def test_get_session(self): try: import sqlalchemy as sa @@ -343,12 +397,25 @@ class TestAppHandler(TestCase): auth = self.app.get_auth_handler() self.assertIsInstance(auth, AuthHandler) + def test_get_email_handler(self): + from wuttjamaican.email import EmailHandler + + mail = self.app.get_email_handler() + self.assertIsInstance(mail, EmailHandler) + def test_get_people_handler(self): from wuttjamaican.people import PeopleHandler people = self.app.get_people_handler() self.assertIsInstance(people, PeopleHandler) + def test_get_send_email(self): + from wuttjamaican.email import EmailHandler + + with patch.object(EmailHandler, 'send_email') as send_email: + self.app.send_email('foo') + send_email.assert_called_once_with('foo') + class TestAppProvider(TestCase): @@ -403,6 +470,12 @@ class TestAppProvider(TestCase): def test_getattr(self): + # enum + self.assertNotIn('enum', self.app.__dict__) + self.assertIs(self.app.enum, wuttjamaican.enum) + + # now we test that providers are loaded... + class FakeProvider(app.AppProvider): def fake_foo(self): return 42 @@ -417,6 +490,16 @@ class TestAppProvider(TestCase): self.assertIs(self.app.providers, fake_providers) get_all_providers.assert_called_once_with() + def test_getattr_model(self): + try: + import wuttjamaican.db.model + except ImportError: + pytest.skip("test not relevant without sqlalchemy") + + # model + self.assertNotIn('model', self.app.__dict__) + self.assertIs(self.app.model, wuttjamaican.db.model) + def test_getattr_providers(self): # collection of providers is loaded on demand diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 0000000..16a6787 --- /dev/null +++ b/tests/test_progress.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +from wuttjamaican import progress as mod + + +class TestProgressBase(TestCase): + + def test_basic(self): + + # sanity / coverage check + prog = mod.ProgressBase('testing', 2) + prog.update(1) + prog.update(2) + prog.finish() + + +class TestConsoleProgress(TestCase): + + def test_basic(self): + + # sanity / coverage check + prog = mod.ConsoleProgress('testing', 2) + prog.update(1) + prog.update(2) + prog.finish() diff --git a/tests/test_util.py b/tests/test_util.py index 0f2baf4..3d350cd 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -7,6 +7,7 @@ from unittest.mock import patch, MagicMock import pytest from wuttjamaican import util as mod +from wuttjamaican.progress import ProgressBase class A: pass @@ -260,3 +261,59 @@ class TestMakeTitle(TestCase): def test_basic(self): text = mod.make_title('foo_bar') self.assertEqual(text, "Foo Bar") + + +class TestProgressLoop(TestCase): + + def test_basic(self): + + def act(obj, i): + pass + + # with progress + mod.progress_loop(act, [1, 2, 3], ProgressBase, + message="whatever") + + # without progress + mod.progress_loop(act, [1, 2, 3], None, + message="whatever") + + +class TestResourcePath(TestCase): + + def test_basic(self): + + # package spec is resolved to path + path = mod.resource_path('wuttjamaican:util.py') + self.assertTrue(path.endswith('wuttjamaican/util.py')) + + # absolute path returned as-is + self.assertEqual(mod.resource_path('/tmp/doesnotexist.txt'), '/tmp/doesnotexist.txt') + + def test_basic_pre_python_3_9(self): + + # the goal here is to get coverage for code which would only + # run on python 3.8 and older, but we only need that coverage + # if we are currently testing python 3.9+ + if sys.version_info.major == 3 and sys.version_info.minor < 9: + pytest.skip("this test is not relevant before python 3.9") + + from importlib.resources import files, as_file + + orig_import = __import__ + + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'importlib.resources': + raise ImportError + if name == 'importlib_resources': + return MagicMock(files=files, as_file=as_file) + return orig_import(name, globals, locals, fromlist, level) + + with patch('builtins.__import__', side_effect=mock_import): + + # package spec is resolved to path + path = mod.resource_path('wuttjamaican:util.py') + self.assertTrue(path.endswith('wuttjamaican/util.py')) + + # absolute path returned as-is + self.assertEqual(mod.resource_path('/tmp/doesnotexist.txt'), '/tmp/doesnotexist.txt')