diff --git a/MANIFEST.in b/MANIFEST.in index 8785f35..00c21ec 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,13 @@ include README.md include LICENSE -include setup.cfg +include pyproject.toml -recursive-include johnnyfive/config * +include johnnyfive/config/README.md +include johnnyfive/config/email.conf-TEMPLATE +include johnnyfive/config/emailFooter.txt +include johnnyfive/config/gmail_credentials.json-TEMPLATE +include johnnyfive/config/gmail_token.json-TEMPLATE +include johnnyfive/config/johnnyfive.conf-TEMPLATE recursive-include johnnyfive/images * global-exclude *.pyc *.o *.so *.DS_Store diff --git a/README.md b/README.md index e38d49b..a117472 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ import johnnyfive page = johnnyfive.ConfluencePage(space, page_title, instance=None, use_oauth=False) # Gmail API -message = johnnyfive.GmailMessage(toaddr, subject, message_text, fromname=None fromaddr=None, interactive=False) +message = johnnyfive.GmailMessage(toaddr, subject, message_text, fromname=None, fromaddr=None, interactive=False) message_list = johnnyfive.GetMessages(label=None, after=None, before=None, interactive=False) # Slack API @@ -33,12 +33,13 @@ slack_channel = johnnyfive.SlackChannel(channel_name) - google-api-python-client - google-auth-httplib2 - google-auth-oauthlib +- httplib2 - lxml -- pyjwt -- python-twitter - requests - slack_sdk -- ligmos @ https://github.com/LowellObservatory/ligmos + +JohnnyFive parses its own INI-style configuration file and does not require +`ligmos`. ## Installation @@ -52,6 +53,8 @@ Installable either as a standalone library: Or as a dependancy for other software: -- In your package's `setup.cfg` file, add: +- In your package's `pyproject.toml` file, add: - ```install_requires = JohnnyFive @ git+https://github.com/LowellObservatory/JohnnyFive``` + ```toml + dependencies = ["JohnnyFive @ git+https://github.com/LowellObservatory/JohnnyFive"] + ``` diff --git a/johnnyfive/old_email.py b/ToyModels/old_email.py similarity index 68% rename from johnnyfive/old_email.py rename to ToyModels/old_email.py index 023857f..e7831da 100644 --- a/johnnyfive/old_email.py +++ b/ToyModels/old_email.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 # # Created on 25 Feb 2020 # @@ -19,11 +17,35 @@ import socket import smtplib from email.message import EmailMessage - - -def sendMail(message, smtploc='localhost', port=25, user=None, passw=None): - """ - This assumes that 'message' is an instance of EmailMessage +from typing import Any + + +def sendMail( + message: EmailMessage, + smtploc: str = "localhost", + port: int | str = 25, + user: str | None = None, + passw: str | None = None, +) -> bool: + """Send an email message via an unencrypted or SSL SMTP connection. + + Parameters + ---------- + message : EmailMessage + Message to send. + smtploc : str, optional + SMTP host name. + port : int | str, optional + SMTP port number. + user : str | None, optional + User name for SSL authentication. + passw : str | None, optional + Password for SSL authentication. + + Returns + ------- + bool + Whether the message was sent successfully. """ # Ultimate return value to know whether we need to try again later success = False @@ -77,8 +99,32 @@ def sendMail(message, smtploc='localhost', port=25, user=None, passw=None): return success -def constructMail(subject, body, fromaddr, toaddr, fromname=None): - """ +def constructMail( + subject: str, + body: str, + fromaddr: str, + toaddr: str, + fromname: str | None = None, +) -> EmailMessage: + """Construct a plain-text email message. + + Parameters + ---------- + subject : str + Email subject. + body : str + Plain-text message body. + fromaddr : str + Sender address. + toaddr : str + Recipient address. + fromname : str | None, optional + Sender display name. + + Returns + ------- + EmailMessage + Configured email message. """ msg = EmailMessage() if fromname is None: diff --git a/ToyModels/tweetTester.py b/ToyModels/tweetTester.py index f3e681c..39f099e 100644 --- a/ToyModels/tweetTester.py +++ b/ToyModels/tweetTester.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 # # Created on 7 Feb 2020 # @@ -15,13 +13,27 @@ from __future__ import division, print_function, absolute_import +import configparser +from pathlib import Path +from typing import Mapping + import twitter -import ligmos +def sendMessage(twit: Mapping[str, str], message: str) -> None: + """Post a message to Twitter using configured API credentials. -def sendMessage(twit, message): - """ + Parameters + ---------- + twit : Mapping[str, str] + Twitter API credential mapping. + message : str + Message to post. + + Returns + ------- + None + The API response is printed for this prototype script. """ api = twitter.Api(consumer_key=twit['apiKey'], consumer_secret=twit['apiSecret'], @@ -41,10 +53,11 @@ def sendMessage(twit, message): if __name__ == "__main__": - confFile = '../config/johnnyfive.conf' - conf = ligmos.utils.confparsers.rawParser(confFile) + conf_file = Path(__file__).parents[1] / "johnnyfive" / "config" / "johnnyfive.conf" + conf = configparser.ConfigParser() + conf.read(conf_file) # Quick and dirty for prototyping, will set up classes later - twit = conf['twitterSetup'] + twit = conf["twitterSetup"] sendMessage(twit) diff --git a/examples/gmail_example.py b/examples/gmail_example.py index 34ddef7..c82ed84 100644 --- a/examples/gmail_example.py +++ b/examples/gmail_example.py @@ -1,13 +1,17 @@ -""" Example for using the Gmail module - -_extended_summary_ -""" +"""Demonstrate the Gmail message API.""" from johnnyfive import gmail as j5g from johnnyfive import utils -def main(interactive=False): +def main(interactive: bool = False) -> None: + """Demonstrate sending a Gmail message with an attachment. + + Parameters + ---------- + interactive : bool, optional + Whether OAuth authentication may open a browser. + """ """main Main Testing Driver """ diff --git a/examples/slack_example.py b/examples/slack_example.py index 9c81725..2905d9f 100644 --- a/examples/slack_example.py +++ b/examples/slack_example.py @@ -1,7 +1,4 @@ -""" Example for using the Slack module - -_extended_summary_ -""" +"""Demonstrate the Slack channel API.""" import os @@ -9,7 +6,8 @@ from johnnyfive import utils # Main Testing Driver ========================================================# -def main(): +def main() -> None: + """Demonstrate sending a Slack message and uploading a file.""" """main Main Testing Driver """ slack_object = j5s.SlackChannel('bot_test') diff --git a/johnnyfive/__init__.py b/johnnyfive/__init__.py index 2124670..08a3415 100644 --- a/johnnyfive/__init__.py +++ b/johnnyfive/__init__.py @@ -1,19 +1,18 @@ # -*- coding: utf-8 -*- # -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 # # Created on 07-Mar-2022 # # @author: tbowers -"""Init File -""" +"""Init File""" # Imports for signal and log handling import os +from types import TracebackType +from typing import IO, Type import warnings __all__ = ["ConfluencePage", "GmailMessage", "GetMessages", "SlackChannel"] @@ -25,9 +24,35 @@ from .utils import * # noqa -def short_warning(message, category, filename, lineno, file=None, line=None): - """ - Return the format for a short warning message. +def short_warning( + message: Warning | str, + category: Type[Warning], + filename: str, + lineno: int, + file: IO[str] | None = None, + line: str | None = None, +) -> str: + """Format a warning as a concise single line. + + Parameters + ---------- + message : Warning | str + Warning text or warning instance. + category : type[Warning] + Warning category. + filename : str + Source filename. + lineno : int + Source line number. + file : IO[str] | None, optional + Unused output stream accepted for the warnings hook protocol. + line : str | None, optional + Unused source line accepted for the warnings hook protocol. + + Returns + ------- + str + Formatted warning line. """ return f" {category.__name__}: {message} ({os.path.split(filename)[1]}:{lineno})\n" diff --git a/johnnyfive/classes.py b/johnnyfive/classes.py index 11f1e94..092341f 100644 --- a/johnnyfive/classes.py +++ b/johnnyfive/classes.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 # # Created on 25 Feb 2020 # @@ -16,8 +14,19 @@ from __future__ import division, print_function, absolute_import -class emailSNMP(object): - def __init__(self): +class emailSNMP: + """Store SMTP connection and message configuration. + + Attributes + ---------- + host : str | None + SMTP server host name. + port : int + SMTP server port. + """ + + def __init__(self) -> None: + """Initialize an SMTP configuration with safe defaults.""" self.host = None self.port = 465 self.user = None diff --git a/johnnyfive/confluence.py b/johnnyfive/confluence.py index 89f89c2..3893922 100644 --- a/johnnyfive/confluence.py +++ b/johnnyfive/confluence.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 # # Created on 23-Sep-2021 # @@ -15,14 +13,15 @@ """ # Built-In Libraries -import warnings +import logging +from typing import Any # 3rd Party Libraries -from atlassian import Confluence +from atlassian.confluence import ConfluenceServer import requests # Internal Imports -from johnnyfive import utils +import johnnyfive.utils # Set API Components @@ -32,35 +31,63 @@ class ConfluencePage: """ConfluencePage Class for a single Confluence Page - _extended_summary_ + Provides permission-aware operations for one Confluence page. Parameters ---------- - space : `str` + space : :obj:`str` The name of the Confluence space for this page - page_title : `str` + page_title : :obj:`str` The page title - instance : ``, optional - An existing Confluence influence, to be used in the case of many - instances of this class used in short order [Default: None] + instance : :class:`~atlassian.confluence.ConfluenceServer`, optional + An existing Confluence object instance to be used instead of + reinstantiating a new Confluence object for communication and + authentication. [Default: None] + use_oauth : :obj:`bool`, optional + Use OAUTH authentication instead of username/password? [Default: False] + logger : :obj:`~logging.Logger`, optional + The logger object for logging [Default: None] """ - def __init__(self, space, page_title, instance=None, use_oauth=False): + def __init__( + self, + space: str, + page_title: str, + instance: ConfluenceServer | None = None, + use_oauth: bool = False, + logger: logging.Logger | None = None, + ) -> None: + """Initialize a page wrapper and fetch its metadata. + + Parameters + ---------- + space : str + Confluence space key. + page_title : str + Page title within ``space``. + instance : ConfluenceServer | None, optional + Existing authenticated client. + use_oauth : bool, optional + Whether to create a bearer-token client. + logger : logging.Logger | None, optional + Logger used for service errors. + """ + # Initialize instance variables self.space = space self.title = page_title - self.instance = ( - setup_confluence(use_oauth=use_oauth) - if not isinstance(instance, Confluence) - else instance + self.logger = logger + + # Set up the Confluence object instance + self.confluence = ( + setup_confluence(use_oauth=use_oauth) if instance is None else instance ) - self.uname = self.instance.username self.space_perms = self._set_permdict() # Set the class metadata based on this page self._set_metadata() - def add_comment(self, comment): - """add_comment Add a comment to the Confluence page + def add_comment(self, comment: str) -> None: + """Add a comment to the Confluence page Sometimes it's helpful to include a comment at the bottom of the Confluence page. These will be signed by Nanni. This method adds @@ -68,150 +95,178 @@ def add_comment(self, comment): Parameters ---------- - comment : `str` + comment : :obj:`str` The comment to be left on the page. """ if not self._check_perm("COMMENT", "add a comment"): return - utils.safe_service_connect(self.instance.add_comment, self.page_id, comment) + johnnyfive.utils.safe_service_connect( + self.confluence.add_comment, self.page_id, comment, logger=self.logger + ) - def add_label(self, label): - """add_label Add a label to the Confluence page + def add_label(self, label: str) -> None: + """Add a label to the Confluence page Sometimes it's helpful to have a label on a Confluence page for searching or sorting. This method adds such to a page. Parameters ---------- - label : `str` + label : :obj:`str` The label to be added to the page """ if not self._check_perm("EDITSPACE", "add a label"): return - utils.safe_service_connect(self.instance.set_page_label, self.page_id, label) + johnnyfive.utils.safe_service_connect( + self.confluence.set_page_label, self.page_id, label, logger=self.logger + ) - def attach_file(self, filename, name=None, content_type=None, comment=None): - """attach_file Attach a file to this page + def attach_file( + self, + filename: str, + name: str | None = None, + content_type: str | None = None, + comment: str | None = None, + ) -> None: + """Attach a file to this page Wrapper for the Confluence method attach_file() that includes the page ID of this object and is wrapped in utils.safe_service_connect(). Parameters ---------- - filename : `str` + filename : :obj:`str` Filename of the attachment - name : `str`, optional + name : :obj:`str`, optional Display name for this attachment [Default: None] - content_type : `str`, optional + content_type : :obj:`str`, optional MIME content type [Default: None] - comment : `str`, optional + comment : :obj:`str`, optional Additional comment or description to be included [Default: None] """ if not self._check_perm("CREATEATTACHMENT", "create an attachment"): return - utils.safe_service_connect( - self.instance.attach_file, + johnnyfive.utils.safe_service_connect( + self.confluence.attach_file, filename, name=name, content_type=content_type, page_id=self.page_id, comment=comment, + logger=self.logger, ) - def create(self, page_body, parent_id=None): - """create Create a brand new Confluence page + def create( + self, page_body: str, parent_id: str | None = None, representation: str = "wiki" + ) -> None: + """Create a brand new Confluence page Summon from the depths of computing a new page. Parameters ---------- - page_body : `str` + page_body : :obj:`str` The body of the new Confluence page. - parent_id : `str`, optional + parent_id : :obj:`str`, optional The parent page to place this under. If none given, the new page - will be created at the root of `self.space`. [Default: None] + will be created at the root of ``self.space``. [Default: None] + representation : :obj:`str`, optional + The Confluence strorage representation to use. [Default: "wiki"] + Use "storage" for XML-based documents """ if not self._check_perm("EDITSPACE", "create a page"): return # Check if it exists before we try anything if self.exists: - print("Can't create a page that already exists!") + johnnyfive.utils.proper_print( + "Can't create a page that already exists!", "info", self.logger + ) return - utils.safe_service_connect( - self.instance.create_page, + johnnyfive.utils.safe_service_connect( + self.confluence.create_page, self.space, self.title, page_body, parent_id=parent_id, - representation="wiki", + representation=representation, editor="v1", + logger=self.logger, ) # Set the instance metadata (exists, page_id, etc.) self._set_metadata() - def delete_attachment(self, filename): - """delete_attachment Delete an attachment from this page + def delete_attachment(self, filename: str) -> None: + """Delete an attachment from this page Wrapper for the Confluence method delete_attachment() that includes the page ID of this object and is wrapped in utils.safe_service_connect(). An attahment may be deleted using either the filename, or by passing - `None` or "" to the filename and specifying the attachment_id. + ``None`` or "" to the filename and specifying the attachment_id. Parameters ---------- - filename : `str` + filename : :obj:`str` Filename of the attachment to delete """ if not self._check_perm("REMOVEATTACHMENT", "remove an attachment"): return - utils.safe_service_connect( - self.instance.delete_attachment, self.page_id, filename + johnnyfive.utils.safe_service_connect( + self.confluence.delete_attachment, + self.page_id, + filename, + logger=self.logger, ) - def get_page_attachments(self, limit=200): - """get_page_attachments _summary_ + def get_page_attachments(self, limit: int = 200) -> list[object]: + """Retrieve the page attachments - Return a list of page attachment IDs, up to `limit` in length. + Return a list of page attachment IDs, up to ``limit`` in length. Parameters ---------- - limit : `int`, optional - The number of attachments to return [Default: 200] + limit : :obj:`int`, optional + The number of attachments to return (Default: 200) Returns ------- - `list` + :obj:`list` List of Confluence attachment IDs """ - return utils.safe_service_connect( - self.instance.get_attachments_from_content, self.page_id, limit=limit + return johnnyfive.utils.safe_service_connect( + self.confluence.get_attachments_from_content, + self.page_id, + limit=limit, + logger=self.logger, ) - def get_page_contents(self): - """get_page_contents Retrieve the page contents in HTML-ish format + def get_page_contents(self) -> str: + """Retrieve the page contents in HTML-ish format Either for curiosity or for modification, get the page contents, which live in the `body.storage` portion of the `get_page_by_id` response. Returns ------- - `str` + :obj:`str` The HTML-ish body of the confluence page. """ - contents = utils.safe_service_connect( - self.instance.get_page_by_id, self.page_id, expand="body.storage" + contents = johnnyfive.utils.safe_service_connect( + self.confluence.get_page_by_id, + self.page_id, + expand="body.storage", + logger=self.logger, ) # Extract the contents from the return object return contents["body"]["storage"]["value"] - def smite(self): + def smite(self) -> None: """smite Kill with extreme prejudice Remove the Confluence page and update the instance metadata to reflect @@ -220,37 +275,38 @@ def smite(self): if not self._check_perm("REMOVEPAGE", "remove a page"): return - utils.safe_service_connect(self.instance.remove_page, self.page_id) + johnnyfive.utils.safe_service_connect( + self.confluence.remove_page, self.page_id, logger=self.logger + ) self._set_metadata() - def update_contents(self, body): - """update_contents Update the contents of the Confluence page + def update_contents(self, body: str) -> None: + """Update the contents of the Confluence page Update the page by replacing the existing content with new. The idea - for this method is to be used in concert with `get_page_contents` to - obtain a page, modify it, then replace it. + for this method is to be used in concert with :func:``get_page_contents`` + to obtain a page, modify it, then replace it. Parameters ---------- - body : `str` + body : :obj:`str` The new page contents to upload to Confluence. - - Returns - ------- - _type_ - _description_ """ if not self._check_perm("EDITSPACE", "update a page"): return - utils.safe_service_connect( - self.instance.update_page, self.page_id, self.title, body + johnnyfive.utils.safe_service_connect( + self.confluence.update_page, + self.page_id, + self.title, + body, + logger=self.logger, ) - def _check_perm(self, perm_key, perm_action): - """_check_perm Check the perm_dict for a particular action + def _check_perm(self, perm_key: str, perm_action: str) -> bool: + """Check the premissions dictionary for a particular action - Check the `perm_ley` in the permissions dictionary to see whether the + Check the ``perm_key`` in the permissions dictionary to see whether the requested action is permitted. The wrinkle is that if the user does not have permission to see @@ -261,124 +317,106 @@ def _check_perm(self, perm_key, perm_action): Parameters ---------- - perm_key : `str` + perm_key : :obj:`str` The key in perm_dict to look for - perm_action : `str` + perm_action : :obj:`str` The action that is requested by the calling function. Returns ------- - `bool` + :obj:`bool` True for perform action, False for not """ perm_val = self.space_perms.get(perm_key, None) # If the value is explicitely False, warn as such if perm_val is False: - warnings.warn( - f"User {self.uname} does not have permission " + johnnyfive.utils.proper_print( + f"User {self.confluence.username} does not have permission " f"to {perm_action} in space {self.space}.", - utils.PermissionWarning, + "warn", + self.logger, ) return False - # If value is None, no permission check was performed, proceed + # If value is None, permission preflight is disabled; let the REST + # operation itself enforce the authenticated user's permissions. if perm_val is None: - warnings.warn( - "Permissions check is disabled... hoping for the best.", - utils.PermissionWarning, - ) + return True return True - def _set_metadata(self): - """_set_metadata Set the various instance metadata + def _set_metadata(self) -> None: + """Set the various instance metadata Especially after a page is created or deleted, this method updates the various instance attributes to keep current. """ - self.exists = utils.safe_service_connect( - self.instance.page_exists, self.space, self.title + self.exists = johnnyfive.utils.safe_service_connect( + self.confluence.page_exists, self.space, self.title, logger=self.logger ) # Page-Specific Information self.page_id = ( None if not self.exists - else utils.safe_service_connect( - self.instance.get_page_id, self.space, self.title + else johnnyfive.utils.safe_service_connect( + self.confluence.get_page_id, self.space, self.title, logger=self.logger ) ) self.attachment_url = ( None if not self.exists - else f"{self.instance.url}download/attachments/{self.page_id}/" + else f"{self.confluence.url}download/attachments/{self.page_id}/" ) - def _set_permdict(self): - """_set_permdict Create a dictionary of permissions + def _set_permdict(self) -> dict[str, bool]: + """Disable permission enumeration for REST-based automation clients. - This method creates a dictionary of permissions for this user in this - space. Each item in the dictionary is boolean based on the results of - the method confluence.get_space_permissions(). + Current Confluence REST deployments can require space-administrator + privileges to enumerate all permissions. J5 only needs the narrower + privileges for each page operation, so it lets those REST operations + perform the authoritative authorization check instead. Returns ------- - `dict` - The dictionary of permissions (boolean) + dict[str, bool] + Empty map indicating that permission preflight is disabled. """ - perms = utils.safe_service_connect( - self.instance.get_space_permissions, self.space - ) - - # Check to see if the authenticated user can view permissions - if not perms: - warnings.warn( - f"User {self.uname} needs permission to view " - f"permissions in space {self.space}. Contact " - "your Confluence administrator.", - utils.PermissionWarning, - ) - - perm_dict = {} - for perm in perms: - # Set this permission as false... will update to True if needed - perm_dict[perm["type"]] = False - for space_perm in perm["spacePermissions"]: - if space_perm["userName"] == self.uname: - perm_dict[perm["type"]] = True - - return perm_dict + return {} # Internal Functions =========================================================# -def setup_confluence(use_oauth=False): - """setup_confluence Set up the Confluence class instance +def setup_confluence(use_oauth: bool = False) -> ConfluenceServer: + """Set up the Confluence class instance Reads in the confluence.conf configuration file, which contains the URL, username, and password (and/or OAUTH info). - NOTE: For Confluence install version >= 7.9, can use OAUTH for - authentication instead of username/password. + .. note:: + For Confluence install version >= 7.9, can use OAUTH for + authentication instead of username/password. Parameters ---------- - use_oauth : `bool`, optional + use_oauth : :obj:`bool`, optional Use the OAUTH authentication scheme? [Default: False] Returns ------- - confluence : `atlassian.Confluence` + confluence : :class:`~atlassian.confluence.ConfluenceServer` Confluence class, initialized with credentials """ # Read the setup - setup = utils.read_ligmos_conffiles("confluenceSetup") + setup = johnnyfive.utils.read_config_section("confluenceSetup") - # If we are using OAUTH, instantiate a Confluence object with it + # If we are using OAuth, instantiate a Server client with its bearer token. if use_oauth: - s = requests.Session() - s.headers["Authorization"] = f"Bearer {setup.access_token}" - return Confluence(url=setup.host, session=s) - - # Else, return a Confluence object instantiated with username/password - return Confluence(url=setup.host, username=setup.user, password=setup.password) + session = requests.Session() + session.headers["Authorization"] = f"Bearer {setup.access_token}" + return ConfluenceServer(url=setup.host, session=session) + + # Otherwise, return a Server client instantiated with username/password. + return ConfluenceServer( + url=setup.host, username=setup.user, password=setup.password + ) diff --git a/johnnyfive/gmail.py b/johnnyfive/gmail.py index 89dbb2a..38a1f17 100644 --- a/johnnyfive/gmail.py +++ b/johnnyfive/gmail.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 # # Created on 15 Feb 2022 # @@ -18,22 +16,30 @@ # Built-In Libraries import base64 -from email import mime +import email.mime.audio +import email.mime.base +import email.mime.image +import email.mime.multipart +import email.mime.text +import json +import logging import mimetypes import os -import warnings +from collections.abc import Iterator, Mapping +from pathlib import Path +from typing import Any # 3rd Party Libraries from bs4 import BeautifulSoup -from googleapiclient.discovery import build -from googleapiclient.errors import UnknownApiNameOrVersion -from googleapiclient.errors import HttpError -from google_auth_oauthlib.flow import InstalledAppFlow -from google.auth.transport.requests import Request -from google.oauth2.credentials import Credentials +import googleapiclient.discovery +import googleapiclient.errors +import google_auth_oauthlib.flow +import google.auth.exceptions +import google.auth.transport.requests +import google.oauth2.credentials # Internal Imports -from johnnyfive import utils +import johnnyfive.utils # This scope is for sending email using the OAuth2 library @@ -45,58 +51,85 @@ class GmailMessage: - """GmailMessage Class for a single Gmail Message + """Class for a single Gmail Message - _extended_summary_ + Builds MIME messages and sends them through an authenticated Gmail service. Parameters ---------- - toaddr : `str` or `list` + toaddr : :obj:`str` or :obj:`list` The intended recipient(s) of the email message - subject : `str` + subject : :obj:`str` The subject of the email message - message_text : `str` + message_text : :obj:`str` The body text of the email message (as a single string with optional newlines.) - fromname : `str`, optional + fromname : :obj:`str`, optional Display Name of the sender (i.e. which bot) [Default: None] - fromaddr : `str`, optional + fromaddr : :obj:`str`, optional Sender email address [Default: Value from [gmailSetup]] + interactive : :obj:`bool`, optional + Whether to run this in interactive mode (Default: False) + logger : :obj:`~logging.Logger`, optional + The logger object for logging [Default: None] """ def __init__( self, - toaddr, - subject, - message_text, - fromname=None, - fromaddr=None, - interactive=False, - ): + toaddr: str | list[str], + subject: str, + message_text: str, + fromname: str | None = None, + fromaddr: str | None = None, + interactive: bool = False, + logger: logging.Logger | None = None, + ) -> None: + """Build a Gmail MIME message and initialize its API service. + + Parameters + ---------- + toaddr : str | list[str] + Recipient address or addresses. + subject : str + Email subject. + message_text : str + Plain-text body. + fromname : str | None, optional + Sender display name. + fromaddr : str | None, optional + Sender address; defaults to the J5 configuration value. + interactive : bool, optional + Whether OAuth authorization may open a browser. + logger : logging.Logger | None, optional + Logger used for service errors. + """ + # Set the logger, if passed + self.logger = logger + # Load default `fromaddr`` if None passed in if not fromaddr: - fromaddr = utils.read_ligmos_conffiles("gmailSetup").user + fromaddr = johnnyfive.utils.read_config_section("gmailSetup").user # Initialize the Gmail connection - self.service = setup_gmail(interactive=interactive) + self.service = setup_gmail(interactive=interactive, logger=self.logger) # Build the container for a multipart MIME message - self.message = mime.multipart.MIMEMultipart() + self.message = email.mime.multipart.MIMEMultipart() self.message["to"] = toaddr if isinstance(toaddr, str) else ",".join(toaddr) self.message["from"] = f"{fromname} <{fromaddr}>" if fromname else fromaddr self.message["subject"] = subject # Place the text into the message - self.message.attach(mime.text.MIMEText(message_text)) + self.message.attach(email.mime.text.MIMEText(message_text)) - def add_attachment(self, file): - """add_attachment _summary_ + def add_attachment(self, file: str | Path) -> None: + """Add an attachment to the GMAIL message - _extended_summary_ + The attachment MIME type is inferred from its filename. Parameters ---------- - file : `str` + file : :obj:`str` Filename of the attachment """ # For the attachment, guess the MIME type for reading it in @@ -109,18 +142,18 @@ def add_attachment(self, file): # Case out the content type main_type, sub_type = content_type.split("/", 1) if main_type == "text": - with open(file, "rb") as fp: - attachment = mime.text.MIMEText(fp.read(), _subtype=sub_type) + with open(file, encoding="utf-8") as f_obj: + attachment = email.mime.text.MIMEText(f_obj.read(), _subtype=sub_type) elif main_type == "image": - with open(file, "rb") as fp: - attachment = mime.image.MIMEImage(fp.read(), _subtype=sub_type) + with open(file, "rb") as f_obj: + attachment = email.mime.image.MIMEImage(f_obj.read(), _subtype=sub_type) elif main_type == "audio": - with open(file, "rb") as fp: - attachment = mime.audio.MIMEAudio(fp.read(), _subtype=sub_type) + with open(file, "rb") as f_obj: + attachment = email.mime.audio.MIMEAudio(f_obj.read(), _subtype=sub_type) else: - with open(file, "rb") as fp: - attachment = mime.base.MIMEBase(main_type, sub_type) - attachment.set_payload(fp.read()) + with open(file, "rb") as f_obj: + attachment = email.mime.base.MIMEBase(main_type, sub_type) + attachment.set_payload(f_obj.read()) # Add the attachment to the email message attachment.add_header( @@ -128,19 +161,14 @@ def add_attachment(self, file): ) self.message.attach(attachment) - def send(self): - """send Send the GmailMessage + def send(self) -> dict[str, Any]: + """Send the GmailMessage - _extended_summary_ + Encodes the MIME message and sends it through Gmail's API. - Parameters - ---------- - n_tries : `int`, optional - The number of retry attemps at sending this message [Default: 5] - - Returns + Returns ------- - `dict` + :obj:`dict` The sent message object """ # Take the message object, and 64-bit encode it properly for sending @@ -150,52 +178,92 @@ def send(self): # If Gmail `Resource` was not returned earlier, try again if not self.service: - self.service = setup_gmail() + self.service = setup_gmail(logger=self.logger) # Try to send the message (API: users.messages.send) try: - return utils.safe_service_connect( + return johnnyfive.utils.safe_service_connect( self.service.users() .messages() .send(userId="me", body=sendable_message) - .execute + .execute, + logger=self.logger, ) - except (HttpError, ConnectionError) as error: - warnings.warn(f"An error occurred within GmailMessage.send():\n{error}") - return None + except (googleapiclient.errors.HttpError, ConnectionError) as error: + johnnyfive.utils.proper_print( + f"An error occurred within GmailMessage.send(): {error}", + "except", + self.logger, + ) + raise johnnyfive.utils.J5Error from error class GetMessages: - """GetMessages Get Gmail messages corresponding to given criteria + """Get Gmail messages corresponding to given criteria - _extended_summary_ + Queries Gmail messages and exposes helpers for rendering and relabeling them. Parameters ---------- - label : `str`, optional + label : :obj:`str`, optional The Gmail label of messages to find [Default: None] - after : `str`, optional + after : :obj:`str`, optional Date after which to search for messages. Must be in YYYY/MM/DD format. - [Default: None] - before : `str`, optional + (Default: None) + before : :obj:`str`, optional Date before which to search for messages. Must be in YYYY/MM/DD format. - [Default: None] + (Default: None) + interactive : :obj:`bool`, optional + Whether to run this in interactive mode (Default: False) + logger : :obj:`logging.Logger`, optional + The logger object for logging [Default: None] """ - def __init__(self, label=None, after=None, before=None, interactive=False): + def __init__( + self, + label: str | None = None, + after: str | None = None, + before: str | None = None, + interactive: bool = False, + logger: logging.Logger | None = None, + ) -> None: + """Connect to Gmail and collect messages matching search criteria. + + Parameters + ---------- + label : str | None, optional + Gmail label name used to filter messages. + after : str | None, optional + Inclusive lower date bound in ``YYYY/MM/DD`` form. + before : str | None, optional + Exclusive upper date bound in ``YYYY/MM/DD`` form. + interactive : bool, optional + Whether OAuth authorization may open a browser. + logger : logging.Logger | None, optional + Logger used for service errors. + """ # Initialize basic stuff self.label_list = None self.message_list = [] + self.logger = logger # Initialize the Gmail connection - self.service = setup_gmail(interactive=interactive) - self.label_id = self._lableId_from_labelName(label) - self.query = build_query(after_date=after, before_date=before) + self.service = setup_gmail(interactive=interactive, logger=self.logger) + + # If we cannot connect to GMail, return here with an empty message_list + if self.service is None: + johnnyfive.utils.proper_print( + "Cannot connect to GMail!", "error", self.logger + ) + return + + self.label_id = self._label_id_from_name(label) + self.query = self.build_query(after_date=after, before_date=before) # Get the list of matching messages (API: users.messages.list) if self.label_id: try: - results = utils.safe_service_connect( + results = johnnyfive.utils.safe_service_connect( self.service.users() .messages() .list( @@ -204,103 +272,160 @@ def __init__(self, label=None, after=None, before=None, interactive=False): q=self.query, maxResults=500, ) - .execute + .execute, + logger=self.logger, ) self.message_list = results.get("messages", []) - except (HttpError, ConnectionError) as error: - warnings.warn( - f"An error occurred within GetMessages.__init__():\n{error}" + except (googleapiclient.errors.HttpError, ConnectionError) as error: + johnnyfive.utils.proper_print( + f"An error occurred within GetMessages.__init__(): {error}", + "except", + self.logger, ) - def render_message(self, message_id): - """render_message Retrieve and render a message by ID# + def render_message(self, message_id: str) -> dict[str, str]: + """Retrieve and render a message by ID# Gmail mnessages are stored in a JSON-like structure that must be parsed out to get the tasty nougat center. Parameters ---------- - message_id : `str` - The ['id'] field of an entry in self.message_list + message_id : :obj:`str` + The ``['id']`` field of an entry in self.message_list Returns ------- - `dict` + :obj:`dict` Dictionary containing the subject, sender, date, and body of the message. """ + payload: Mapping[str, Any] | None = None try: # Get the message, then start parsing (API: users.messages.get) - results = utils.safe_service_connect( - self.service.users().messages().get(userId="me", id=message_id).execute + results = johnnyfive.utils.safe_service_connect( + self.service.users() + .messages() + .get(userId="me", id=message_id, format="full") + .execute, + logger=self.logger, ) - payload = results["payload"] - headers = payload["headers"] + payload = results.get("payload", {}) # If exception, print message and return empty values - except (HttpError, ConnectionError) as error: - warnings.warn( - f"An error occurred within GetMessages.render_message():\n{error}" + except (googleapiclient.errors.HttpError, ConnectionError) as error: + johnnyfive.utils.proper_print( + f"An error occurred within GetMessages.render_message(): {error}", + "except", + self.logger, ) - payload = None - # Return empty dictionary if unsuccessful in connecting if not payload: - return dict(subject="", sender="", date="", body="") - - # Look for Subject and Sender Email in the headers - for d in headers: - if d["name"] == "Subject": - subject = d["value"] - if d["name"] == "From": - sender = d["value"] - if d["name"] == "Date": - date = d["value"] - - # The Body of the message is in Encrypted format -- decode it. - # Get the data and decode it with base 64 decoder. - data = payload["body"]["data"] - data = data.replace("-", "+").replace("_", "/") - decoded_data = base64.b64decode(data) - - # `decoded_data` is in lxml format; parse with BeautifulSoup - body = BeautifulSoup(decoded_data, "lxml").body() - body = body[0].text + return {"subject": "", "sender": "", "date": "", "body": ""} + + headers = { + str(header.get("name", "")).lower(): str(header.get("value", "")) + for header in payload.get("headers", []) + if isinstance(header, Mapping) + } # Return a dictionary with the plain-text components of this message - return dict(subject=subject, sender=sender, date=date, body=body) + return { + "subject": headers.get("subject", ""), + "sender": headers.get("from", ""), + "date": headers.get("date", ""), + "body": self._extract_message_body(payload), + } - def update_msg_labels(self, message_id, add_labels=None, remove_labels=None): - """update_msg_labels Update the labels for a message by ID# + @staticmethod + def _iter_message_parts(part: Mapping[str, Any]) -> Iterator[Mapping[str, Any]]: + """Yield a MIME part and all of its nested child parts. - _extended_summary_ + Parameters + ---------- + part : Mapping[str, Any] + Gmail ``MessagePart`` object to traverse. + + Yields + ------ + Mapping[str, Any] + Each MIME part in depth-first order. + """ + yield part + for child in part.get("parts", []): + if isinstance(child, Mapping): + yield from GetMessages._iter_message_parts(child) + + @classmethod + def _extract_message_body(cls, payload: Mapping[str, Any]) -> str: + """Extract readable inline text from a Gmail MIME payload. + + Plain text is preferred when both ``text/plain`` and ``text/html`` + alternatives are present. Container and attachment parts without + inline ``body.data`` are ignored. Parameters ---------- - message_id : `str` - The ['id'] field of an entry in self.message_list - add_labels : `list`, optional - The list of label IDs to add to this message [Default: None] - remove_labels : `list`, optional - The list of label IDs to remove from this message [Default: None] + payload : Mapping[str, Any] + Top-level Gmail ``MessagePart`` payload. Returns ------- - `Any` - Uh, the Message object from Gmail... probably just return nothing? + str + Decoded message text, or an empty string when no readable inline + text part exists. + """ + parts = list(cls._iter_message_parts(payload)) + for mime_type in ("text/plain", "text/html"): + for part in parts: + if part.get("mimeType", "").lower() != mime_type: + continue + body = part.get("body", {}) + data = body.get("data") if isinstance(body, Mapping) else None + if not isinstance(data, str) or not data: + continue + + padded_data = data + "=" * (-len(data) % 4) + decoded = base64.urlsafe_b64decode(padded_data).decode( + "utf-8", errors="replace" + ) + if mime_type == "text/plain": + return decoded + return BeautifulSoup(decoded, "lxml").get_text(separator="\n", strip=True) + + return "" + + def update_msg_labels( + self, + message_id: str, + add_labels: list[str] | None = None, + remove_labels: list[str] | None = None, + ) -> dict[str, Any]: + """Update the labels for a message by ID# + + Label names are resolved to Gmail label IDs before the update. + + Parameters + ---------- + message_id : :obj:`str` + The ``['id']`` field of an entry in self.message_list + add_labels : :obj:`list`, optional + The list of label IDs to add to this message [Default: None] + remove_labels : :obj:`list`, optional + The list of label IDs to remove from this message [Default: None] + """ if not add_labels and not remove_labels: - print("No labels to change.") - return None + johnnyfive.utils.proper_print("No labels to change.", "info", self.logger) # Convert Label Names to Label IDs add_label_ids, remove_label_ids = [], [] if add_labels: for label in add_labels: - add_label_ids.append(self._lableId_from_labelName(label)) + add_label_ids.append(self._label_id_from_name(label)) if remove_labels: for label in remove_labels: - remove_label_ids.append(self._lableId_from_labelName(label)) + remove_label_ids.append(self._label_id_from_name(label)) # Build the label dictionary to send to Gmail body = {} @@ -311,33 +436,36 @@ def update_msg_labels(self, message_id, add_labels=None, remove_labels=None): try: # Modify message lables (API: users.messages.modify) - return utils.safe_service_connect( + return johnnyfive.utils.safe_service_connect( self.service.users() .messages() .modify(userId="me", id=message_id, body=body) - .execute + .execute, + logger=self.logger, ) # If exception, print message - except (HttpError, ConnectionError) as error: - warnings.warn( - f"An error occurred within GetMessages.update_msg_labels():\n{error}" + except (googleapiclient.errors.HttpError, ConnectionError) as error: + johnnyfive.utils.proper_print( + f"An error occurred within GetMessages.update_msg_labels(): {error}", + "except", + self.logger, ) - # If unsuccessful in connecting, return None - return None + # If unsuccessful in connecting, raise + raise johnnyfive.utils.J5Error("Unsuccessful connection") - def _lableId_from_labelName(self, name): - """_lableId_from_labelName Get the Label ID from the Label Name + def _label_id_from_name(self, name: str | None) -> str | None: + """Get the Label ID from the Label Name - _extended_summary_ + The label list is retrieved once and cached for the instance. Parameters ---------- - name : `str` + name : :obj:`str` Label name Returns ------- - `str` + :obj:`str` Label ID """ if not self.service: @@ -347,19 +475,24 @@ def _lableId_from_labelName(self, name): if not self.label_list: # Get the list of labels for the "me" account (API: users.labels.list) try: - results = utils.safe_service_connect( - self.service.users().labels().list(userId="me").execute + results = johnnyfive.utils.safe_service_connect( + self.service.users().labels().list(userId="me").execute, + logger=self.logger, ) self.label_list = results.get("labels", []) - except (HttpError, ConnectionError) as error: - warnings.warn( - f"An error occurred within GetMessages._labelId_from_labelName():\n{error}" + except (googleapiclient.errors.HttpError, ConnectionError) as error: + johnnyfive.utils.proper_print( + f"An error occurred within GetMessages._labelId_from_labelName(): {error}", + "except", + self.logger, ) self.label_list = [] # If there are no labels, return None if not self.label_list: - print("Whoops, no labels found.") + johnnyfive.utils.proper_print( + "Whoops, no labels found.", "warn", self.logger + ) return None label_id = None @@ -370,93 +503,154 @@ def _lableId_from_labelName(self, name): return label_id + @staticmethod + def build_query( + after_date: str | None = None, before_date: str | None = None + ) -> str: + """build_query Build the query string for users.messages.list + + Date filters are formatted for Gmail's message-list query syntax. + + Parameters + ---------- + after_date : :obj:`str` + Date after which to search for messages. + before_date : :obj:`str` + Date before which to search for messages. + + Returns + ------- + :obj:`str` + The appropriate query string + """ + query = "" + if after_date: + query = query + f" after:{after_date}" + if before_date: + query = query + f" before:{before_date}" + return query + # Newer OAUTH Routines =======================================================# -def setup_gmail(interactive=False): - """setup_gmail Initialize the GMail API (via OAuth) +def setup_gmail( + interactive: bool = False, logger: logging.Logger | None = None +) -> googleapiclient.discovery.Resource: + """Initialize the GMail API (via OAuth) - [extended_summary] + Creates or refreshes the OAuth credentials used by the Gmail API. NOTE: The first time this is run on a machine, it will open a webpage for authorizing the API. All subsequent runs will be silent. Parameters ---------- - interactive : `bool`, optional + interactive : :obj:`bool`, optional Is this session interactive? Relates to how to deal with toke refresh. [Default: False] + logger : :obj:`~logging.Logger`, optional + The logger object for logging (Default: None) Returns ------- - `googleapiclient.discovery.Resource` + :obj:`~googleapiclient.discovery.Resource` The GMail API service object for consumption by other routines """ # Read in the credential token creds = None - if os.path.exists(token_fn := utils.Paths.gmail_token): - creds = Credentials.from_authorized_user_file(token_fn, SCOPES) + if os.path.exists(token_fn := johnnyfive.utils.Paths.gmail_token): + try: + creds = google.oauth2.credentials.Credentials.from_authorized_user_file( + token_fn, SCOPES + ) + except json.decoder.JSONDecodeError as err: + raise johnnyfive.utils.J5Error( + f"Cannot parse Gmail token in {token_fn}" + ) from err # If there are no (valid) credentials available... if not creds or not creds.valid: - # If just expired, refresh and move on if creds and creds.expired and creds.refresh_token: try: - utils.safe_service_connect(creds.refresh, Request()) - except (HttpError, ConnectionError) as error: - warnings.warn(f"An error occurred within setup_gmail():\n{error}") + johnnyfive.utils.safe_service_connect( + creds.refresh, + google.auth.transport.requests.Request(), + logger=logger, + ) + except (googleapiclient.errors.HttpError, ConnectionError) as err: + johnnyfive.utils.proper_print( + f"An error occurred within setup_gmail(): {err}", "warn", logger + ) + except google.auth.exceptions.RefreshError as err: + raise johnnyfive.utils.J5Error( + f"{type(err).__name__} {err}\n" + "https://stackoverflow.com/questions/10576386/invalid-grant-trying-to-get-oauth-token-from-google\n" + "Try running j5_authenticate_gmail" + ) from err # If running in `interactive`, lauch browser to log in elif interactive: - flow = InstalledAppFlow.from_client_secrets_file( - utils.Paths.gmail_creds, SCOPES + johnnyfive.utils.proper_print("If interactive...", "info", logger) + flow = google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file( + johnnyfive.utils.Paths.gmail_creds, SCOPES ) creds = flow.run_local_server(port=0) # Otherwise, raise an exception and specify to run interactively else: - raise ValueError( - "\nNo Gmail credentials found. You may need to run:\n" - "j5_install_conf\n" - "or to authenticate user, run (NOT in a container):\n" - "j5_authenticate_gmail" + errmsg = ( + "No Gmail credentials found. You may need to run:\n" + "\t`j5_install_conf`\n" + "\tor to authenticate user, run (NOT in a container):\n" + "\t`j5_authenticate_gmail`" + ) + johnnyfive.utils.proper_print( + errmsg, + "error", + logger, ) + raise johnnyfive.utils.J5Error(errmsg) # Save the credentials for the next run with open(token_fn, "w", encoding="utf-8") as token: token.write(creds.to_json()) - # Try building the GMail API service. If error, print error & return None + # Try building the GMail API service. If error, print error & raise try: # Call the Gmail API - return build("gmail", "v1", credentials=creds) - except (HttpError, UnknownApiNameOrVersion) as error: + johnnyfive.utils.proper_print("Calling the GMAIL API...", "info", logger) + return googleapiclient.discovery.build("gmail", "v1", credentials=creds) + except ( + googleapiclient.errors.HttpError, + googleapiclient.errors.UnknownApiNameOrVersion, + ) as err: # TODO(developer) - Handle errors from gmail API. - warnings.warn(f"An error occurred within setup_gmail():\n{error}") - return None + johnnyfive.utils.proper_print( + f"An error occurred within setup_gmail():\n{err}", "except", logger + ) + raise johnnyfive.utils.J5Error from err + +def authenticate_gmail(logger: logging.Logger | None = None) -> None: + """Console Script for authenticating Gmail -# Utility Functions ==========================================================# -def build_query(after_date=None, before_date=None): - """build_query Build the query string for users.messages.list + This is the command-line script for doing the interactive authentication + for Gmail needed to keep the tokens, etc. up to date. When/If there is + a ``RefreshError`` kicked by one of the classes in this module, this script + needs to be run on the command line `interactively` to remove the existing + token and re-authenticate the user via a web browser. - _extended_summary_ + Console script:: + + j5_authenticate_gmail Parameters ---------- - after_date : `str` - Date after which to search for messages. - before_date : `str` - Date before which to search for messages. - - Returns - ------- - `str` - The appropriate query string + logger : :obj:`~logging.Logger`, optional + The logger object for logging (Default: None) """ - q = "" - if after_date: - q = q + f" after:{after_date}" - if before_date: - q = q + f" before:{before_date}" - return q + johnnyfive.utils.proper_print("Authenticate GMail...", "info", logger) + # Remove the existing GMAIL TOKEN file, if extant... + johnnyfive.utils.Paths.gmail_token.unlink(missing_ok=True) + # Run setup + setup_gmail(interactive=True) diff --git a/johnnyfive/slack.py b/johnnyfive/slack.py index c23d4a0..b90b71c 100644 --- a/johnnyfive/slack.py +++ b/johnnyfive/slack.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 # # Created on 14-Feb-2022 # @@ -18,13 +16,16 @@ """ # Built-In Libraries +import pathlib +from typing import Any import warnings # 3rd Party Libraries import slack_sdk +import slack_sdk.errors # Internal Imports -from johnnyfive import utils +import johnnyfive.utils # Set API Components @@ -34,42 +35,49 @@ class SlackChannel: """SlackChannel Class for communicating with a Slack Channel - _extended_summary_ + Resolves a channel name and provides message and file operations. Parameters ---------- - channel_name : `str` + channel_name : :obj:`str` Slack Channel into which to post """ - def __init__(self, channel_name): + def __init__(self, channel_name: str) -> None: + """Initialize a channel client. + + Parameters + ---------- + channel_name : str + Human-readable Slack channel name. + """ self.client = setup_slack() # Get the channel ID self.channel_id = self._read_channels(channel_name) - def send_message(self, message): - """send_message Send a (text only) message to the channel + def send_message(self, message: str) -> Any: + """Send a (text only) message to the channel - _extended_summary_ + The Slack API response is returned unchanged. Parameters ---------- - message : `str` or `blocks[]` array + message : :obj:`str` or `blocks[]` array The message to send to the Slack channel Returns ------- - `Any` + :obj:`~typing.Any` The response from Slack """ response = None try: # Call the conversations.list method using the WebClient - response = utils.safe_service_connect( + response = johnnyfive.utils.safe_service_connect( self.client.chat_postMessage, channel=self.channel_id, - text=message + text=message, # You could also use a blocks[] array to send richer content ) # Print result, which includes information about the message (like TS) @@ -80,26 +88,26 @@ def send_message(self, message): ) return response - def upload_file(self, file, title=None): - """upload_file Upload a file to the channel + def upload_file(self, file: str | pathlib.Path, title: str | None = None) -> Any: + """Upload a file to the channel - _extended_summary_ + The Slack API response is returned unchanged. Parameters ---------- - file : `str` or `os.PathLike` + file : :obj:`str` or :obj:`~pathlib.Path` The (path and) filename of the file to be uploaded. - title : `str`, optional - The title for the file posted [Default: None] + title : :obj:`str`, optional + The title for the file posted (Default: None) Returns ------- - `Any` + :obj:`~typing.Any` The response from Slack """ response = None try: - response = utils.safe_service_connect( + response = johnnyfive.utils.safe_service_connect( self.client.files_upload, channels=self.channel_id, file=file, @@ -111,24 +119,26 @@ def upload_file(self, file, title=None): ) return response - def _read_channels(self, name): - """_read_channels Return the Channel ID for the names channel + def _read_channels(self, name: str) -> str | None: + """Return the Channel ID for the names channel Parameters ---------- - name : `str` + name : :obj:`str` The name of the channel Returns ------- - `str` + :obj:`str` The desired Channel ID """ conversation_id = None try: # Call the conversations.list() method using the WebClient - result = utils.safe_service_connect(self.client.conversations_list) + result = johnnyfive.utils.safe_service_connect( + self.client.conversations_list + ) for _ in result: if conversation_id is not None: break @@ -147,20 +157,18 @@ def _read_channels(self, name): # Internal Functions =========================================================# -def setup_slack(): - """setup_slack Setup the Slack WebClient for posting +def setup_slack() -> slack_sdk.web.client.WebClient | None: + """Setup the Slack WebClient for posting - _extended_summary_ + Reads the configured token and creates a client for Slack API calls. Returns ------- - client : `slack_sdk.web.client.WebClient` + client : :obj:`~slack_sdk.web.client.WebClient` The WebClient object needed for reading and writing - logger : `logging.Logger` - The logging thingie """ # Read the setup - setup = utils.read_ligmos_conffiles("slackSetup") + setup = johnnyfive.utils.read_config_section("slackSetup") # SlackWebClient instantiates a client that can call API methods # When using Bolt, you can use either `app.client` or the `client` passed to listeners. diff --git a/johnnyfive/utils.py b/johnnyfive/utils.py index d75ed39..9931efb 100644 --- a/johnnyfive/utils.py +++ b/johnnyfive/utils.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 # # Created on 14-Feb-2022 # @@ -18,57 +16,87 @@ # Built-In Libraries import argparse -import os +import configparser +import dataclasses +from importlib import resources +import logging +import pathlib import shutil import time +import typing import warnings # 3rd Party Libraries -from googleapiclient.errors import HttpError -from google.auth.exceptions import TransportError +import atlassian.errors +import google.auth.exceptions import httplib2 -from pkg_resources import resource_filename import requests -from slack_sdk.errors import SlackApiError - -# Lowell Libraries -import ligmos +import slack_sdk.errors # Internal Imports # Set API Components -__all__ = ["PermissionWarning", "print_dict", "safe_service_connect"] +__all__ = [ + "J5Error", + "print_dict", + "proper_print", + "read_config_section", + "safe_service_connect", +] + +# Define error classes +class J5Error(Exception): + """J5Error Class -class PermissionWarning(UserWarning): - """PermissionWarning - Subclass of UserWarning that is more specific to the case of permissions + Base JohnnyFive error class """ # Classes to hold useful information +@dataclasses.dataclass class Paths: """Paths - [extended_summary] + Centralizes paths to packaged configuration and image resources. """ # Main data & config directories - config = resource_filename("johnnyfive", "config") - images = resource_filename("johnnyfive", "images") - gmail_token = os.path.join(config, "gmail_token.json") - gmail_creds = os.path.join(config, "gmail_credentials.json") + config = resources.files("johnnyfive") / "config" + images = resources.files("johnnyfive") / "images" + gmail_token = config / "gmail_token.json" + gmail_creds = config / "gmail_credentials.json" + + +@dataclasses.dataclass +class baseTarget: + """ + Empty class that gets inherited by basically everything since it contains + most/all the usual stuff you'd need to connect to a ... thing. + """ + def __init__(self) -> None: + """Initialize a configuration target with common connection fields.""" + self.name = None + self.host = None + self.port = 22 + self.type = None + self.user = None + self.protocol = None + self.password = None + self.enabled = False -class authTarget(ligmos.utils.classes.baseTarget): - """authTarget Extension of LIGMOS baseTarget - Adds specified attributes used in JohnnyFive to silence LIGMOS's - "Setting orphan object key" messages +@dataclasses.dataclass +class authTarget(baseTarget): + """Configuration target with the credentials used by JohnnyFive. + + Additional values in the configuration section are retained as attributes. """ - def __init__(self): + def __init__(self) -> None: + """Initialize a configuration target with credential fields.""" super().__init__() self.access_token = None self.apiKey = None @@ -77,27 +105,95 @@ def __init__(self): self.tokenSecret = None -def authenticate_gmail(): - """authenticate_gmail Console Script for authenticating Gmail +def assignConf( + conf: configparser.SectionProxy, + obj: type[baseTarget], + backfill: bool = False, + debug: bool = False, +) -> baseTarget: + """Copy parsed configuration values to a target instance. + + Given an arbitrary class reference and a parsed configuration file (conf), + assign keys from the latter into parameters in the former. - This will be a command-line script for doing the interactive authentication - for Gmail needed to keep the tokens, etc. up to date. + Assumes that ALL keys in the class are present in the configuration; if + they aren't, then they're set to ```None``` and caught/announced in the + ```KeyError``` exception below. - TODO: Actually implement this function! + If 'backfill' is False, parameters that are in the *configuration file* + but not in the given class are *ignored* completely. If True, + they're added to the given class with a warning. + + Parameters + ---------- + conf : configparser.SectionProxy + Configuration section to convert. + obj : type[baseTarget] + Target class to instantiate. + backfill : bool, optional + Whether to retain keys not predefined by ``obj``. + debug : bool, optional + Whether to print missing predefined keys. + + Returns + ------- + baseTarget + Populated configuration target. """ - print("Whee! We're going to authenticate gamil!") + # Make an instance of our given object/class + classy = obj() + # Get the list of parameters in the instance (classy) given class (obj) + oparams = list(classy.__dict__.keys()) -def install_conffiles(args=None): - """install_conffiles Console Script for installing configuration files + # Now do the same for the configuration object (conf) + cparams = list(conf.keys()) + + # Check to see if there are any that are in the class but not in the conf + # If there are, keydiffs will != [] and they'll be shoved into the class + # with a warning if backfill is True, otherwise they're ignored entirely + keydiffs = list(set(cparams) - set(oparams)) + + for key in classy.__dict__: + try: + # Remember: key is from the input class here + kval = conf[key] + + # Check to see if it's a comma-separated-list, and other parsing + # stuff happens to check for none/true/false + nkval = valChecks(kval) + + # Actually set the parameter (key) in the class (classy) + # to the value that we found/cleaned up (nkval) + setattr(classy, key, nkval) + except KeyError: + # This means that + if debug is True: + print("Missing expected configuration key %s" % (key)) + # Just set it to None and move on with our lives + setattr(classy, key, None) + + if backfill is True: + # If there are any, that is + if keydiffs != []: + for orphan in keydiffs: + orphVal = valChecks(conf[orphan]) + print("Setting orphan object key %s to %s" % (orphan, orphVal)) + setattr(classy, orphan, orphVal) + + return classy + + +def install_conffiles(args: typing.Sequence[str] | None = None) -> None: + """Console Script for installing configuration files This function is designed to install the (secret) configuration files - (e.g., gmail_credentials.json or johnnyfive.conf) into the proper - config/ directory buried wherever on the filesystem. + (`e.g.`, ``gmail_credentials.json`` or ``johnnyfive.conf``) into the proper + ``config/`` directory buried wherever on the filesystem. Parameters ---------- - args : `Any`, optional + args : :obj:~`typing.Any`, optional The arguments passed from the command line [Default: None] """ # Use argparse for the Command-Line Script @@ -117,7 +213,7 @@ def install_conffiles(args=None): # Now, loop through the files privided for file in res.files: # Skip things that aren't files - if not isinstance(file, str) or not os.path.isfile(file): + if not isinstance(file, str) or not pathlib.Path(file).is_file(): print(f"Argument {file} is not a file... skipping.") continue @@ -127,35 +223,52 @@ def install_conffiles(args=None): shutil.copy2(file, Paths.config) -def read_ligmos_conffiles(confname, conffile="johnnyfive.conf"): - """read_ligmos_conffiles Read a configuration file using LIGMOS - - Having this as a separate function may be a bit of an overkill, but it - makes it easier to keep the ligmos imports only in one place, and - simplifies the code elsewhere. +def read_config_section( + confname: str, conffile: str = "johnnyfive.conf" +) -> baseTarget: + """Read a JohnnyFive configuration section into an attribute object. Parameters ---------- - confname : `str` + confname : :obj:`str` Name of the table within the configuration file to parse - conffile : `str` + conffile : :obj:`str` Name of the configuration file to parse Returns ------- - `ligmos.utils.classes.baseTarget` + :class:`baseTarget` An object with arrtibutes matching the keys in the associated configuration file. """ - ligconf = ligmos.utils.confparsers.rawParser(os.path.join(Paths.config, conffile)) - ligconf = ligmos.workers.confUtils.assignConf( - ligconf[confname], authTarget, backfill=True - ) - return ligconf + try: + config = rawParser(Paths.config / conffile) + return assignConf(config[confname], authTarget, backfill=True) + except KeyError as err: + raise J5Error( + f"Configuration key {confname} not present.\n" + "Try installing configuration files via j5 utilities." + ) from err + except Exception as err: + raise J5Error( + "Unexpected error occurred while reading in configuration file.\n" + f"\n{type(err).__name__} {err.args}" + ) from err + + +def read_ligmos_conffiles( + confname: str, conffile: str = "johnnyfive.conf" +) -> baseTarget: + """Backward-compatible alias for :func:`read_config_section`. + + JohnnyFive no longer depends on ligmos; new code should use + :func:`read_config_section`. + """ + return read_config_section(confname, conffile) -def print_dict(dd, indent=0, di=4): - """print_dict Print a dictionary in tree format +def print_dict(dd: dict[str, typing.Any], indent: int = 0, di: int = 4) -> None: + """Print a dictionary in tree format You know how sometimes you get these nested dictionaries, and they're a pain to visually parse? This routine prints out the contents of a @@ -166,11 +279,11 @@ def print_dict(dd, indent=0, di=4): Parameters ---------- - dd : `dict` + dd : :obj:`dict` The dictionary to print - indent : `int`, optional + indent : :obj:`int`, optional The initial indentation for the tree [Default: 0] - di: `int`, optional + di: :obj:`int`, optional The incremental indentation for each layer of the tree [Default: 4] """ if not isinstance(dd, dict): @@ -186,33 +299,105 @@ def print_dict(dd, indent=0, di=4): print(f"{' '*indent}{key:12s}: {value}") -def safe_service_connect(func, *args, pause=5, nretries=5, **kwargs): - """safe_service_connect Safely connect to Service (error-catching) +def proper_print( + msg: str, level: str, logger: logging.Logger | None = None +) -> None: + """Log if logger, else print to stdout + + Selects a logger method or standard warning/output based on ``level``. + + Parameters + ---------- + msg : :obj:`str` + The message to convey + level : ;obj:`str` + The logging level. One of [``info``,``warn``,``except``] + logger : :obj:`~logging.Logger`, optional + The logger object for logging [Default: None] + """ + if level == "info": + if logger is None: + print(msg) + else: + logger.info(msg) + elif level == "warn": + if logger is None: + warnings.warn(msg) + else: + logger.warning(msg) + elif level == "error": + if logger is None: + warnings.warn(f"EXCEPTION: {msg}") + else: + logger.error(msg) + elif level == "except": + if logger is None: + warnings.warn(f"EXCEPTION: {msg}") + else: + logger.exception(msg) + + +def rawParser(confname: str | pathlib.Path) -> configparser.ConfigParser: + """Parse an INI-style configuration file. + + Parameters + ---------- + confname : str | pathlib.Path + Path to the configuration file. + + Returns + ------- + configparser.ConfigParser + Parsed configuration, which is empty if the file cannot be opened. + """ + config = None + try: + config = configparser.ConfigParser() + config.read_file(open(confname, "r")) + except IOError as err: + print("ERROR: Configuration file %s not found!" % (confname)) + print(str(err)) + + return config + + +def safe_service_connect( + func: typing.Callable[..., typing.Any], + *args: typing.Any, + pause: int | float = 5, + nretries: int = 5, + logger: logging.Logger | None = None, + **kwargs: typing.Any, +) -> typing.Any: + """Safely connect to Service (includes error-catching) Wrapper for Service-connection functions to catch errors that might be - kicked (ConnectionTimeout, for instance). + kicked (``ConnectionTimeout``, for instance). - This function performs a semi-infinite loop, pausing for `pause` seconds - after each failed function call, up to a maximum of `nretries` retries. + This function performs a semi-infinite loop, pausing for ``pause`` seconds + after each failed function call, up to a maximum of ``nretries`` retries. Parameters ---------- - func : `method` + func : :obj:`~typing.Callable` The Service connection method to be wrapped - pause : `int` or `float`, optional + pause : :obj:`int` or :obj:`float`, optional The number of seconds to wait in between retries to connect. [Default: 5] - nretries : `int`, optional + nretries : :obj:`int`, optional The total number of times to retry connecting before returning None [Default: 10] + logger : :obj:`~logging.Logger`, optional + The logger object for logging [Default: None] Returns ------- - `Any` - The return value of `func` -- or None if unable to run `func` + :obj:`~typing.Any` + The return value of ``func`` -- or None if unable to run ``func`` """ - for i in range(1, nretries + 1): + # Now, for the actual function... + for i in range(1, nretries + 1): # Nominal function return try: return func(*args, **kwargs) @@ -220,43 +405,130 @@ def safe_service_connect(func, *args, pause=5, nretries=5, **kwargs): # This is a network error... retry except ( ConnectionError, - TransportError, + TimeoutError, + google.auth.exceptions.TransportError, httplib2.error.ServerNotFoundError, - ) as exception: - print( - f"\nWarning: Execution of `{func.__name__}` failed because of:\n{exception}" + requests.exceptions.ReadTimeout, + ) as err: + proper_print( + f"Execution of `{func.__name__}` failed because of network error." + f"\n{err}", + "error", + logger, ) - if (i := i + 1) <= nretries: - print( - f"Waiting {pause} seconds before starting attempt #{i}/{nretries}" + + if i < nretries: + proper_print( + f"Waiting {pause} seconds before starting attempt #{i+1}/{nretries}", + "info", + logger, ) time.sleep(pause) else: - raise ConnectionError( - f"Could not connect to service after {nretries} attempts." - ) from exception + proper_print( + f"Could not connect to service after {nretries} attempts.", + "error", + logger, + ) + break # This is for a Service error (premissions, etc.), no retry - except requests.exceptions.HTTPError as exception: - print( - f"\nWarning: Execution of `{func.__name__}` failed because of:\n{exception}" - "\nAborting..." - ) - break - - # Gmail service error, no retry and pass the exception upward - except HttpError as exception: - warnings.warn( - f"Caught Gmail error... passing up. {type(exception).__name__}" + except requests.exceptions.HTTPError as err: + proper_print( + f"Execution of `{func.__name__}` failed because of HTTP error." + f"\n{type(err).__name__} {err.args}", + "error", + logger, ) - raise exception + proper_print("Aborting...", "except", logger) + raise err + + # # Gmail service error, no retry and pass the exception upward + # except googleapiclient.errors.HttpError as exception: + # proper_print( + # f"Caught Gmail HTTP error... passing up. {type(exception).__name__}", + # "except", + # logger, + # ) + # raise exception # Slack service error, no retry and pass the exception upward - except SlackApiError as exception: - warnings.warn( - f"Caught Slack error... passing up. {type(exception).__name__}" + except slack_sdk.errors.SlackApiError as err: + proper_print( + f"Caught Slack API error... passing up. {type(err).__name__}", + "except", + logger, + ) + raise err + + # Confluence service error, no retry and pass the excepetion upward + except atlassian.errors.ApiError as err: + proper_print( + f"Caught Atlassian API Error... passing up. {type(err).__name__}", + "except", + logger, ) - raise exception + raise err + + # Google RefreshError occurs when the gmail_token.json to too old + except google.auth.exceptions.RefreshError as err: + proper_print( + "Google Token Refresh Error.\n" + f"\tDescription: {err.args[0]}\n" + "\tIf the reason is 'Token has been expired or revoked', then run\n" + "\t`j5_authenticate_gmail` to refresh the token.", + "error", + logger, + ) + raise err + + # If not successful, raise error + raise J5Error("Unspecified error") + + +def valChecks(kval: str) -> str | bool | None | list[str | bool | None]: + """Convert comma-separated configuration values to Python values. - # If not successful, return None - return None + Parameters + ---------- + kval : str + Raw configuration value. + + Returns + ------- + str | bool | None | list[str | bool | None] + A scalar for one value or a list for multiple values, with literal + ``true``, ``false``, and ``none`` converted to their Python values. + """ + # It'll always be a string by this point, so it should always + # have a .split() method. If not, someone else has mucked about + # with the configuration object before it got here. + kval = kval.strip().split(",") + + # Trim off leading/trailing whitespace for each. Also make sure + # that it's a list, no matter what, so we can itterate over it. + kval = [kv.strip() for kv in kval] + + # kval is now definitely a list + allval = [] + for val in kval: + # Some icky type checks + if val.lower() == "none": + nkval = None + elif val.lower() == "false": + nkval = False + elif val.lower() == "true": + nkval = True + else: + nkval = val + # Put it into a list in case there's more than one + allval.append(nkval) + + # If there's just one thing that we found, return it alone. Otherwise + # return the full list of stuff + if len(allval) == 1: + nkval = allval[0] + else: + nkval = allval + + return nkval diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dab3c14 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[build-system] +requires = ["setuptools>=80"] +build-backend = "setuptools.build_meta" + +[project] +name = "johnnyfive" +version = "0.3" +description = "Helper utilities for Lowell Observatory code notifications" +readme = "README.md" +requires-python = ">=3.14" +license = "MPL-2.0" +license-files = ["LICENSE"] +authors = [ + { name = "Lowell Observatory", email = "rhamilton@lowell.edu" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Build Tools", +] +dependencies = [ + "atlassian-python-api>=5.0.4,<6", + "beautifulsoup4>=4.13.0", + "google-api-python-client>=2.200.0", + "google-auth-httplib2>=0.2.0", + "google-auth-oauthlib>=1.2.0", + "httplib2>=0.22.0", + "lxml>=5.3.0", + "requests>=2.32.0", + "slack-sdk>=3.44.1", +] +keywords = ["confluence", "gmail", "notifications", "slack"] + +[project.urls] +"Bug Reports" = "https://github.com/LowellObservatory/JohnnyFive/issues" +Source = "https://github.com/LowellObservatory/JohnnyFive/" + +[project.scripts] +j5_install_conf = "johnnyfive.utils:install_conffiles" +j5_authenticate_gmail = "johnnyfive.gmail:authenticate_gmail" + +[project.optional-dependencies] +dev = ["check-manifest>=0.50"] +test = ["coverage>=7.0", "pytest>=8.0"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["johnnyfive*"] +namespaces = false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7be44f7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# Runtime dependencies are declared in pyproject.toml. This convenience file +# installs the project and its development/test tools from a source checkout. +.[dev,test] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3f625fa..0000000 --- a/setup.cfg +++ /dev/null @@ -1,12 +0,0 @@ -[metadata] -# This includes the license file in the wheel. -license_file = LICENSE - -[bdist_wheel] -# This flag says to generate wheels that support both Python 2 and Python -# 3. If your code will not run unchanged on both Python 2 and 3, you will -# need to generate separate wheels for each Python version that you -# support. Removing this line (or setting universal to 0) will prevent -# bdist_wheel from trying to make a universal wheel. For more see: -# https://packaging.python.org/tutorials/distributing-packages/#wheels -universal=1 diff --git a/setup.py b/setup.py deleted file mode 100644 index 9284d4d..0000000 --- a/setup.py +++ /dev/null @@ -1,199 +0,0 @@ -"""A setuptools based setup module. - -See: -https://packaging.python.org/en/latest/distributing.html -https://github.com/pypa/sampleproject -""" - -# Always prefer setuptools over distutils -from setuptools import setup, find_packages -# To use a consistent encoding -from codecs import open -from os import path - -here = path.abspath(path.dirname(__file__)) - -# Get the long description from the README file -with open(path.join(here, 'README.md'), encoding='utf-8') as f: - long_description = f.read() - -# Arguments marked as "Required" below must be included for upload to PyPI. -# Fields marked as "Optional" may be commented out. - -setup( - # This is the name of your project. The first time you publish this - # package, this name will be registered for you. It will determine how - # users can install this project, e.g.: - # - # $ pip install sampleproject - # - # And where it will live on PyPI: https://pypi.org/project/sampleproject/ - # - # There are some restrictions on what makes a valid project name - # specification here: - # https://packaging.python.org/specifications/core-metadata/#name - name='johnnyfive', # Required - - # Versions should comply with PEP 440: - # https://www.python.org/dev/peps/pep-0440/ - # - # For a discussion on single-sourcing the version across setup.py and the - # project code, see - # https://packaging.python.org/en/latest/single_source_version.html - version='0.3', # Required - - # This is a one-line description or tagline of what your project does. This - # corresponds to the "Summary" metadata field: - # https://packaging.python.org/specifications/core-metadata/#summary - description='Helper utilities for Lowell Observatory code notifications', - - # This is an optional longer description of your project that represents - # the body of text which users will see when they visit PyPI. - # - # Often, this is the same as your README, so you can just read it in from - # that file directly (as we have already done above) - # - # This field corresponds to the "Description" metadata field: - # https://packaging.python.org/specifications/core-metadata/#description-optional - long_description=long_description, # Optional - - # Denotes that our long_description is in Markdown; valid values are - # text/plain, text/x-rst, and text/markdown - # - # Optional if long_description is written in reStructuredText (rst) but - # required for plain-text or Markdown; if unspecified, "applications should - # attempt to render [the long_description] as text/x-rst; charset=UTF-8 and - # fall back to text/plain if it is not valid rst" (see link below) - # - # This field corresponds to the "Description-Content-Type" metadata field: - # https://packaging.python.org/specifications/core-metadata/#description-content-type-optional - long_description_content_type='text/markdown', # Optional (see note above) - - # This should be a valid link to your project's main homepage. - # - # This field corresponds to the "Home-Page" metadata field: - # https://packaging.python.org/specifications/core-metadata/#home-page-optional - url='https://github.com/LowellObservatory/JohnnyFive', # Optional - - # This should be your name or the name of the organization which owns the - # project. - author='Lowell Observatory', # Optional - - # This should be a valid email address corresponding to the author listed - # above. - author_email='rhamilton@lowell.edu', # Optional - - # Classifiers help users find your project by categorizing it. - # - # For a list of valid classifiers, see https://pypi.org/classifiers/ - classifiers=[ # Optional - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - 'Development Status :: 4 - Beta', - - # Indicate who your project is intended for - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Build Tools', - - # Pick your license as you wish - 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)', - - # Specify the Python versions you support here. In particular, ensure - # that you indicate whether you support Python 2, Python 3 or both. - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - ], - - # This field adds keywords for your project which will appear on the - # project page. What does your project relate to? - # - # Note that this is a string of words separated by whitespace, not a list. - keywords='', # Optional - - # You can just specify package directories manually here if your project is - # simple. Or you can use find_packages(). - # - # Alternatively, if you just want to distribute a single Python file, use - # the `py_modules` argument instead as follows, which will expect a file - # called `my_module.py` to exist: - # - # py_modules=["my_module"], - # - packages=find_packages(exclude=['contrib', 'docs', 'tests']), # Required - - # This field lists other packages that your project depends on to run. - # Any package you put here will be installed by pip when your project is - # installed, so they must be valid existing projects. - # - # For an analysis of "install_requires" vs pip's requirements files see: - # https://packaging.python.org/en/latest/requirements.html - install_requires=['atlassian-python-api', - 'beautifulsoup4', - 'google-api-python-client', - 'google-auth-httplib2', - 'google-auth-oauthlib', - 'lxml', - 'pyjwt', - 'python-twitter', - 'requests', - 'slack_sdk', - 'ligmos @ git+https://github.com/LowellObservatory/ligmos'], - - # List additional groups of dependencies here (e.g. development - # dependencies). Users will be able to install these using the "extras" - # syntax, for example: - # - # $ pip install sampleproject[dev] - # - # Similar to `install_requires` above, these must be valid existing - # projects. - extras_require={ # Optional - 'dev': ['check-manifest'], - 'test': ['coverage'], - }, - - # If there are data files included in your packages that need to be - # installed, specify them here. - # - # If using Python 2.6 or earlier, then these have to be included in - # MANIFEST.in as well. - package_data={}, - include_package_data=True, - - # Although 'package_data' is the preferred approach, in some case you may - # need to place data files outside of your packages. See: - # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files - # - # In this case, 'data_file' will be installed into '/my_data' - data_files=[], # Optional - - # To provide executable scripts, use entry points in preference to the - # "scripts" keyword. Entry points provide cross-platform support and allow - # `pip` to create the appropriate form of executable for the target - # platform. - # - # For example, the following would provide a command called `sample` which - # executes the function `main` from this package when invoked: - entry_points={ # Optional - 'console_scripts': [ - 'j5_install_conf=johnnyfive.utils:install_conffiles', - 'j5_authenticate_gmail=johnnyfive.utils:authenticate_gmail', - ] - }, - - # List additional URLs that are relevant to your project as a dict. - # - # This field corresponds to the "Project-URL" metadata fields: - # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use - # - # Examples listed include a pattern for specifying where the package tracks - # issues, where the source is hosted, where to say thanks to the package - # maintainers, and where to support the project financially. The key is - # what's used to render the link text on PyPI. - project_urls={ # Optional - 'Bug Reports': 'https://github.com/LowellObservatory/JohnnyFive/issues', - 'Source': 'https://github.com/LowellObservatory/JohnnyFive/', - }, -) diff --git a/tests/test_service_integrations.py b/tests/test_service_integrations.py new file mode 100644 index 0000000..068b63c --- /dev/null +++ b/tests/test_service_integrations.py @@ -0,0 +1,272 @@ +"""Hermetic integration tests for J5's external-service wrappers.""" + +from __future__ import annotations + +import base64 +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest + +from johnnyfive import confluence, gmail, slack + + +class FakeSlackClient: + """Minimal Slack client that records wrapper calls.""" + + def __init__(self) -> None: + """Initialize call recording.""" + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def conversations_list(self) -> dict[str, list[dict[str, str]]]: + """Return one available channel.""" + return {"channels": [{"name": "alerts", "id": "C123"}]} + + def chat_postMessage(self, **kwargs: Any) -> dict[str, Any]: + """Record and return a message response.""" + self.calls.append(("message", kwargs)) + return {"ok": True, **kwargs} + + def files_upload(self, **kwargs: Any) -> dict[str, Any]: + """Record and return an upload response.""" + self.calls.append(("upload", kwargs)) + return {"ok": True, **kwargs} + + +def test_slack_channel_routes_messages_and_files(monkeypatch: pytest.MonkeyPatch) -> None: + """Resolve a channel and route wrapper operations to its client.""" + client = FakeSlackClient() + monkeypatch.setattr(slack, "setup_slack", lambda: client) + + channel = slack.SlackChannel("alerts") + + assert channel.send_message("hello") == {"ok": True, "channel": "C123", "text": "hello"} + assert channel.upload_file("report.txt", title="Report")["title"] == "Report" + assert [name for name, _ in client.calls] == ["message", "upload"] + + +class FakeConfluenceClient: + """Minimal Confluence client that records page operations.""" + + username = "bot" + url = "https://confluence.example/" + + def __init__(self) -> None: + """Initialize call recording.""" + self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] + self.permission_queries = 0 + + def get_space_permissions(self, space: str) -> list[dict[str, Any]]: + """Grant the permissions used by this test.""" + self.permission_queries += 1 + return [ + { + "type": permission, + "spacePermissions": [{"userName": self.username}], + } + for permission in ("COMMENT", "EDITSPACE", "CREATEATTACHMENT", "REMOVEATTACHMENT", "REMOVEPAGE") + ] + + def page_exists(self, space: str, title: str) -> bool: + """Report that the page exists.""" + return True + + def get_page_id(self, space: str, title: str) -> str: + """Return a deterministic page ID.""" + return "42" + + def add_comment(self, *args: Any, **kwargs: Any) -> None: + """Record a comment call.""" + self.calls.append(("comment", args, kwargs)) + + def set_page_label(self, *args: Any, **kwargs: Any) -> None: + """Record a label call.""" + self.calls.append(("label", args, kwargs)) + + def get_attachments_from_content(self, *args: Any, **kwargs: Any) -> list[str]: + """Return a deterministic attachment list.""" + return ["attachment-1"] + + def get_page_by_id(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + """Return deterministic page content.""" + return {"body": {"storage": {"value": "

content

"}}} + + +def test_confluence_page_integrates_metadata_and_operations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exercise metadata, permissions, and API wrappers through one client.""" + client = FakeConfluenceClient() + monkeypatch.setattr(confluence, "setup_confluence", lambda use_oauth=False: client) + + page = confluence.ConfluencePage("OPS", "Status") + page.add_comment("All clear") + page.add_label("nightly") + + assert page.page_id == "42" + assert page.get_page_attachments() == ["attachment-1"] + assert page.get_page_contents() == "

content

" + assert [name for name, _, _ in client.calls] == ["comment", "label"] + assert page.space_perms == {} + assert client.permission_queries == 0 + + +class FakeConfluenceServer: + """Capture constructor arguments for the explicit v5 Server client.""" + + def __init__(self, **kwargs: Any) -> None: + """Store connection parameters without making a network request.""" + self.kwargs = kwargs + + +def test_setup_confluence_uses_explicit_server_client( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Create v5 Server clients for basic and bearer-token authentication.""" + config = SimpleNamespace( + host="https://confluence.example", + user="bot", + password="password", + access_token="token", + ) + monkeypatch.setattr(confluence, "ConfluenceServer", FakeConfluenceServer) + monkeypatch.setattr( + confluence.johnnyfive.utils, + "read_config_section", + lambda _: config, + ) + + basic_client = confluence.setup_confluence() + oauth_client = confluence.setup_confluence(use_oauth=True) + + assert basic_client.kwargs == { + "url": "https://confluence.example", + "username": "bot", + "password": "password", + } + assert oauth_client.kwargs["url"] == "https://confluence.example" + assert oauth_client.kwargs["session"].headers["Authorization"] == "Bearer token" + + +class FakeGmailRequest: + """Fake request object with Gmail's execute protocol.""" + + def __init__(self, response: dict[str, Any]) -> None: + """Store the response returned by ``execute``.""" + self.response = response + + def execute(self) -> dict[str, Any]: + """Return the configured API response.""" + return self.response + + +class FakeGmailService: + """Minimal Gmail service used to exercise message construction and sending.""" + + def __init__(self) -> None: + """Initialize a sent-message record.""" + self.sent_body: dict[str, str] | None = None + + def users(self) -> FakeGmailService: + """Return the users API facade.""" + return self + + def messages(self) -> FakeGmailService: + """Return the messages API facade.""" + return self + + def send(self, *, userId: str, body: dict[str, str]) -> FakeGmailRequest: + """Record a send request and return its response.""" + assert userId == "me" + self.sent_body = body + return FakeGmailRequest({"id": "message-1"}) + + +class FakeGmailReadService: + """Minimal Gmail service that returns a predefined full message payload.""" + + def __init__(self, response: dict[str, Any]) -> None: + """Store the API response and requested message format.""" + self.response = response + self.request_format: str | None = None + + def users(self) -> FakeGmailReadService: + """Return the users API facade.""" + return self + + def messages(self) -> FakeGmailReadService: + """Return the messages API facade.""" + return self + + def get(self, *, userId: str, id: str, format: str) -> FakeGmailRequest: + """Record the requested format and return the configured message.""" + assert userId == "me" + assert id == "message-2" + self.request_format = format + return FakeGmailRequest(self.response) + + +def test_gmail_message_builds_attachment_and_sends( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Construct a MIME message, attach a file, and send through the fake API.""" + service = FakeGmailService() + attachment = tmp_path / "report.txt" + attachment.write_text("report contents", encoding="utf-8") + monkeypatch.setattr(gmail, "setup_gmail", lambda interactive=False, logger=None: service) + + message = gmail.GmailMessage("bot@example.test", "Report", "Body") + message.add_attachment(attachment) + + assert message.send() == {"id": "message-1"} + assert service.sent_body is not None + assert "raw" in service.sent_body + + +def test_render_message_traverses_nested_parts_and_prefers_plain_text() -> None: + """Render nested multipart content without assuming the first part is text.""" + def encode(value: str) -> str: + """Return unpadded base64url content as Gmail supplies it.""" + return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=") + + service = FakeGmailReadService( + { + "payload": { + "headers": [ + {"name": "Subject", "value": "Nightly report"}, + {"name": "From", "value": "bot@example.test"}, + {"name": "Date", "value": "Mon, 8 Sep 2026 19:31:45 -0700"}, + ], + "mimeType": "multipart/mixed", + "body": {}, + "parts": [ + { + "mimeType": "multipart/alternative", + "body": {}, + "parts": [ + {"mimeType": "text/html", "body": {"data": encode("HTML report")}}, + {"mimeType": "text/plain", "body": {"data": encode("Plain report")}}, + ], + } + ], + } + } + ) + messages = object.__new__(gmail.GetMessages) + messages.service = service + messages.logger = None + + assert messages.render_message("message-2") == { + "subject": "Nightly report", + "sender": "bot@example.test", + "date": "Mon, 8 Sep 2026 19:31:45 -0700", + "body": "Plain report", + } + assert service.request_format == "full" + assert gmail.GetMessages._extract_message_body( + {"mimeType": "text/html", "body": {"data": encode("HTML only")}} + ) == "HTML only" + assert gmail.GetMessages._extract_message_body( + {"mimeType": "multipart/mixed", "body": {}, "parts": []} + ) == "" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..d922942 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,83 @@ +"""Unit tests for configuration and service utility helpers.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from johnnyfive import utils + + +def test_val_checks_converts_scalars_and_lists() -> None: + """Convert configuration literals and comma-separated values.""" + assert utils.valChecks(" true ") is True + assert utils.valChecks("None") is None + assert utils.valChecks("first, false, second") == ["first", False, "second"] + + +def test_read_config_section_and_legacy_alias( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Parse a section into J5's target object through both public names.""" + (tmp_path / "johnnyfive.conf").write_text( + "[service]\nhost = example.test\nenabled = true\ncustom = one, two\n", + encoding="utf-8", + ) + monkeypatch.setattr(utils.Paths, "config", tmp_path) + + target = utils.read_config_section("service") + legacy_target = utils.read_ligmos_conffiles("service") + + assert target.host == "example.test" + assert target.enabled is True + assert target.custom == ["one", "two"] + assert legacy_target.host == target.host + + +def test_read_config_section_reports_missing_section( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Raise J5Error instead of leaking a missing-section KeyError.""" + (tmp_path / "johnnyfive.conf").write_text("[other]\nvalue = 1\n", encoding="utf-8") + monkeypatch.setattr(utils.Paths, "config", tmp_path) + + with pytest.raises(utils.J5Error, match="Configuration key missing"): + utils.read_config_section("missing") + + +def test_install_conffiles_copies_requested_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Copy configuration files to the configured package directory.""" + source = tmp_path / "source.conf" + destination = tmp_path / "destination" + destination.mkdir() + source.write_text("[service]\nkey = value\n", encoding="utf-8") + monkeypatch.setattr(utils.Paths, "config", destination) + + utils.install_conffiles([str(source)]) + + assert (destination / source.name).read_text(encoding="utf-8") == source.read_text( + encoding="utf-8" + ) + + +def test_safe_service_connect_retries_network_failure( + monkeypatch: pytest.MonkeyPatch +) -> None: + """Retry transient connection failures and return the eventual result.""" + attempts = 0 + + def flaky_service() -> str: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise ConnectionError("temporary outage") + return "connected" + + monkeypatch.setattr(utils.time, "sleep", lambda _: None) + + with pytest.warns(UserWarning, match="network error"): + assert utils.safe_service_connect(flaky_service, pause=0, nretries=2) == "connected" + assert attempts == 2