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 . +# +################################################################################ +""" +Upgrade Model +""" + +import datetime + +import sqlalchemy as sa +from sqlalchemy import orm + +from .base import Base, uuid_column, uuid_fk_column +from wuttjamaican.enum import UpgradeStatus + + +class Upgrade(Base): + """ + Represents an app upgrade. + """ + __tablename__ = 'upgrade' + + uuid = uuid_column() + + created = sa.Column(sa.DateTime(timezone=True), nullable=False, + default=datetime.datetime.now, doc=""" + When the upgrade record was created. + """) + + created_by_uuid = uuid_fk_column('user.uuid', nullable=False) + created_by = orm.relationship( + 'User', + foreign_keys=[created_by_uuid], + doc=""" + :class:`~wuttjamaican.db.model.auth.User` who created the + upgrade record. + """) + + description = sa.Column(sa.String(length=255), nullable=False, doc=""" + Basic (identifying) description for the upgrade. + """) + + notes = sa.Column(sa.Text(), nullable=True, doc=""" + Notes for the upgrade. + """) + + executing = sa.Column(sa.Boolean(), nullable=False, default=False, doc=""" + Whether or not the upgrade is currently being performed. + """) + + status = sa.Column(sa.Enum(UpgradeStatus), nullable=False, doc=""" + Current status for the upgrade. This field uses an enum, + :class:`~wuttjamaican.enum.UpgradeStatus`. + """) + + executed = sa.Column(sa.DateTime(timezone=True), nullable=True, doc=""" + When the upgrade was executed. + """) + + executed_by_uuid = uuid_fk_column('user.uuid', nullable=True) + executed_by = orm.relationship( + 'User', + foreign_keys=[executed_by_uuid], + doc=""" + :class:`~wuttjamaican.db.model.auth.User` who executed the + upgrade. + """) + + exit_code = sa.Column(sa.Integer(), nullable=True, doc=""" + Exit code for the upgrade execution process, if applicable. + """) + + def __str__(self): + return str(self.description or "") diff --git a/src/wuttjamaican/email/__init__.py b/src/wuttjamaican/email/__init__.py new file mode 100644 index 0000000..8702f9d --- /dev/null +++ b/src/wuttjamaican/email/__init__.py @@ -0,0 +1,33 @@ +# -*- 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 . +# +################################################################################ +""" +Email Utilities + +The following are available in this ``wuttjamaican.email`` namespace: + +* :class:`~wuttjamaican.email.handler.EmailHandler` +* :class:`~wuttjamaican.email.message.Message` +""" + +from .handler import EmailHandler +from .message import Message diff --git a/src/wuttjamaican/email/handler.py b/src/wuttjamaican/email/handler.py new file mode 100644 index 0000000..59f328c --- /dev/null +++ b/src/wuttjamaican/email/handler.py @@ -0,0 +1,423 @@ +# -*- 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 . +# +################################################################################ +""" +Email Handler +""" + +import logging +import smtplib + +from wuttjamaican.app import GenericHandler +from wuttjamaican.util import resource_path +from wuttjamaican.email.message import Message + + +log = logging.getLogger(__name__) + + +class EmailHandler(GenericHandler): + """ + Base class and default implementation for the :term:`email + handler`. + + Responsible for sending email messages on behalf of the + :term:`app`. + + You normally would not create this directly, but instead call + :meth:`~wuttjamaican.app.AppHandler.get_email_handler()` on your + :term:`app handler`. + """ + + # nb. this is fallback/default subject for auto-message + universal_subject = "Automated message" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + from mako.lookup import TemplateLookup + + # prefer configured list of template lookup paths, if set + templates = self.config.get_list(f'{self.config.appname}.email.templates') + if not templates: + + # otherwise use all available paths, from app providers + available = [] + for provider in self.app.providers.values(): + if hasattr(provider, 'email_templates'): + templates = provider.email_templates + if isinstance(templates, str): + templates = [templates] + if templates: + available.extend(templates) + templates = available + + # convert all to true file paths + if templates: + templates = [resource_path(p) for p in templates] + + # will use these lookups from now on + self.txt_templates = TemplateLookup(directories=templates) + self.html_templates = TemplateLookup(directories=templates, + # nb. escape HTML special chars + # TODO: sounds great but i forget why? + default_filters=['h']) + + def make_message(self, **kwargs): + """ + Make and return a new email message. + + This is the "raw" factory which is simply a wrapper around the + class constructor. See also :meth:`make_auto_message()`. + + :returns: :class:`~wuttjamaican.email.message.Message` object. + """ + return Message(**kwargs) + + def make_auto_message(self, key, context={}, **kwargs): + """ + Make a new email message using config to determine its + properties, and auto-generating body from a template. + + Once everything has been collected/prepared, + :meth:`make_message()` is called to create the final message, + and that is returned. + + :param key: Unique key for this particular "type" of message. + This key is used as a prefix for all config settings and + template names pertinent to the message. + + :param context: Context dict used to render template(s) for + the message. + + :param \**kwargs: Any remaining kwargs are passed as-is to + :meth:`make_message()`. More on this below. + + :returns: :class:`~wuttjamaican.email.message.Message` object. + + This method may invoke some others, to gather the message + attributes. Each will check config, or render a template, or + both. However if a particular attribute is provided by the + caller, the corresponding "auto" method is skipped. + + * :meth:`get_auto_sender()` + * :meth:`get_auto_subject()` + * :meth:`get_auto_to()` + * :meth:`get_auto_cc()` + * :meth:`get_auto_bcc()` + * :meth:`get_auto_txt_body()` + * :meth:`get_auto_html_body()` + """ + kwargs['key'] = key + if 'sender' not in kwargs: + kwargs['sender'] = self.get_auto_sender(key) + if 'subject' not in kwargs: + kwargs['subject'] = self.get_auto_subject(key, context) + if 'to' not in kwargs: + kwargs['to'] = self.get_auto_to(key) + if 'cc' not in kwargs: + kwargs['cc'] = self.get_auto_cc(key) + if 'bcc' not in kwargs: + kwargs['bcc'] = self.get_auto_bcc(key) + if 'txt_body' not in kwargs: + kwargs['txt_body'] = self.get_auto_txt_body(key, context) + if 'html_body' not in kwargs: + kwargs['html_body'] = self.get_auto_html_body(key, context) + return self.make_message(**kwargs) + + def get_auto_sender(self, key): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.sender` address for + a message, as determined by config. + """ + # prefer configured sender specific to key + sender = self.config.get(f'{self.config.appname}.email.{key}.sender') + if sender: + return sender + + # fall back to global default (required!) + return self.config.require(f'{self.config.appname}.email.default.sender') + + def get_auto_subject(self, key, context={}, rendered=True): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.subject` line for a + message, as determined by config. + + This calls :meth:`get_auto_subject_template()` and then + renders the result using the given context. + + :param rendered: If this is ``False``, the "raw" subject + template will be returned, instead of the final/rendered + subject text. + """ + from mako.template import Template + + template = self.get_auto_subject_template(key) + if not rendered: + return template + return Template(template).render(**context) + + def get_auto_subject_template(self, key): + """ + Returns the template string to use for automatic subject line + of a message, as determined by config. + + In many cases this will be a simple string and not a + "template" per se; however it is still treated as a template. + + The template returned from this method is used to render the + final subject line in :meth:`get_auto_subject()`. + """ + # prefer configured subject specific to key + template = self.config.get(f'{self.config.appname}.email.{key}.subject') + if template: + return template + + # fall back to global default + return self.config.get(f'{self.config.appname}.email.default.subject', + default=self.universal_subject) + + def get_auto_to(self, key): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.to` recipient + address(es) for a message, as determined by config. + """ + return self.get_auto_recips(key, 'to') + + def get_auto_cc(self, key): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.cc` recipient + address(es) for a message, as determined by config. + """ + return self.get_auto_recips(key, 'cc') + + def get_auto_bcc(self, key): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.bcc` recipient + address(es) for a message, as determined by config. + """ + return self.get_auto_recips(key, 'bcc') + + def get_auto_recips(self, key, typ): + """ """ + typ = typ.lower() + if typ not in ('to', 'cc', 'bcc'): + raise ValueError("requested type not supported") + + # prefer configured recips specific to key + recips = self.config.get_list(f'{self.config.appname}.email.{key}.{typ}') + if recips: + return recips + + # fall back to global default + return self.config.get_list(f'{self.config.appname}.email.default.{typ}', + default=[]) + + def get_auto_txt_body(self, key, context={}): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.txt_body` content + for a message, as determined by config. This renders a + template with the given context. + """ + template = self.get_auto_body_template(key, 'txt') + if template: + return template.render(**context) + + def get_auto_html_body(self, key, context={}): + """ + Returns automatic + :attr:`~wuttjamaican.email.message.Message.html_body` content + for a message, as determined by config. This renders a + template with the given context. + """ + template = self.get_auto_body_template(key, 'html') + if template: + return template.render(**context) + + def get_auto_body_template(self, key, typ): + """ """ + from mako.exceptions import TopLevelLookupException + + typ = typ.lower() + if typ not in ('txt', 'html'): + raise ValueError("requested type not supported") + + if typ == 'txt': + templates = self.txt_templates + elif typ == 'html': + templates = self.html_templates + + try: + return templates.get_template(f'{key}.{typ}.mako') + except TopLevelLookupException: + pass + + def deliver_message(self, message, sender=None, recips=None): + """ + Deliver a message via SMTP smarthost. + + :param message: Either a + :class:`~wuttjamaican.email.message.Message` object or + similar, or a string representing the complete message to + be sent as-is. + + :param sender: Optional sender address to use for delivery. + If not specified, will be read from ``message``. + + :param recips: Optional recipient address(es) for delivery. + If not specified, will be read from ``message``. + + A general rule here is that you can either provide a proper + :class:`~wuttjamaican.email.message.Message` object, **or** + you *must* provide ``sender`` and ``recips``. The logic is + not smart enough (yet?) to parse sender/recips from a simple + string message. + + Note also, this method does not (yet?) have robust error + handling, so if an error occurs with the SMTP session, it will + simply raise to caller. + + :returns: ``None`` + """ + if not sender: + sender = message.sender + if not sender: + raise ValueError("no sender identified for message delivery") + + if not recips: + recips = set() + if message.to: + recips.update(message.to) + if message.cc: + recips.update(message.cc) + if message.bcc: + recips.update(message.bcc) + elif isinstance(recips, str): + recips = [recips] + + recips = set(recips) + if not recips: + raise ValueError("no recipients identified for message delivery") + + if not isinstance(message, str): + message = message.as_string() + + # get smtp info + server = self.config.get(f'{self.config.appname}.mail.smtp.server', default='localhost') + username = self.config.get(f'{self.config.appname}.mail.smtp.username') + password = self.config.get(f'{self.config.appname}.mail.smtp.password') + + # make sure sending is enabled + log.debug("sending email from %s; to %s", sender, recips) + if not self.sending_is_enabled(): + log.debug("nevermind, config says no emails") + return + + # smtp connect + session = smtplib.SMTP(server) + if username and password: + session.login(username, password) + + # smtp send + session.sendmail(sender, recips, message) + session.quit() + log.debug("email was sent") + + def sending_is_enabled(self): + """ + Returns boolean indicating if email sending is enabled. + + Set this flag in config like this: + + .. code-block:: ini + + [wutta.mail] + send_emails = true + + Note that it is OFF by default. + """ + return self.config.get_bool(f'{self.config.appname}.mail.send_emails', + default=False) + + def send_email(self, key=None, context={}, message=None, sender=None, recips=None, **kwargs): + """ + Send an email message. + + This method can send a ``message`` you provide, or it can + construct one automatically from key/config/templates. + + :param key: Indicates which "type" of automatic email to send. + Used to lookup config settings and template files. + + :param context: Context dict for rendering automatic email + template(s). + + :param message: Optional pre-built message instance, to send + as-is. + + :param sender: Optional sender address for the + message/delivery. + + If ``message`` is not provided, then the ``sender`` (if + provided) will also be used when constructing the + auto-message (i.e. to set the ``From:`` header). + + In any case if ``sender`` is provided, it will be used for + the actual SMTP delivery. + + :param recips: Optional list of recipient addresses for + delivery. If not specified, will be read from the message + itself (after auto-generating it, if applicable). + + .. note:: + + This param does not affect an auto-generated message; it + is used for delivery only. As such it must contain + *all* true recipients. + + If you provide the ``message`` but not the ``recips``, + the latter will be read from message headers: ``To:``, + ``Cc:`` and ``Bcc:`` + + If you want an auto-generated message but also want to + override various recipient headers, then you must + provide those explicitly:: + + context = {'data': [1, 2, 3]} + app.send_email('foo', context, to='me@example.com', cc='bobby@example.com') + + :param \**kwargs: Any remaining kwargs are passed along to + :meth:`make_auto_message()`. So, not used if you provide + the ``message``. + """ + if message is None: + if sender: + kwargs['sender'] = sender + message = self.make_auto_message(key, context, **kwargs) + + self.deliver_message(message, recips=recips) diff --git a/src/wuttjamaican/email/message.py b/src/wuttjamaican/email/message.py new file mode 100644 index 0000000..0e9f25e --- /dev/null +++ b/src/wuttjamaican/email/message.py @@ -0,0 +1,158 @@ +# -*- 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 . +# +################################################################################ +""" +Email Message +""" + +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + + +class Message: + """ + Represents an email message to be sent. + + :param to: Recipient(s) for the message. This may be either a + string, or list of strings. If a string, it will be converted + to a list since that is how the :attr:`to` attribute tracks it. + Similar logic is used for :attr:`cc` and :attr:`bcc`. + + All attributes shown below may also be specified via constructor. + + .. attribute:: key + + Unique key indicating the "type" of message. An "ad-hoc" + message created arbitrarily may not have/need a key; however + one created via + :meth:`~wuttjamaican.email.handler.EmailHandler.make_auto_message()` + will always have a key. + + This key is not used for anything within the ``Message`` class + logic. It is used by + :meth:`~wuttjamaican.email.handler.EmailHandler.make_auto_message()` + when constructing the message, and the key is set on the final + message only as a reference. + + .. attribute:: sender + + Sender (``From:``) address for the message. + + .. attribute:: subject + + Subject text for the message. + + .. attribute:: to + + List of ``To:`` recipients for the message. + + .. attribute:: cc + + List of ``Cc:`` recipients for the message. + + .. attribute:: bcc + + List of ``Bcc:`` recipients for the message. + + .. attribute:: replyto + + Optional reply-to (``Reply-To:``) address for the message. + + .. attribute:: txt_body + + String with the ``text/plain`` body content. + + .. attribute:: html_body + + String with the ``text/html`` body content. + """ + + def __init__( + self, + key=None, + sender=None, + subject=None, + to=None, + cc=None, + bcc=None, + replyto=None, + txt_body=None, + html_body=None, + ): + self.key = key + self.sender = sender + self.subject = subject + self.set_recips('to', to) + self.set_recips('cc', cc) + self.set_recips('bcc', bcc) + self.replyto = replyto + self.txt_body = txt_body + self.html_body = html_body + + def set_recips(self, name, value): + """ """ + if value: + if isinstance(value, str): + value = [value] + if not isinstance(value, (list, tuple)): + raise ValueError("must specify a string, tuple or list value") + else: + value = [] + setattr(self, name, list(value)) + + def as_string(self): + """ + Returns the complete message as string. This is called from + within + :meth:`~wuttjamaican.email.handler.EmailHandler.deliver_message()` + to obtain the SMTP payload. + """ + msg = None + + if self.txt_body and self.html_body: + txt = MIMEText(self.txt_body, _charset='utf_8') + html = MIMEText(self.html_body, _subtype='html', _charset='utf_8') + msg = MIMEMultipart(_subtype='alternative', _subparts=[txt, html]) + + elif self.txt_body: + msg = MIMEText(self.txt_body, _charset='utf_8') + + elif self.html_body: + msg = MIMEText(self.html_body, 'html', _charset='utf_8') + + if not msg: + raise ValueError("message has no body parts") + + msg['Subject'] = self.subject + msg['From'] = self.sender + + for addr in self.to: + msg['To'] = addr + for addr in self.cc: + msg['Cc'] = addr + for addr in self.bcc: + msg['Bcc'] = addr + + if self.replyto: + msg.add_header('Reply-To', self.replyto) + + return msg.as_string() diff --git a/src/wuttjamaican/enum.py b/src/wuttjamaican/enum.py new file mode 100644 index 0000000..3265745 --- /dev/null +++ b/src/wuttjamaican/enum.py @@ -0,0 +1,38 @@ +# -*- 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 . +# +################################################################################ +""" +Enum Values +""" + +from enum import Enum + + +class UpgradeStatus(Enum): + """ + Enum values for + :attr:`wuttjamaican.db.model.upgrades.Upgrade.status`. + """ + PENDING = 'pending' + EXECUTING = 'executing' + SUCCESS = 'success' + FAILURE = 'failure' diff --git a/src/wuttjamaican/progress.py b/src/wuttjamaican/progress.py new file mode 100644 index 0000000..712675c --- /dev/null +++ b/src/wuttjamaican/progress.py @@ -0,0 +1,113 @@ +# -*- 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 . +# +################################################################################ +""" +Progress Indicators +""" + +import sys + +from progress.bar import Bar + + +class ProgressBase: + """ + Base class for progress indicators. + + This is *only* a base class, and should not be used directly. For + simple console use, see :class:`ConsoleProgress`. + + Progress indicators are created via factory from various places in + the code. The factory is called with ``(message, maximum)`` args + and it must return a progress instance with these methods: + + * :meth:`update()` + * :meth:`finish()` + + Code may call ``update()`` several times while its operation + continues; it then ultimately should call ``finish()``. + + See also :func:`wuttjamaican.util.progress_loop()` and + :meth:`wuttjamaican.app.AppHandler.progress_loop()` for a way to + do these things automatically from code. + + :param message: Info message to be displayed along with the + progress bar. + + :param maximum: Max progress value. + """ + + def __init__(self, message, maximum): + self.message = message + self.maximum = maximum + + def update(self, value): + """ + Update the current progress value. + + :param value: New progress value to be displayed. + """ + + def finish(self): + """ + Wrap things up for the progress display etc. + """ + + +class ConsoleProgress(ProgressBase): + """ + Provides a console-based progress bar. + + This is a subclass of :class:`ProgressBase`. + + Simple usage is like:: + + from wuttjamaican.progress import ConsoleProgress + + def action(obj, i): + print(obj) + + items = [1, 2, 3, 4, 5] + + app = config.get_app() + app.progress_loop(action, items, ConsoleProgress, + message="printing items") + + See also :func:`~wuttjamaican.util.progress_loop()`. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args) + + self.stderr = kwargs.get('stderr', sys.stderr) + self.stderr.write(f"\n{self.message}...\n") + + self.bar = Bar(message='', max=self.maximum, width=70, + suffix='%(index)d/%(max)d %(percent)d%% ETA %(eta)ds') + + def update(self, value): + """ """ + self.bar.next() + + def finish(self): + """ """ + self.bar.finish() diff --git a/src/wuttjamaican/testing.py b/src/wuttjamaican/testing.py index 069d130..a51f3b5 100644 --- a/src/wuttjamaican/testing.py +++ b/src/wuttjamaican/testing.py @@ -97,3 +97,11 @@ class FileConfigTestCase(TestCase): with open(path, 'wt') as f: f.write(content) return path + + def mkdir(self, dirname): + """ + Make a new temporary folder and return its path. + + Note that this will be created *underneath* :attr:`tempdir`. + """ + return tempfile.mkdtemp(dir=self.tempdir) diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index 51bdb03..342868e 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -26,6 +26,7 @@ WuttJamaican - utilities import importlib import logging +import os import shlex from uuid import uuid1 @@ -210,3 +211,99 @@ def parse_list(value): elif value.startswith("'") and value.endswith("'"): values[i] = value[1:-1] return values + + +def progress_loop(func, items, factory, message=None): + """ + Convenience function to iterate over a set of items, invoking + logic for each, and updating a progress indicator along the way. + + This function may also be called via the :term:`app handler`; see + :meth:`~wuttjamaican.app.AppHandler.progress_loop()`. + + The ``factory`` will be called to create the progress indicator, + which should be an instance of + :class:`~wuttjamaican.progress.ProgressBase`. + + The ``factory`` may also be ``None`` in which case there is no + progress, and this is really just a simple "for loop". + + :param func: Callable to be invoked for each item in the sequence. + See below for more details. + + :param items: Sequence of items over which to iterate. + + :param factory: Callable which creates/returns a progress + indicator, or can be ``None`` for no progress. + + :param message: Message to display along with the progress + indicator. If no message is specified, whether a default is + shown will be up to the progress indicator. + + The ``func`` param should be a callable which accepts 2 positional + args ``(obj, i)`` - meaning for which is as follows: + + :param obj: This will be an item within the sequence. + + :param i: This will be the *one-based* sequence number for the + item. + + See also :class:`~wuttjamaican.progress.ConsoleProgress` for a + usage example. + """ + progress = None + if factory: + count = len(items) + progress = factory(message, count) + + for i, item in enumerate(items, 1): + + func(item, i) + + if progress: + progress.update(i) + + if progress: + progress.finish() + + +def resource_path(path): + """ + Returns the absolute file path for the given resource path. + + A "resource path" is one which designates a python package name, + plus some path under that. For instance: + + .. code-block:: none + + wuttjamaican.email:templates + + Assuming such a path should exist, the question is "where?" + + So this function uses :mod:`python:importlib.resources` to locate + the path, possibly extracting the file(s) from a zipped package, + and returning the final path on disk. + + It only does this if it detects it is needed, based on the given + ``path`` argument. If that is already an absolute path then it + will be returned as-is. + + :param path: Either a package resource specifier as shown above, + or regular file path. + + :returns: Absolute file path to the resource. + """ + if not os.path.isabs(path) and ':' in path: + + try: + # nb. these were added in python 3.9 + from importlib.resources import files, as_file + except ImportError: # python < 3.9 + from importlib_resources import files, as_file + + package, filename = path.split(':') + ref = files(package) / filename + with as_file(ref) as path: + return str(path) + + return path diff --git a/tests/db/model/test_upgrades.py b/tests/db/model/test_upgrades.py new file mode 100644 index 0000000..c40a193 --- /dev/null +++ b/tests/db/model/test_upgrades.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +try: + from wuttjamaican.db.model import upgrades as mod +except ImportError: + pass +else: + + class TestUpgrade(TestCase): + + def test_str(self): + upgrade = mod.Upgrade(description="upgrade foo") + self.assertEqual(str(upgrade), "upgrade foo") diff --git a/tests/email/__init__.py b/tests/email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/email/templates/test_foo.html.mako b/tests/email/templates/test_foo.html.mako new file mode 100644 index 0000000..babdeaf --- /dev/null +++ b/tests/email/templates/test_foo.html.mako @@ -0,0 +1 @@ +

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')