diff --git a/.gitignore b/.gitignore index a82ba2c..285ad65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ -execNotifyDir/config/cfg.ini -__pycache__/ -.idea/workspace.xml \ No newline at end of file +/etc/execNotify/cfg.ini +/mail/ + +/.idea/workspace.xml +/__pycache__/ + +/dist/ +/src/de_p1st_execNotify.egg-info/ diff --git a/.idea/execNotify.iml b/.idea/execNotify.iml index 84b184a..3cb69b0 100644 --- a/.idea/execNotify.iml +++ b/.idea/execNotify.iml @@ -1,8 +1,13 @@ - - + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index d4e4db6..14fc843 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/.idea/runConfigurations/execNotify.xml b/.idea/runConfigurations/execNotify.xml new file mode 100644 index 0000000..7d065f6 --- /dev/null +++ b/.idea/runConfigurations/execNotify.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/notify.xml b/.idea/runConfigurations/notify.xml new file mode 100644 index 0000000..f0e9c04 --- /dev/null +++ b/.idea/runConfigurations/notify.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/Makefile b/Makefile deleted file mode 100644 index e85d574..0000000 --- a/Makefile +++ /dev/null @@ -1,24 +0,0 @@ -.PHONY: all install clean copy remove - -all: install - -clean: remove - -install: permissions - -permissions: copy - chmod 755 /usr/local/bin/notify - chmod 755 /usr/local/bin/execNotify - find /usr/local/bin/execNotifyDir \( -type d -exec chmod 755 {} + \) -o \( -type f -exec chmod 644 {} + \) - chown root:root /usr/local/bin/execNotify - chown -R root:root /usr/local/bin/execNotifyDir - -copy: - cp notify /usr/local/bin/notify - cp execNotify /usr/local/bin/execNotify - cp -r execNotifyDir/ /usr/local/bin/ - -remove: - rm /usr/local/bin/notify - rm /usr/local/bin/execNotify - rm -r /usr/local/bin/execNotifyDir diff --git a/README.md b/README.md index f010842..c26de98 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,88 @@ # execNotify -Send notification on failure. -Or send an unconditional notification. +* Send email notification if command fails with [execNotify](src/de/p1st/exec_notify/execNotify). +* Send unconditional notifications with [notify](src/de/p1st/exec_notify/notify). -## setup +## Installation -Create a `config.ini` file inside `execNotifyDir/config`. - -For the required fields, see `execNotifyDir/config/config.ini.example` - -## installation +Create a _venv_ and install the module from TestPyPI: ```shell -sudo make install +python3 -m pip install --index-url https://test.pypi.org/simple/ --no-deps de-p1st-execNotify ``` -## usage of execNotify +The above command uses the parameter `--no-deps`: + +> Since TestPyPI doesn’t have the same packages as the live PyPI, it’s possible that attempting +> to install dependencies may fail or install something unexpected. While this package +> doesn’t have any dependencies, it’s a good practice to avoid installing dependencies when +> using TestPyPI. + +## Configuration + +Create configuration file `/etc/execNotify/cfg.ini`. + +For the required fields, see [./etc/execNotify/cfg.ini.example](etc/execNotify/cfg.ini.example) + + +## Usage + +### Usage of execNotify Add `execNotify` in front of your command to execute. You can pipe into `execNotify` but **not** from `execNotify` as the output gets modified. -### Example +**Example:** -For `ls /home` run `execNotify ls /home` +`execNotify ls /non/existent/file` will mail you the exit code, stdout and stderr of `ls /non/existent/file` -## usage of notify +### Usage of notify Send stdout via mail: `echo "Hello world!" | notify` -Or with optional subject: +Send stdout and stderr via mail: + +`echo "Hello world!" 2>&1 | notify` + +Send stdout and specify an optional email subject: `echo "Hello world!" | notify "someSubject"` -Or without the pipe: - -`notify "someSubject" "Hello" "World!" "What's" "up?"` +Send message without using a pipe: `notify "someSubject" "Hello World! What's up?"` + + +## Development + +When started with environment variable `DE_P1ST_EXEC_NOTIFY` set, +then the configuration file is read from [./etc/execNotify/cfg.ini](etc/execNotify/cfg.ini). + +### Uploading to TestPyPI + +More detailed instructions can be found at https://packaging.python.org/tutorials/packaging-projects/ + +0) Set up a _venv_ +1) Develop and test locally +2) Increase/Adjust `[metadata][version]` in [setup.cfg](setup.cfg) +3) Install the `build` and `twine` modules to your _venv_. +4) Next generate a [distribution archive](https://packaging.python.org/tutorials/packaging-projects/#generating-distribution-archives): + +```shell +python3 -m build +``` + +5) Upload the distribution packages with twine. For the username, use `__token__`. For the password, use a +[test.pypi.org API token](https://test.pypi.org/manage/account/#api-tokens): + +```shell +python3 -m twine upload --repository testpypi dist/* +``` + +6) Congratulations! You can view the uploaded module under: + +* [https://test.pypi.org/project/de-p1st-execNotify/](https://test.pypi.org/project/de-p1st-execNotify/) diff --git a/execNotifyDir/config/cfg.ini.example b/etc/execNotify/cfg.ini.example similarity index 76% rename from execNotifyDir/config/cfg.ini.example rename to etc/execNotify/cfg.ini.example index b6a0a8b..107cff8 100644 --- a/execNotifyDir/config/cfg.ini.example +++ b/etc/execNotify/cfg.ini.example @@ -9,4 +9,4 @@ from = noreply@example.com to = me@example.com [file] -errorfile = /home/exampleUser/ERROR.mail +maildir = /home/exampleUser/mail/ diff --git a/execNotify b/execNotify deleted file mode 100755 index 8a1a0b6..0000000 --- a/execNotify +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -from sys import argv -import socket -from typing import List - -from execNotifyDir import exec, config, mail - - -def main(): - if len(argv) >= 2: - if not executeCommand(argv[1:]): - exit(1) - exit(0) - - -def executeCommand(command: List) -> bool: - keys = ['command', 'status', 'stderr', 'stdout'] - code, stdout, stderr = exec.execute(command) - values = [str(command), str(code), stderr, stdout] - - BODY = '' - for key, value in zip(keys, values): - BODY += '=== {} ===\n{}\n'.format(key, value) - print(BODY) - - if code != 0: - hostname = socket.gethostname() - - SUBJECT = '{} | {}'.format(hostname, str(command)) - mail.sendMailOrWriteToFile(SUBJECT=SUBJECT, BODY=BODY) - - return False - else: - return True - - -if __name__ == '__main__': - main() - if mail.prevMailNotSent(): - mail.informAboutOldMail() diff --git a/execNotifyDir/util.py b/execNotifyDir/util.py deleted file mode 100644 index b2768e9..0000000 --- a/execNotifyDir/util.py +++ /dev/null @@ -1,15 +0,0 @@ -import os -from pathlib import Path - - -def getProjectBase() -> Path: - return Path(os.path.realpath(__file__)).parent.parent - - -def readFirstLine(file: Path) -> str: - """ - :param file: Path to file - :return: first line of file - """ - with open(file, "r") as f: - return f.readline() diff --git a/notify b/notify deleted file mode 100755 index 5f8c40c..0000000 --- a/notify +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -from sys import argv, stderr, stdin -import socket -from execNotifyDir import exec, config, mail - - -def main(): - """ - echo | ./notify - echo | ./notify - ./notify ... - """ - - BODY = None - subj = None - hostname = socket.gethostname() - - if len(argv) >= 2: - subj = argv[1] - if len(argv) >= 3: - BODY = str(argv[2:]) - if subj is None: - subj = "notify" - if BODY is None: - BODY = "=== stdin ===\n" + stdin.read() - - SUBJECT = "{} | {}".format(hostname, subj) - print(BODY) - - mail.sendMailOrWriteToFile(SUBJECT=SUBJECT, BODY=BODY) - - -if __name__ == '__main__': - main() - if mail.prevMailNotSent(): - mail.informAboutOldMail() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c035a4f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +# https://packaging.python.org/tutorials/packaging-projects/#creating-pyproject-toml + +[build-system] +requires = [ + "setuptools>=42", + "wheel" +] +build-backend = "setuptools.build_meta" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..78a0746 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,28 @@ +# setup.cfg is the configuration file for setuptools. +# https://packaging.python.org/tutorials/packaging-projects/#configuring-metadata +# https://pypi.org/classifiers/ + +[metadata] +name = de-p1st-execNotify +version = 0.0.2 +author = Daniel Langbein +author_email = daniel@systemli.org +description = Send mail with process output +long_description = file: README.md +long_description_content_type = text/markdown +url = https://codeberg.org/langfingaz/execNotify +project_urls = + Bug Tracker = https://codeberg.org/langfingaz/execNotify/issues +classifiers = + Programming Language :: Python :: 3 + License :: OSI Approved :: MIT License + Operating System :: Unix + +[options] +package_dir = + = src +packages = find: +python_requires = >=3.8 + +[options.packages.find] +where = src \ No newline at end of file diff --git a/execNotifyDir/__init__.py b/src/de/__init__.py similarity index 100% rename from execNotifyDir/__init__.py rename to src/de/__init__.py diff --git a/src/de/p1st/__init__.py b/src/de/p1st/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/de/p1st/exec_notify/__init__.py b/src/de/p1st/exec_notify/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/de/p1st/exec_notify/execNotify b/src/de/p1st/exec_notify/execNotify new file mode 100755 index 0000000..fbc917a --- /dev/null +++ b/src/de/p1st/exec_notify/execNotify @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import sys +from sys import argv +import socket +from typing import List + +from de.p1st.exec_notify.lib import exec, config, mail + + +def main(): + if len(argv) <= 1: + print('No command given to execute!', file=sys.stderr) + exit(1) + + exit(executeCommand(argv[1:])) + + +def executeCommand(command: List) -> int: + """ + Executes the given command and sends an email on failure. + + :return: exit code of executed command + """ + + keys = ['command', 'exit code', 'stderr', 'stdout'] + exitCode, stdout, stderr = exec.execute(command) + values = [str(command), str(exitCode), stderr, stdout] + + BODY = '' + for key, value in zip(keys, values): + BODY += '=== {} ===\n{}\n'.format(key, value) + print(BODY) + + if exitCode != 0: + hostname = socket.gethostname() + + SUBJECT = '{} | {}'.format(hostname, str(command)) + mail.sendMailOrWriteToFile(SUBJECT=SUBJECT, BODY=BODY) + + return exitCode + + +if __name__ == '__main__': + main() diff --git a/src/de/p1st/exec_notify/lib/__init__.py b/src/de/p1st/exec_notify/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/execNotifyDir/config.py b/src/de/p1st/exec_notify/lib/config.py similarity index 61% rename from execNotifyDir/config.py rename to src/de/p1st/exec_notify/lib/config.py index 533ff12..22aa106 100644 --- a/execNotifyDir/config.py +++ b/src/de/p1st/exec_notify/lib/config.py @@ -1,7 +1,8 @@ -from pathlib import Path +from pathlib import Path, PosixPath import configparser -from execNotifyDir import util +from de.p1st.exec_notify.lib import util + def getHostAndPort(): return config['mail']['host'], config['mail']['port'] @@ -19,8 +20,8 @@ def getTo(): return config['mail']['to'] -def getErrorFile() -> Path: - return Path(config['file']['errorfile']) +def getMailDir() -> Path: + return Path(config['file']['maildir']) def _getCfgFile() -> Path: @@ -28,7 +29,11 @@ def _getCfgFile() -> Path: def _getCfgDir() -> Path: - return util.getProjectBase().joinpath('execNotifyDir').joinpath('config') + if util.isInDevelopment(): + return util.getProjectBase().joinpath('etc','execNotify') + else: + return PosixPath('/etc/execNotify/') + config: configparser.ConfigParser = configparser.ConfigParser() config.read(_getCfgFile()) diff --git a/execNotifyDir/exec.py b/src/de/p1st/exec_notify/lib/exec.py similarity index 93% rename from execNotifyDir/exec.py rename to src/de/p1st/exec_notify/lib/exec.py index 1a89044..f4cef9b 100644 --- a/execNotifyDir/exec.py +++ b/src/de/p1st/exec_notify/lib/exec.py @@ -9,7 +9,7 @@ def execute(command: List[str]): Wait for command to complete. :param command: A command to executed as list of words, e.g. ["echo", "hello"] - :return: (status, stdout, stderr) + :return: (exit_code, stdout, stderr) """ completed: subprocess.CompletedProcess = subprocess.run( diff --git a/execNotifyDir/mail.py b/src/de/p1st/exec_notify/lib/mail.py similarity index 55% rename from execNotifyDir/mail.py rename to src/de/p1st/exec_notify/lib/mail.py index 29c56a8..1840fd4 100644 --- a/execNotifyDir/mail.py +++ b/src/de/p1st/exec_notify/lib/mail.py @@ -6,15 +6,20 @@ import socket from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -from execNotifyDir import config +from de.p1st.exec_notify.lib import config -def sendMailOrWriteToFile(SUBJECT: str, BODY: str): +def sendMailOrWriteToFile(SUBJECT: str, BODY: str, informAboutLocalMail: bool = True): + if informAboutLocalMail and localMailExists(): + BODY=f'[!] Note [!]\nThere is some local mail inside [file][maildir] that could not be sent previously! ' \ + f'Please read and then delete the local mail.\n\n\n' \ + f'{BODY}' + try: sendMail(SUBJECT=SUBJECT, BODY=BODY) except Exception as e: - print("Error: Could not send mail! Writing to file instead ...", file=sys.stderr) - print(e, file=sys.stderr) + print(f'execNotify>> Could not send mail: {e}', file=sys.stderr) + print(f'execNotify>> Writing to file instead ...', file=sys.stderr) # Instead, try to save the mail so that the user can read # it later when connected to this computer @@ -23,7 +28,7 @@ def sendMailOrWriteToFile(SUBJECT: str, BODY: str): def sendMail(SUBJECT: str, BODY: str): """ - :throws Exception: if mail could not be sent + :throws Exception: If mail could not be sent """ FROM = config.getFrom() @@ -55,32 +60,20 @@ def saveMail(SUBJECT: str, BODY: str): time = datetime.datetime.now() timeStr = time.strftime('%Y%m%d_%H%M%S') - DATE = '=' * 20 + '\n=== date ===\n' + timeStr + '\n' - SUBJECT = '=== subject ===\n' + SUBJECT + '\n' - try: + # create parent directory if not existent + mailDir = config.getMailDir() + mailDir.mkdir(parents=True, exist_ok=True) + # append to file; create file if not existent - with open(config.getErrorFile(), "a") as f: - f.write(DATE) - f.write(SUBJECT) - f.write(BODY) + with open(mailDir.joinpath(timeStr), "a") as f: + f.write(f'{"=" * 20}\n=== date ===\n{timeStr}\n=== subject ===\n{SUBJECT}\n=== body ===\n{BODY}\n') except Exception as e: - print('Error: Could not write to file!', file=sys.stderr) - print(e, file=sys.stderr) + print(f'execNotify>> Could not write to file: {e}', file=sys.stderr) -def prevMailNotSent(): - return config.getErrorFile().exists() - - -def informAboutOldMail(): +def localMailExists(): """ - Try to inform user via mail about previous error(s) that could not be sent to him before. - Maybe this time sending of an email works ;) + :return: True if local mail exists in maildir folder. Once the mail is read the user shall delete (or move) it. """ - - SUBJECT = '{} | Some mails not sent!'.format(socket.gethostname()) - BODY = 'Please check the file {} for mails which could previously not be sent to you!\n' \ - 'Note: You may delete the file after reading it ;)' \ - .format(config.getErrorFile()) - sendMail(SUBJECT=SUBJECT, BODY=BODY) + return len(list(config.getMailDir().iterdir())) > 0 diff --git a/src/de/p1st/exec_notify/lib/util.py b/src/de/p1st/exec_notify/lib/util.py new file mode 100644 index 0000000..3ea65a1 --- /dev/null +++ b/src/de/p1st/exec_notify/lib/util.py @@ -0,0 +1,24 @@ +import os +from pathlib import Path + + +def isInDevelopment() -> bool: + """ + Helpful for local development where this python module is not installed. + + :return: True if environment variable DE_P1ST_EXEC_NOTIFY is set. + """ + return 'DE_P1ST_EXEC_NOTIFY' in os.environ + + +def getProjectBase() -> Path: + return Path(os.path.realpath(__file__)).parent.parent.parent.parent.parent.parent + + +def readFirstLine(file: Path) -> str: + """ + :param file: Path to file + :return: first line of file + """ + with open(file, "r") as f: + return f.readline() diff --git a/src/de/p1st/exec_notify/notify b/src/de/p1st/exec_notify/notify new file mode 100755 index 0000000..453cffc --- /dev/null +++ b/src/de/p1st/exec_notify/notify @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +from sys import argv, stderr, stdin +import socket + +from de.p1st.exec_notify.lib import exec, config, mail + + +def main(): + """ + echo | ./notify + echo | ./notify + ./notify + ./notify ... + """ + + BODY = None + subj = None + hostname = socket.gethostname() + + if len(argv) > 3: + print('execNotify>> Expected at most two arguments!') + exit(1) + if len(argv) == 2 or len(argv) == 3: + subj = argv[1] + if len(argv) == 3: + BODY = argv[2] + + if subj is None: + subj = 'notify' + if BODY is None: + BODY = f'=== stdin ===\n{stdin.read()}' + + SUBJECT = f'{hostname} | {subj}' + print(BODY) + + mail.sendMailOrWriteToFile(SUBJECT=SUBJECT, BODY=BODY) + + +if __name__ == '__main__': + main()