watch-diff 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ name: Build
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ test:
7
+ runs-on: ubuntu-latest
8
+ strategy:
9
+ matrix:
10
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
11
+ steps:
12
+ - uses: actions/checkout@v5
13
+ - uses: actions/setup-python@v6
14
+ with:
15
+ python-version: ${{ matrix.python-version }}
16
+ - name: Run tests
17
+ run: python -m unittest -v
@@ -0,0 +1,2 @@
1
+ __pycache__/
2
+ watch_diff.egg-info/
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018-2019 Francis Bergin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: watch-diff
3
+ Version: 0.7.0
4
+ Summary: Watch command output and get notified on changes
5
+ Author-email: Francis Bergin <me@francisbergin.ca>
6
+ Maintainer-email: Francis Bergin <me@francisbergin.ca>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # watch-diff
13
+
14
+ [![PyPI](https://img.shields.io/pypi/v/watch-diff.svg)](https://pypi.org/project/watch-diff)
15
+
16
+ ## setup
17
+
18
+ ```shell
19
+ pip install watch-diff
20
+ ```
21
+
22
+ ## usage
23
+
24
+ ```console
25
+ $ watch-diff --help
26
+ usage: watch-diff [-h] [-v | -d] [-i SECONDS] [-r RECIPIENT] command
27
+
28
+ Watch command output and get notified on changes
29
+
30
+ positional arguments:
31
+ command the command to watch
32
+
33
+ optional arguments:
34
+ -h, --help show this help message and exit
35
+ -i SECONDS, --interval SECONDS
36
+ number of seconds between executions
37
+ -r RECIPIENT, --recipient RECIPIENT
38
+ send email to recipient
39
+
40
+ logging level:
41
+ -v, --verbose enable verbose output
42
+ -d, --debug show debugging statements
43
+ ```
44
+
45
+ ## credentials
46
+
47
+ ```shell
48
+ export SMTP_HOST=qwer.ty
49
+ export SMTP_PORT=1234
50
+ export SMTP_USER=qwer@qwer.ty
51
+ read -s -p "SMTP_PASS: " SMTP_PASS
52
+ export SMTP_PASS
53
+ ```
54
+
55
+ ## development
56
+
57
+ ```shell
58
+ # setup
59
+ python3 -m venv venv && . venv/bin/activate
60
+
61
+ # editable install
62
+ pip install -e .[dev]
63
+
64
+ # running tests
65
+ python -m unittest -v
66
+ ```
@@ -0,0 +1,55 @@
1
+ # watch-diff
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/watch-diff.svg)](https://pypi.org/project/watch-diff)
4
+
5
+ ## setup
6
+
7
+ ```shell
8
+ pip install watch-diff
9
+ ```
10
+
11
+ ## usage
12
+
13
+ ```console
14
+ $ watch-diff --help
15
+ usage: watch-diff [-h] [-v | -d] [-i SECONDS] [-r RECIPIENT] command
16
+
17
+ Watch command output and get notified on changes
18
+
19
+ positional arguments:
20
+ command the command to watch
21
+
22
+ optional arguments:
23
+ -h, --help show this help message and exit
24
+ -i SECONDS, --interval SECONDS
25
+ number of seconds between executions
26
+ -r RECIPIENT, --recipient RECIPIENT
27
+ send email to recipient
28
+
29
+ logging level:
30
+ -v, --verbose enable verbose output
31
+ -d, --debug show debugging statements
32
+ ```
33
+
34
+ ## credentials
35
+
36
+ ```shell
37
+ export SMTP_HOST=qwer.ty
38
+ export SMTP_PORT=1234
39
+ export SMTP_USER=qwer@qwer.ty
40
+ read -s -p "SMTP_PASS: " SMTP_PASS
41
+ export SMTP_PASS
42
+ ```
43
+
44
+ ## development
45
+
46
+ ```shell
47
+ # setup
48
+ python3 -m venv venv && . venv/bin/activate
49
+
50
+ # editable install
51
+ pip install -e .[dev]
52
+
53
+ # running tests
54
+ python -m unittest -v
55
+ ```
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "watch-diff"
3
+ version = "0.7.0"
4
+ description = "Watch command output and get notified on changes"
5
+ readme = "README.md"
6
+ authors = [
7
+ {name = "Francis Bergin", email = "me@francisbergin.ca"},
8
+ ]
9
+ maintainers = [
10
+ {name = "Francis Bergin", email = "me@francisbergin.ca"},
11
+ ]
12
+ license = "MIT"
13
+ requires-python = ">=3.10"
14
+ dependencies = []
15
+
16
+ [project.scripts]
17
+ watch-diff = "watch_diff.__main__:main"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
File without changes
@@ -0,0 +1,40 @@
1
+ """
2
+ """
3
+
4
+ import os
5
+ import unittest
6
+
7
+ import watch_diff
8
+
9
+
10
+ smtp_host = os.environ.get('SMTP_HOST')
11
+ smtp_port = os.environ.get('SMTP_PORT')
12
+ smtp_user = os.environ.get('SMTP_USER')
13
+ smtp_pass = os.environ.get('SMTP_PASS')
14
+
15
+
16
+ class TestWatchDiff(unittest.TestCase):
17
+
18
+ def test_api_available(self):
19
+ self.assertTrue(watch_diff.Command)
20
+ self.assertTrue(watch_diff.Diff)
21
+ self.assertTrue(watch_diff.Email)
22
+ self.assertTrue(watch_diff.DefaultFormatter)
23
+ self.assertTrue(watch_diff.ConsoleFormatter)
24
+ self.assertTrue(watch_diff.HTMLFormatter)
25
+ self.assertTrue(watch_diff.OutputFormatting)
26
+
27
+ def test_command(self):
28
+ c = watch_diff.Command('date')
29
+ self.assertFalse(c)
30
+ d = c.run()
31
+ self.assertTrue(c)
32
+ self.assertTrue(d)
33
+
34
+ @unittest.skipIf(not smtp_host, 'SMTP_HOST is not available')
35
+ @unittest.skipIf(not smtp_port, 'SMTP_PORT is not available')
36
+ @unittest.skipIf(not smtp_user, 'SMTP_USER is not available')
37
+ @unittest.skipIf(not smtp_pass, 'SMTP_PASS is not available')
38
+ def test_email(self):
39
+ e = watch_diff.Email(smtp_host, smtp_port, smtp_user, smtp_pass, 'watch-diff-tests', smtp_user)
40
+ e.send_email('watch diff tests', 'text', 'html')
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "watch-diff"
7
+ version = "0.7.0"
8
+ source = { editable = "." }
@@ -0,0 +1,10 @@
1
+ """
2
+ """
3
+
4
+ __version__ = '0.6.0'
5
+
6
+
7
+ from .command import Command
8
+ from .diff import Diff
9
+ from .email import Email
10
+ from .format import DefaultFormatter, ConsoleFormatter, HTMLFormatter, OutputFormatting
@@ -0,0 +1,83 @@
1
+ """
2
+ """
3
+
4
+ import argparse
5
+ import datetime
6
+ import getpass
7
+ import logging
8
+ import os
9
+ import time
10
+
11
+ from email.utils import make_msgid
12
+
13
+ from . import command
14
+ from . import email
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ parser = argparse.ArgumentParser(description='Watch command output and get notified on changes')
20
+ logging_group = parser.add_argument_group('logging level').add_mutually_exclusive_group()
21
+ logging_group.add_argument('-v', '--verbose', action='store_const',
22
+ const=logging.INFO, default=logging.CRITICAL,
23
+ dest='loglevel', help='enable verbose output')
24
+ logging_group.add_argument('-d', '--debug', action='store_const',
25
+ const=logging.DEBUG, dest='loglevel',
26
+ help='show debugging statements')
27
+ parser.add_argument('-i', '--interval', type=int, default=5, metavar='SECONDS', help='number of seconds between executions')
28
+ parser.add_argument('-r', '--recipient', help='send email to recipient')
29
+ parser.add_argument('command', help='the command to watch')
30
+
31
+
32
+ def _main():
33
+ args = parser.parse_args()
34
+ logging.basicConfig(level=args.loglevel)
35
+ e = None
36
+
37
+ if args.recipient:
38
+ smtp_host = os.environ.get('SMTP_HOST') or input('SMTP_HOST: ')
39
+ smtp_port = os.environ.get('SMTP_PORT') or input('SMTP_PORT: ')
40
+ smtp_user = os.environ.get('SMTP_USER') or input('SMTP_USER: ')
41
+ smtp_pass = os.environ.get('SMTP_PASS') or getpass.getpass('SMTP_PASS: ')
42
+ e = email.Email(smtp_host, smtp_port, smtp_user, smtp_pass, 'watch-diff', args.recipient)
43
+
44
+ first_run = True
45
+ c = command.Command(args.command)
46
+ previous_msg_id = None
47
+
48
+ while True:
49
+ now = str(datetime.datetime.now())
50
+ logger.info('executing command with time {}'.format(now))
51
+ diff = c.run(now)
52
+
53
+ if first_run:
54
+ print('[{}] first_run:'.format(now))
55
+ print(c.to_console())
56
+ subject = 'watch-diff first_run: {}'.format(args.command)
57
+ if e:
58
+ logger.info('sending first_run email to {}'.format(args.recipient))
59
+ msg_id = make_msgid()
60
+ e.send_email(subject, str(c), c.to_html(full_html=True), msg_id)
61
+ previous_msg_id = msg_id
62
+ elif diff:
63
+ print('[{}] diff:'.format(now))
64
+ print(diff.to_console())
65
+ subject = 'watch-diff diff: {}'.format(args.command)
66
+ if e:
67
+ logger.info('sending diff email to {}'.format(args.recipient))
68
+ msg_id = make_msgid()
69
+ e.send_email(subject, str(diff), diff.to_html(full_html=True), msg_id, previous_msg_id)
70
+ previous_msg_id = msg_id
71
+ else:
72
+ print('[{}] no diff'.format(now))
73
+
74
+ logger.info('sleeping for {} seconds'.format(args.interval))
75
+ time.sleep(args.interval)
76
+ first_run = False
77
+
78
+
79
+ def main():
80
+ try:
81
+ _main()
82
+ except KeyboardInterrupt:
83
+ pass
@@ -0,0 +1,41 @@
1
+ """
2
+ """
3
+
4
+ import datetime
5
+ import subprocess
6
+
7
+ from . import diff
8
+ from . import format
9
+
10
+
11
+ class Command(format.OutputFormatting):
12
+
13
+ def __init__(self, command):
14
+ self._command = command
15
+
16
+ self._previous_datetime = ''
17
+ self._previous_result = ''
18
+
19
+ self._current_datetime = ''
20
+ self._current_result = ''
21
+
22
+ def __bool__(self):
23
+ return bool(self._current_result)
24
+
25
+ def _format(self, formatter=format.DefaultFormatter):
26
+ return self._current_result
27
+
28
+ def _diff(self):
29
+ return diff.Diff(self._previous_result, self._current_result, self._previous_datetime, self._current_datetime)
30
+
31
+ def _run(self):
32
+ return subprocess.getoutput(self._command)
33
+
34
+ def run(self, now=None):
35
+ self._previous_datetime = self._current_datetime
36
+ self._previous_result = self._current_result
37
+
38
+ self._current_datetime = now or str(datetime.datetime.now())
39
+ self._current_result = self._run()
40
+
41
+ return self._diff()
@@ -0,0 +1,31 @@
1
+ """
2
+ """
3
+
4
+ import difflib
5
+
6
+ from . import format
7
+
8
+
9
+ class Diff(format.OutputFormatting):
10
+
11
+ def __init__(self, a, b, previous_datetime, current_datetime):
12
+ self._a = a
13
+ self._b = b
14
+ self._diff = '\n'.join(difflib.unified_diff(a.splitlines(), b.splitlines(), 'Previous', 'Current', previous_datetime, current_datetime, lineterm='')) or None
15
+
16
+ def __bool__(self):
17
+ return bool(self._diff)
18
+
19
+ def _format(self, formatter=format.DefaultFormatter):
20
+ lines = self._diff.splitlines()
21
+ output = []
22
+ for line in lines[:2]:
23
+ output.append('{}{}{}'.format(formatter.header_start, line, formatter.header_end))
24
+ for line in lines[2:]:
25
+ if line[0] == '+':
26
+ output.append('{}{}{}'.format(formatter.addition_start, line, formatter.addition_end))
27
+ elif line[0] == '-':
28
+ output.append('{}{}{}'.format(formatter.subtraction_start, line, formatter.subtraction_end))
29
+ else:
30
+ output.append(line)
31
+ return '\n'.join(output)
@@ -0,0 +1,84 @@
1
+ """
2
+ """
3
+
4
+ import functools
5
+ import logging
6
+ import smtplib
7
+ import socket
8
+ import time
9
+
10
+ from email.mime.multipart import MIMEMultipart
11
+ from email.mime.text import MIMEText
12
+ from email.utils import make_msgid, formatdate
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _repeat_on_exceptions(num_times=3, *exceptions):
19
+ def decorator(func):
20
+ @functools.wraps(func)
21
+ def wrapper(*args, **kwargs):
22
+ count = 1
23
+ while True:
24
+ try:
25
+ logger.info('running func: "{}", count: {}'.format(func.__name__, count))
26
+ return func(*args, **kwargs)
27
+ except Exception as e:
28
+ if (not exceptions or e.__class__ in exceptions) and count < num_times:
29
+ count += 1
30
+ time.sleep(30)
31
+ continue
32
+ else:
33
+ raise
34
+ return wrapper
35
+ return decorator
36
+
37
+
38
+ class Email:
39
+
40
+ def __init__(self, smtp_host, smtp_port, smtp_user, smtp_pass, from_name, recipient):
41
+ self._smtp_host = smtp_host
42
+ self._smtp_port = smtp_port
43
+ self._smtp_user = smtp_user
44
+ self._smtp_pass = smtp_pass
45
+ self._from_name = from_name
46
+ self._recipient = recipient
47
+
48
+ @_repeat_on_exceptions(3, smtplib.SMTPServerDisconnected, socket.gaierror, socket.timeout)
49
+ def _smtp_connect(self, smtp_host, smtp_port):
50
+ return smtplib.SMTP(host=self._smtp_host, port=self._smtp_port)
51
+
52
+ @_repeat_on_exceptions(3, smtplib.SMTPAuthenticationError, socket.gaierror, socket.timeout)
53
+ def _smtp_login(self, session, smtp_user, smtp_pass):
54
+ session.login(self._smtp_user, self._smtp_pass)
55
+
56
+ def send_email(self, subject, text, html, msg_id=None, previous_msg_id=None):
57
+ logger.info('sending email')
58
+
59
+ msg = MIMEMultipart('alternative')
60
+
61
+ msg['From'] = '{} <{}>'.format(self._from_name, self._smtp_user)
62
+ msg['To'] = self._recipient
63
+ msg['Subject'] = subject
64
+ msg['Date'] = formatdate()
65
+ msg['Message-ID'] = msg_id or make_msgid()
66
+
67
+ if previous_msg_id:
68
+ msg['In-Reply-To'] = previous_msg_id
69
+
70
+ part1 = MIMEText(text, 'plain')
71
+ part2 = MIMEText(html, 'html')
72
+
73
+ msg.attach(part1)
74
+ msg.attach(part2)
75
+
76
+ s = self._smtp_connect(self._smtp_host, self._smtp_port)
77
+ s.ehlo()
78
+ s.starttls()
79
+ s.ehlo()
80
+ self._smtp_login(s, self._smtp_user, self._smtp_pass)
81
+ s.sendmail(self._smtp_user, self._recipient, msg.as_string())
82
+ s.quit()
83
+
84
+ logger.info('email sent successfully')
@@ -0,0 +1,51 @@
1
+ """
2
+ """
3
+
4
+ class DefaultFormatter:
5
+ header_start = ''
6
+ header_end = ''
7
+ addition_start = ''
8
+ addition_end = ''
9
+ subtraction_start = ''
10
+ subtraction_end = ''
11
+
12
+ class ConsoleFormatter(DefaultFormatter):
13
+ header_start = '\033[1m'
14
+ header_end = '\033[0m'
15
+ addition_start = '\033[92m'
16
+ addition_end = '\033[0m'
17
+ subtraction_start = '\033[91m'
18
+ subtraction_end = '\033[0m'
19
+
20
+ class HTMLFormatter(DefaultFormatter):
21
+ header_start = '<b>'
22
+ header_end = '</b>'
23
+ addition_start = '<span style="color:green">'
24
+ addition_end = '</span>'
25
+ subtraction_start = '<span style="color:red">'
26
+ subtraction_end = '</span>'
27
+
28
+ class OutputFormatting:
29
+
30
+ def __str__(self):
31
+ return self._format(DefaultFormatter)
32
+
33
+ def to_console(self):
34
+ return self._format(ConsoleFormatter)
35
+
36
+ def to_html(self, full_html=False):
37
+ partial_html = self._format(HTMLFormatter)
38
+ if not full_html:
39
+ return partial_html
40
+ else:
41
+ html_page = '''
42
+ <html>
43
+ <body>
44
+ <pre>
45
+ {}
46
+ </pre>
47
+ </body>
48
+ </html>
49
+ '''
50
+ html_page = '\n'.join([l.strip() for l in html_page.splitlines()])
51
+ return html_page.format(partial_html)