From 6b71875cd0bce3f75edbd59865bdc463529e262d Mon Sep 17 00:00:00 2001 From: Zaytsev Dmitriy Date: Fri, 2 Sep 2016 13:39:52 +0300 Subject: [PATCH 1/5] email notifications --- pyprind/__init__.py | 1 + pyprind/email_notification.py | 76 +++++++++++++++++++++++++++++++++++ pyprind/prog_class.py | 75 ++++++++++++++++++++++++++++++---- pyprind/progbar.py | 5 ++- requirements.txt | 3 +- setup.py | 5 ++- 6 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 pyprind/email_notification.py diff --git a/pyprind/__init__.py b/pyprind/__init__.py index 08af3c3..b541494 100644 --- a/pyprind/__init__.py +++ b/pyprind/__init__.py @@ -15,6 +15,7 @@ from .progpercent import ProgPercent from .generator_factory import prog_percent from .generator_factory import prog_bar +from .email_notification import setup_email __version__ = '2.9.8' diff --git a/pyprind/email_notification.py b/pyprind/email_notification.py new file mode 100644 index 0000000..3934751 --- /dev/null +++ b/pyprind/email_notification.py @@ -0,0 +1,76 @@ +import base64 +import os +try: + import configparser +except ImportError: + import ConfigParser as configparser + +try: + from Crypto.Cipher import AES + from Crypto import Random + crypto_import = True +except ImportError: + crypto_import = False + + +class AESCipher(object): + + def __init__(self): + self.dir_path = os.path.dirname(os.path.abspath(__file__)) + self.key = self.generate_key() + self.file = None + self.get_current_path() + if not crypto_import: + raise ValueError('crypto package is required when using' + ' email notifications.') + + @staticmethod + def pad(s): + return s + (16 - len(s) % 16) * chr(16 - len(s) % 16) + + @staticmethod + def unpad(s): + return s[:-ord(s[len(s) - 1:])] + + def get_current_path(self): + self.file = os.path.join(self.dir_path, 'email_settings.ini.enc') + + def generate_key(self): + key_path = os.path.join(self.dir_path, 'pyprind.key') + if not os.path.exists(key_path): + with open(key_path, 'wb') as key_file: + key_file.write(os.urandom(16)) + with open(key_path, 'rb') as f: + key = f.read() + return key + + def encrypt(self, text): + text = self.pad(text) + iv = Random.new().read(AES.block_size) + cipher = AES.new(self.key, AES.MODE_CBC, iv) + encrypted_mes = base64.b64encode(iv + cipher.encrypt(text)) + with open(self.file, 'wb') as f: + f.write(encrypted_mes) + + def decrypt(self): + with open(self.file, 'rb') as f: + enc = base64.b64decode(f.read()) + iv = enc[:16] + cipher = AES.new(self.key, AES.MODE_CBC, iv) + return self.unpad(cipher.decrypt(enc[16:])) + + +def setup_email(smtp_server, smtp_port, username, password): + dir_path = os.path.dirname(os.path.abspath(__file__)) + file_path = os.path.join(dir_path, 'email_settings.ini.enc') + cipher = AESCipher() + config = configparser.ConfigParser() + config.add_section('Email') + config.set('Email', 'smtp_server', smtp_server) + config.set('Email', 'smtp_port', str(smtp_port)) + config.set('Email', 'username', username) + config.set('Email', 'password', password) + with open(file_path, 'wb') as f: + config.write(f) + with open(file_path, 'rb') as af: + cipher.encrypt(af.read()) diff --git a/pyprind/prog_class.py b/pyprind/prog_class.py index c0ce211..bebe99f 100644 --- a/pyprind/prog_class.py +++ b/pyprind/prog_class.py @@ -9,12 +9,25 @@ Code Repository: https://github.com/rasbt/pyprind PyPI: https://pypi.python.org/pypi/PyPrind """ - - +import smtplib +import socket import time import sys import os from io import UnsupportedOperation +from email.mime.text import MIMEText +from .email_notification import AESCipher + + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +try: + import configparser +except ImportError: + import ConfigParser as configparser try: import psutil @@ -25,7 +38,7 @@ class Prog(): def __init__(self, iterations, track_time, stream, title, - monitor, update_interval=None): + monitor, update_interval=None, email=False): """ Initializes tracking object. """ self.cnt = 0 self.title = title @@ -54,6 +67,49 @@ def __init__(self, iterations, track_time, stream, title, self.process = psutil.Process() if self.track: self.eta = 1 + self.config = self.load_email_config() if email else False + + def load_email_config(self): + dir_path = os.path.dirname(os.path.abspath(__file__)) + file_path = os.path.join(dir_path, 'email_settings.ini.enc') + if not os.path.exists(file_path): + print('The email config cannot be found, please call' + ' pyprind.setup_email function') + return False + return self.parse_email_config() + + @staticmethod + def parse_email_config(): + buf = StringIO() + cipher = AESCipher() + raw_data = cipher.decrypt() + buf.write(raw_data) + buf.seek(0, os.SEEK_SET) + config = configparser.ConfigParser() + config.readfp(buf) + return config + + def send_email(self, message): + email_address = self.config.get('Email', 'username') + msg = MIMEText(message, 'plain') + msg['From'] = email_address + msg['To'] = email_address + msg['Subject'] = 'Your task has finished' + password = self.config.get('Email', 'password') + self.config.get('Email', 'smtp_port') + s = smtplib.SMTP_SSL() + s.connect(self.config.get('Email', 'smtp_server'), + self.config.get('Email', 'smtp_port')) + try: + s.login(email_address, password) + except smtplib.SMTPAuthenticationError as e: + print('Error occurred while sending email: %s' % e) + return False + try: + s.sendmail(email_address, [email_address], msg.as_string()) + s.quit() + except socket.error as e: + print('Error occurred while sending email: %s' % e) def update(self, iterations=1, item_id=None, force_flush=False): """ @@ -145,8 +201,9 @@ def _finish(self): self.last_progress -= 1 # to force a refreshed _print() self._print() if self.track: - self._stream_out('\nTotal time elapsed: ' + - self._get_time(self.total_time)) + message = '\nTotal time elapsed: ' + \ + self._get_time(self.total_time) + self._stream_out(message) self._stream_out('\n') self.active = False @@ -191,8 +248,12 @@ def __repr__(self): cpu_mem_info = ' CPU %: {:.2f}\n'\ ' Memory %: {:.2f}'.format(cpu_total, mem_total) - - return time_info + '\n' + cpu_mem_info + time_elapsed = '\nTotal time elapsed: ' + \ + self._get_time(self.total_time) + body_message = time_info + '\n' + cpu_mem_info + if self.config: + self.send_email("{}\n{}".format(time_elapsed, body_message)) + return body_message else: return time_info diff --git a/pyprind/progbar.py b/pyprind/progbar.py index 853febd..a559880 100755 --- a/pyprind/progbar.py +++ b/pyprind/progbar.py @@ -43,9 +43,10 @@ class ProgBar(Prog): """ def __init__(self, iterations, track_time=True, width=30, bar_char='#', - stream=2, title='', monitor=False, update_interval=None): + stream=2, title='', monitor=False, update_interval=None, + email=True): Prog.__init__(self, iterations, track_time, - stream, title, monitor, update_interval) + stream, title, monitor, update_interval, email) self.bar_width = width self._adjust_width() self.bar_char = bar_char diff --git a/requirements.txt b/requirements.txt index ed9e437..21da7ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -psutil>=3.2.0 \ No newline at end of file +psutil>=3.2.0 +pycryptodome==3.4 diff --git a/setup.py b/setup.py index ebdee00..cbb4412 100644 --- a/setup.py +++ b/setup.py @@ -12,9 +12,12 @@ from setuptools import setup, find_packages +from pip.req import parse_requirements import pyprind VERSION = pyprind.__version__ +install_requirements = parse_requirements('requirements.txt', session=False) +requires = [str(i.req) for i in install_requirements] setup(name='PyPrind', version=VERSION, @@ -25,12 +28,12 @@ packages=find_packages(), package_data={'': ['LICENSE', 'README.md', - 'requirements.txt', 'CHANGELOG.md', 'CONTRIBUTING.md'], 'tests': ['tests/test_percentage_indicator.py', 'tests/test_progress_bar.py']}, include_package_data=True, + install_requires=requires, license='BSD 3-Clause', platforms='any', classifiers=[ From ec817b5d2ea36bf37b8d2080d604c5a47c40038f Mon Sep 17 00:00:00 2001 From: Zaytsev Dmitry Date: Fri, 10 Mar 2017 23:54:24 +0300 Subject: [PATCH 2/5] added python 3 support and fixed some small bugs --- pyprind/__init__.py | 2 +- pyprind/email_notification.py | 26 ++++++++++++++++++-------- pyprind/prog_class.py | 10 ++++++---- pyprind/progbar.py | 2 +- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/pyprind/__init__.py b/pyprind/__init__.py index b541494..e36094f 100644 --- a/pyprind/__init__.py +++ b/pyprind/__init__.py @@ -18,4 +18,4 @@ from .email_notification import setup_email -__version__ = '2.9.8' +__version__ = '2.9.9dev0' diff --git a/pyprind/email_notification.py b/pyprind/email_notification.py index 3934751..d066e65 100644 --- a/pyprind/email_notification.py +++ b/pyprind/email_notification.py @@ -33,10 +33,11 @@ def unpad(s): return s[:-ord(s[len(s) - 1:])] def get_current_path(self): - self.file = os.path.join(self.dir_path, 'email_settings.ini.enc') + self.file = os.path.join(get_pyprind_config_dir(), + 'email_settings.ini.enc') def generate_key(self): - key_path = os.path.join(self.dir_path, 'pyprind.key') + key_path = os.path.join(get_pyprind_config_dir(), 'pyprind.key') if not os.path.exists(key_path): with open(key_path, 'wb') as key_file: key_file.write(os.urandom(16)) @@ -45,7 +46,7 @@ def generate_key(self): return key def encrypt(self, text): - text = self.pad(text) + text = str.encode(self.pad(text)) iv = Random.new().read(AES.block_size) cipher = AES.new(self.key, AES.MODE_CBC, iv) encrypted_mes = base64.b64encode(iv + cipher.encrypt(text)) @@ -53,7 +54,7 @@ def encrypt(self, text): f.write(encrypted_mes) def decrypt(self): - with open(self.file, 'rb') as f: + with open(self.file, 'r') as f: enc = base64.b64decode(f.read()) iv = enc[:16] cipher = AES.new(self.key, AES.MODE_CBC, iv) @@ -61,8 +62,11 @@ def decrypt(self): def setup_email(smtp_server, smtp_port, username, password): - dir_path = os.path.dirname(os.path.abspath(__file__)) - file_path = os.path.join(dir_path, 'email_settings.ini.enc') + """Create and encrypt email config file""" + pyprind_dir = get_pyprind_config_dir() + if not os.path.exists(pyprind_dir): + os.makedirs(pyprind_dir) + file_path = os.path.join(pyprind_dir, 'email_settings.ini.enc') cipher = AESCipher() config = configparser.ConfigParser() config.add_section('Email') @@ -70,7 +74,13 @@ def setup_email(smtp_server, smtp_port, username, password): config.set('Email', 'smtp_port', str(smtp_port)) config.set('Email', 'username', username) config.set('Email', 'password', password) - with open(file_path, 'wb') as f: + with open(file_path, 'w') as f: config.write(f) - with open(file_path, 'rb') as af: + with open(file_path, 'r') as af: cipher.encrypt(af.read()) + + +def get_pyprind_config_dir(): + home = os.path.expanduser("~") + config_path = os.path.join(home, '.pyprind') + return config_path diff --git a/pyprind/prog_class.py b/pyprind/prog_class.py index bebe99f..ad21b92 100644 --- a/pyprind/prog_class.py +++ b/pyprind/prog_class.py @@ -16,7 +16,7 @@ import os from io import UnsupportedOperation from email.mime.text import MIMEText -from .email_notification import AESCipher +from .email_notification import AESCipher, get_pyprind_config_dir try: @@ -70,7 +70,7 @@ def __init__(self, iterations, track_time, stream, title, self.config = self.load_email_config() if email else False def load_email_config(self): - dir_path = os.path.dirname(os.path.abspath(__file__)) + dir_path = get_pyprind_config_dir() file_path = os.path.join(dir_path, 'email_settings.ini.enc') if not os.path.exists(file_path): print('The email config cannot be found, please call' @@ -83,7 +83,7 @@ def parse_email_config(): buf = StringIO() cipher = AESCipher() raw_data = cipher.decrypt() - buf.write(raw_data) + buf.write(raw_data.decode()) buf.seek(0, os.SEEK_SET) config = configparser.ConfigParser() config.readfp(buf) @@ -99,7 +99,7 @@ def send_email(self, message): self.config.get('Email', 'smtp_port') s = smtplib.SMTP_SSL() s.connect(self.config.get('Email', 'smtp_server'), - self.config.get('Email', 'smtp_port')) + self.config.getint('Email', 'smtp_port')) try: s.login(email_address, password) except smtplib.SMTPAuthenticationError as e: @@ -255,6 +255,8 @@ def __repr__(self): self.send_email("{}\n{}".format(time_elapsed, body_message)) return body_message else: + if self.config: + self.send_email(time_info) return time_info def __str__(self): diff --git a/pyprind/progbar.py b/pyprind/progbar.py index a559880..a9b5bb7 100755 --- a/pyprind/progbar.py +++ b/pyprind/progbar.py @@ -44,7 +44,7 @@ class ProgBar(Prog): """ def __init__(self, iterations, track_time=True, width=30, bar_char='#', stream=2, title='', monitor=False, update_interval=None, - email=True): + email=False): Prog.__init__(self, iterations, track_time, stream, title, monitor, update_interval, email) self.bar_width = width From 95285d4333113ccb293c1bef798cc353a7cd674d Mon Sep 17 00:00:00 2001 From: Zaytsev Dmitry Date: Sat, 11 Mar 2017 00:02:10 +0300 Subject: [PATCH 3/5] restore setup,py --- setup.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/setup.py b/setup.py index cbb4412..9ffe238 100644 --- a/setup.py +++ b/setup.py @@ -12,12 +12,9 @@ from setuptools import setup, find_packages -from pip.req import parse_requirements import pyprind VERSION = pyprind.__version__ -install_requirements = parse_requirements('requirements.txt', session=False) -requires = [str(i.req) for i in install_requirements] setup(name='PyPrind', version=VERSION, @@ -33,7 +30,6 @@ 'tests': ['tests/test_percentage_indicator.py', 'tests/test_progress_bar.py']}, include_package_data=True, - install_requires=requires, license='BSD 3-Clause', platforms='any', classifiers=[ From 0bfd04c82847e82fdeb8649be0932f747ccb666f Mon Sep 17 00:00:00 2001 From: Zaytsev Dmitry Date: Wed, 15 Mar 2017 17:05:16 +0300 Subject: [PATCH 4/5] added base example to jupyter notebook and setup_email example in readme --- README.md | 57 ++++++++++++++++++++++++------------- examples/pyprind_demo.ipynb | 37 +++++++++++++++++++++++- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index dcc80b5..086e5f3 100644 --- a/README.md +++ b/README.md @@ -127,61 +127,80 @@ Alternatively, the progress can be tracked via the equivalent generator function ##### ProgBar *`ProgBar(iterations, track_time=True, width=30, bar_char='#', - stream=2, title='', monitor=False, update_interval=None))`* + stream=2, title='', monitor=False, update_interval=None, email=False))`* -- iterations : `int` +- iterations : `int` Number of iterations for the iterative computation. -- track_time : `bool` (default: `True`) +- track_time : `bool` (default: `True`) Prints elapsed time when loop has finished. -- width : `int` (default: 30) +- width : `int` (default: 30) Sets the progress bar width in characters. -- stream : `int` (default: 2). +- stream : `int` (default: 2). Setting the output stream. Takes `1` for stdout, `2` for stderr, or a custom stream object -- title : `str` (default: `''`) +- title : `str` (default: `''`) Setting a title for the progress bar. -- monitor : `bool` (default: `False`) +- monitor : `bool` (default: `False`) Monitors CPU and memory usage if `True` (requires `psutil` package). -- update_interval : float or int (default: `None`) +- update_interval : float or int (default: `None`) The update_interval in seconds controls how often the progress is flushed to the screen. Automatic mode if `update_interval=None`. +- email : `bool` (default: False) + If `True` sends an email notification after finishing the task ##### ProgPercent *`ProgPercent(iterations, track_time=True, - stream=2, title='', monitor=False, update_interval=None)`* + stream=2, title='', monitor=False, update_interval=None, email=False)`* -- iterations : `int` - Number of iterations for the iterative computation. -- track_time : `bool` (default: `True`) +- iterations : `int` + Number of iterations for the iterative computation. +- track_time : `bool` (default: `True`) Prints elapsed time when loop has finished. -- stream : `int` (default: 2). +- stream : `int` (default: 2). Setting the output stream. Takes `1` for stdout, `2` for stderr, or a custom stream object -- title : `str` (default : `''`). +- title : `str` (default : `''`). Setting a title for the percentage indicator. -- monitor : `bool` (default: `False`) +- monitor : `bool` (default: `False`) Monitors CPU and memory usage if `True` (requires `psutil` package). -- update_interval : float or int (default: `None`) +- update_interval : float or int (default: `None`) The update_interval in seconds controls how often the progress is flushed to the screen. Automatic mode if `update_interval=None`. +- email : `bool` (default: False) + If `True` sends an email notification after finishing the task ##### update method *`update(iterations=1, item_id=None, force_flush=False)`* -- iterations : int (default: `1`) +- iterations : int (default: `1`) default argument can be changed to integer values `>=1` in order to update the progress indicators more than once per iteration. -- item_id : str (default: `None`) +- item_id : str (default: `None`) Print an item_id sring behind the progress bar -- force_flush : bool (default: `False`) +- force_flush : bool (default: `False`) If True, flushes the progress indicator to the output screen in each iteration. + + +##### Enable email notifications + +*`pyprind.setup_email(smtp_server, smtp_port, username, password)`* + +- smtp_server : str +- smtp_port : int +- username : str +your full email username example (pyprind@pyprind.com) +- password : str +your password + +If you want to use email notifications you can call function *`pyprind.setup_email`* only once and it will create an encrypted file with your email config and will be using it, if you want to change the email config just call *`pyprind.setup_email`* with new parameters and it will rewrite the email config file. +
diff --git a/examples/pyprind_demo.ipynb b/examples/pyprind_demo.ipynb index edf1c3d..76cbef4 100644 --- a/examples/pyprind_demo.ipynb +++ b/examples/pyprind_demo.ipynb @@ -78,7 +78,8 @@ "- [Progress Bar/Percentage Indicator - Changing the output stream](#Progress-Bar/Percentage-Indicator---Setting-a-title)\n", "- [Stopping the Progress Bar/Percentage Indicator early](#Stopping-the-Progress-Bar/Percentage-Indicator-early)\n", "- [Choosing your own progress bar style](#Choosing-your-own-progress-bar-style)\n", - "- [Controlling the update frequency](#Controlling-the-update-frequency)" + "- [Controlling the update frequency](#Controlling-the-update-frequency)\n", + "- [Setting up email notification](#Setting-up-email-notification)" ] }, { @@ -1038,6 +1039,40 @@ " time.sleep(0.2) # do some computation\n", " bar.update()" ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": false + }, + "source": [ + "## Setting up email notification" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[100 %] Time elapsed: 00:00:20 | ETA: 00:00:00\n", + "Total time elapsed: 00:00:20\n" + ] + } + ], + "source": [ + "n = 100\n", + "pyprind.setup_email('smtp.example.ru', 465, 'example@example.com', 'password')\n", + "bar = pyprind.ProgPercent(n, update_interval=4, email=True)\n", + "for i in range(n):\n", + " time.sleep(0.2) # do some computation\n", + " bar.update()" + ] } ], "metadata": { From 37a05b62c95e3d3e3609fe7c54ec3181276b8642 Mon Sep 17 00:00:00 2001 From: Zaytsev Dmitry Date: Wed, 15 Mar 2017 18:25:19 +0300 Subject: [PATCH 5/5] restored requirements in install --- README.md | 46 +++++++++++++++++++++---------------------- pyprind/prog_class.py | 2 ++ setup.py | 1 + 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 086e5f3..83b794e 100644 --- a/README.md +++ b/README.md @@ -129,24 +129,24 @@ Alternatively, the progress can be tracked via the equivalent generator function *`ProgBar(iterations, track_time=True, width=30, bar_char='#', stream=2, title='', monitor=False, update_interval=None, email=False))`* -- iterations : `int` +- iterations : `int` Number of iterations for the iterative computation. -- track_time : `bool` (default: `True`) +- track_time : `bool` (default: `True`) Prints elapsed time when loop has finished. -- width : `int` (default: 30) +- width : `int` (default: 30) Sets the progress bar width in characters. -- stream : `int` (default: 2). +- stream : `int` (default: 2). Setting the output stream. Takes `1` for stdout, `2` for stderr, or a custom stream object -- title : `str` (default: `''`) +- title : `str` (default: `''`) Setting a title for the progress bar. -- monitor : `bool` (default: `False`) +- monitor : `bool` (default: `False`) Monitors CPU and memory usage if `True` (requires `psutil` package). -- update_interval : float or int (default: `None`) +- update_interval : float or int (default: `None`) The update_interval in seconds controls how often the progress is flushed to the screen. Automatic mode if `update_interval=None`. -- email : `bool` (default: False) +- email : `bool` (default: False) If `True` sends an email notification after finishing the task ##### ProgPercent @@ -154,35 +154,35 @@ Alternatively, the progress can be tracked via the equivalent generator function *`ProgPercent(iterations, track_time=True, stream=2, title='', monitor=False, update_interval=None, email=False)`* -- iterations : `int` - Number of iterations for the iterative computation. -- track_time : `bool` (default: `True`) +- iterations : `int` + Number of iterations for the iterative computation. +- track_time : `bool` (default: `True`) Prints elapsed time when loop has finished. -- stream : `int` (default: 2). +- stream : `int` (default: 2). Setting the output stream. Takes `1` for stdout, `2` for stderr, or a custom stream object -- title : `str` (default : `''`). +- title : `str` (default : `''`). Setting a title for the percentage indicator. -- monitor : `bool` (default: `False`) +- monitor : `bool` (default: `False`) Monitors CPU and memory usage if `True` (requires `psutil` package). -- update_interval : float or int (default: `None`) +- update_interval : float or int (default: `None`) The update_interval in seconds controls how often the progress is flushed to the screen. Automatic mode if `update_interval=None`. -- email : `bool` (default: False) +- email : `bool` (default: False) If `True` sends an email notification after finishing the task ##### update method *`update(iterations=1, item_id=None, force_flush=False)`* -- iterations : int (default: `1`) +- iterations : int (default: `1`) default argument can be changed to integer values `>=1` in order to update the progress indicators more than once per iteration. -- item_id : str (default: `None`) +- item_id : str (default: `None`) Print an item_id sring behind the progress bar -- force_flush : bool (default: `False`) +- force_flush : bool (default: `False`) If True, flushes the progress indicator to the output screen in each iteration. @@ -192,11 +192,11 @@ Alternatively, the progress can be tracked via the equivalent generator function *`pyprind.setup_email(smtp_server, smtp_port, username, password)`* -- smtp_server : str -- smtp_port : int -- username : str +- smtp_server : str +- smtp_port : int +- username : str your full email username example (pyprind@pyprind.com) -- password : str +- password : str your password If you want to use email notifications you can call function *`pyprind.setup_email`* only once and it will create an encrypted file with your email config and will be using it, if you want to change the email config just call *`pyprind.setup_email`* with new parameters and it will rewrite the email config file. diff --git a/pyprind/prog_class.py b/pyprind/prog_class.py index ad21b92..cb8efba 100644 --- a/pyprind/prog_class.py +++ b/pyprind/prog_class.py @@ -9,6 +9,8 @@ Code Repository: https://github.com/rasbt/pyprind PyPI: https://pypi.python.org/pypi/PyPrind """ + + import smtplib import socket import time diff --git a/setup.py b/setup.py index 9ffe238..ebdee00 100644 --- a/setup.py +++ b/setup.py @@ -25,6 +25,7 @@ packages=find_packages(), package_data={'': ['LICENSE', 'README.md', + 'requirements.txt', 'CHANGELOG.md', 'CONTRIBUTING.md'], 'tests': ['tests/test_percentage_indicator.py',