From 5b48ef9c34773b0aad0cee6ed05d9e2b718260fb Mon Sep 17 00:00:00 2001 From: "jim.molecule" Date: Sat, 21 Mar 2020 13:32:03 +0300 Subject: [PATCH] release 1.0 --- .github/ISSUE_TEMPLATE/bug_report.md | 38 ++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 22 +++++ .gitignore | 23 +++-- .idea/dictionaries/jim_molecule.xml | 14 +++ README.md | 80 ++++++++-------- _config.yml | 1 - examples/histogram.png | Bin 0 -> 33297 bytes histogramer/__init__.py | 3 + histogramer/__main__.py | 22 +++++ histogramer/src/__init__.py | 3 + histogramer/src/helpers/__init__.py | 3 + histogramer/src/helpers/args_helper.py | 56 +++++++++++ histogramer/src/helpers/datetime_helper.py | 23 +++++ histogramer/src/helpers/log_helper.py | 60 ++++++++++++ histogramer/src/helpers/random_helper.py | 16 ++++ histogramer/src/histogram.py | 105 +++++++++++++++++++++ histogramer/tests/__init__.py | 3 + histogramer/tests/pytest.ini | 10 ++ histogramer/tests/test_args_helper.py | 105 +++++++++++++++++++++ histogramer/tests/test_datetime_helper.py | 87 +++++++++++++++++ histogramer/tests/test_log_helper.py | 96 +++++++++++++++++++ histogramer/tests/test_random_helper.py | 31 ++++++ requirements_main.txt | 17 ++++ requirements_tests.txt | 15 +++ setup.py | 32 +++++++ 25 files changed, 821 insertions(+), 44 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .idea/dictionaries/jim_molecule.xml delete mode 100644 _config.yml create mode 100644 examples/histogram.png create mode 100644 histogramer/__init__.py create mode 100644 histogramer/__main__.py create mode 100644 histogramer/src/__init__.py create mode 100644 histogramer/src/helpers/__init__.py create mode 100644 histogramer/src/helpers/args_helper.py create mode 100644 histogramer/src/helpers/datetime_helper.py create mode 100644 histogramer/src/helpers/log_helper.py create mode 100644 histogramer/src/helpers/random_helper.py create mode 100644 histogramer/src/histogram.py create mode 100644 histogramer/tests/__init__.py create mode 100644 histogramer/tests/pytest.ini create mode 100644 histogramer/tests/test_args_helper.py create mode 100644 histogramer/tests/test_datetime_helper.py create mode 100644 histogramer/tests/test_log_helper.py create mode 100644 histogramer/tests/test_random_helper.py create mode 100644 requirements_main.txt create mode 100644 requirements_tests.txt create mode 100644 setup.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..0f3aab2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,22 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. +I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions +or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore index b6e4761..ea52ded 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ parts/ sdist/ var/ @@ -85,10 +83,12 @@ ipython_config.py .python-version # pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. +# According to pypa/pipenv#598, it is recommended to include +# Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific +# dependencies or dependencies having no cross-platform support, +# pipenv may install dependencies that don't work, or not install +# all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow @@ -127,3 +127,14 @@ dmypy.json # Pyre type checker .pyre/ + +# Exclude IntelliJ files, they will be recreated from the build files +.idea/* +# But keep dictionaries to have less false positives in spellcheck inspection +!.idea/dictionaries + +# PyCharm files +.DS_Store + +# Application log files +histogramer/.logs/ diff --git a/.idea/dictionaries/jim_molecule.xml b/.idea/dictionaries/jim_molecule.xml new file mode 100644 index 0000000..b029c5e --- /dev/null +++ b/.idea/dictionaries/jim_molecule.xml @@ -0,0 +1,14 @@ + + + + addopts + asctime + asyncio + histogramer + histogrammer + komissarov + levelname + pytest + + + \ No newline at end of file diff --git a/README.md b/README.md index 6914698..2c8ab32 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,43 @@ -## Welcome to GitHub Pages - -You can use the [editor on GitHub](https://github.com/jim-molecule/histogramer/edit/master/README.md) to maintain and preview the content for your website in Markdown files. - -Whenever you commit to this repository, GitHub Pages will run [Jekyll](https://jekyllrb.com/) to rebuild the pages in your site, from the content in your Markdown files. - -### Markdown - -Markdown is a lightweight and easy-to-use syntax for styling your writing. It includes conventions for - -```markdown -Syntax highlighted code block - -# Header 1 -## Header 2 -### Header 3 - -- Bulleted -- List - -1. Numbered -2. List - -**Bold** and _Italic_ and `Code` text - -[Link](url) and ![Image](src) -``` - -For more details see [GitHub Flavored Markdown](https://guides.github.com/features/mastering-markdown/). - -### Jekyll Themes - -Your Pages site will use the layout and styles from the Jekyll theme you have selected in your [repository settings](https://github.com/jim-molecule/histogramer/settings). The name of this theme is saved in the Jekyll `_config.yml` configuration file. - -### Support or Contact - -Having trouble with Pages? Check out our [documentation](https://help.github.com/categories/github-pages-basics/) or [contact support](https://github.com/contact) and we’ll help you sort it out. +# Compatibility # +Histogramer has tested using `python 3.6.8` virtual environment in: + * Windows 10 OS + * macOS Catalina + +# Description # +This tool analyze text files in a directory (which was specified by user) +and it's sub folders. Statistics by words count is gathering +for each text file was found. Then a histogram will be building +by this statistics. + + # Example # +![](examples/histogram.png) + +# Installation # +* #### Using a `*.whl` dist: #### + * Download the latest `*.whl` version from a + [releases page](https://github.com/jim-molecule/histogramer/releases) + * Install histogramer: `pip3 install --upgrade path_to_wheel.whl` + +* #### Using sources: #### + * Remove dist files from project root: + * Windows: `RMDIR /Q/S build dist histogramer.egg-info` + * Mac: `rm -r build dist histogramer.egg-info` + * Install wheel: `pip3 install wheel` + * Build dist: `python setup.py bdist_wheel` + * Install histogramer: `pip3 install --upgrade path_to_wheel.whl` + +# Issues # +Please, report about any issues to an +[issues page](https://github.com/jim-molecule/histogramer/issues/new/choose) +with `~/.logs` folder's files attached. + +# Testing # +For run all tests, please, use `pytest ./histogramer/tests` from project root. +Pytest options are placed in `~/histogramer/tests/pytest.ini` file. + +# Usage # +Run a `python -m histogramer --help` script. + +# Virtual environment # +For main usage `~/requirements_main.txt` should be installed. +For testing: `~/requirements_tests.txt`. diff --git a/_config.yml b/_config.yml deleted file mode 100644 index 2f7efbe..0000000 --- a/_config.yml +++ /dev/null @@ -1 +0,0 @@ -theme: jekyll-theme-minimal \ No newline at end of file diff --git a/examples/histogram.png b/examples/histogram.png new file mode 100644 index 0000000000000000000000000000000000000000..fd9a78ed4ca5adf84bfce762d61414246cae899a GIT binary patch literal 33297 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 5 * (1024 ** 2), + lambda: None)() + # create folder for file logs if not exists + Path(path).mkdir(parents=True, exist_ok=True) + + rotating_file_handler = RotatingFileHandler(filename=file_name) + rotating_file_handler.setFormatter(fmt=log_formatter) + rotating_file_handler.setLevel(level=logging.INFO) + logger.addHandler(hdlr=rotating_file_handler) + + +async def init_logger(folder_name, root_path): + """ + Configure logger for logging events in console (and in a file, optional). + :param folder_name: Name of the folder where logs will be stored. + :param root_path: Path to the log folder. + :return: Instance of logger. + """ + log_formatter = logging.Formatter("[%(asctime)s] " + "[%(threadName)s] " + "[%(levelname)s] " + "%(message)s") + logger = logging.getLogger() + logger.setLevel(level=logging.INFO) + + console_handler = logging.StreamHandler() + console_handler.setFormatter(fmt=log_formatter) + console_handler.setLevel(level=logging.ERROR) + logger.addHandler(hdlr=console_handler) + + {"0": lambda: None}.get( + root_path, + lambda: _add_rotating_file_handler(folder_name, + logger, + log_formatter, + root_path))() + return logger diff --git a/histogramer/src/helpers/random_helper.py b/histogramer/src/helpers/random_helper.py new file mode 100644 index 0000000..9cd964b --- /dev/null +++ b/histogramer/src/helpers/random_helper.py @@ -0,0 +1,16 @@ +""" +Helps to generate random objects. +""" +import secrets +import string + + +async def get_random_string(string_length=10): + """ + Generate a random string of fixed length. + :param string_length: Length of string. + :return: Random string of fixed length. + """ + + return "".join(secrets.choice(string.ascii_lowercase) + for _ in range(string_length)) diff --git a/histogramer/src/histogram.py b/histogramer/src/histogram.py new file mode 100644 index 0000000..e344c2f --- /dev/null +++ b/histogramer/src/histogram.py @@ -0,0 +1,105 @@ +""" +Implementation of main functions for histogram building. +""" +import sys +from datetime import datetime +from multiprocessing import Pool +from pathlib import Path + +import matplotlib.pyplot as plt +import seaborn +from halo import Halo + +from histogramer.src.helpers.datetime_helper import ( + datetime_to_str, + get_duration +) + + +def _exit_if_empty_data(logger): + """ + Write log message and exit from application. + :param logger: Instance of logger. + :return: None. + """ + logger.warning("there is no data for a histogram building") + sys.exit() + + +def _count_words(file): + """ + Count words number in the file. + :param file: Path to the file which will be processed. + :return: Words count in the current file or error message. + """ + try: + return len(file.read_text().split()) + except (IOError, UnicodeDecodeError) as exception: + return f"Can't read '{file}'. Error: {exception}" + + +async def process_text_files(extension, logger, path): + """ + Calculate words count for each file (with specified extension) in that dir + and it's sub folders. + :param extension: Only files with such extension will be processed. + :param logger: Instance of logger. + :param path: Root directory in which (and it's sub folders) files will + be processed. + :return: List of numbers where each number equals words count + in the file. + """ + with Halo("Processing text files...") as spinner: + start_time = datetime.utcnow() + with Pool() as pool: + words_count = [] + for result in pool.imap_unordered(_count_words, + Path(path).rglob(extension)): + {True: lambda r=result: logger.warning(r), + False: lambda r=result: words_count.append(r) + }.get(isinstance(result, str), lambda: None)() + spinner.text = f"{len(words_count)} files processed" + end_time = datetime.utcnow() + spinner.succeed(f"[{await datetime_to_str(end_time)}] " + + f"{len(words_count)} files successfully processed for" + + f" {await get_duration(start_time, end_time)} " + "seconds.") + return words_count + + +async def show_histogram(logger, words_count): + """ + Show a histogram using words count by text files. + :param logger: Instance of logger. + :param words_count: List of numbers where each number equals words count + in the file. + :return: None. + """ + {True: lambda: _exit_if_empty_data(logger)}.get( + len(words_count) == 0, lambda: None)() + + start_time = datetime.utcnow() + message = f"[{await datetime_to_str(start_time)}] Building histogram..." + with Halo(text=message) as spinner: + plt.figure("histogramer", + dpi=75, + facecolor=(0, 0, 0), + figsize=(16, 10)) + plt.style.use(style="dark_background") + plt.xlabel(xlabel="Words Count") + plt.ylabel(ylabel="Files Count") + plt.title(label="Bar Chart for Words Count in Files", fontsize=22) + seaborn.set() + seaborn.distplot(a=words_count, kde=False) + plt.grid(alpha=0.1, which="both", linestyle="--") + plt.grid(alpha=0.08, which="minor", linestyle="-.") + plt.xticks(rotation=45) + plt.tight_layout() + + end_time = datetime.utcnow() + spinner.succeed(f"[{await datetime_to_str(end_time)}] " + + "Histogram successfully built for " + + f"{await get_duration(start_time, end_time)} " + "seconds.") + logger.info(msg="Histogram successfully built") + plt.show() diff --git a/histogramer/tests/__init__.py b/histogramer/tests/__init__.py new file mode 100644 index 0000000..b234b64 --- /dev/null +++ b/histogramer/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Init for 'tests' python package. +""" diff --git a/histogramer/tests/pytest.ini b/histogramer/tests/pytest.ini new file mode 100644 index 0000000..d0314e0 --- /dev/null +++ b/histogramer/tests/pytest.ini @@ -0,0 +1,10 @@ +[pytest] +addopts = -k test -m "not skip" -n auto --reruns 1 --reruns-delay 0 +markers = + args_helper + asyncio_helper + datetime_helper + log_helper + random_helper + serial + skip diff --git a/histogramer/tests/test_args_helper.py b/histogramer/tests/test_args_helper.py new file mode 100644 index 0000000..296283b --- /dev/null +++ b/histogramer/tests/test_args_helper.py @@ -0,0 +1,105 @@ +""" +Tests for args_helper module. +""" +import os +import sys + +import pytest + +from histogramer.src.helpers.args_helper import get_dir_type, parse_arguments +from histogramer.src.helpers.random_helper import get_random_string + + +@pytest.mark.args_helper +@pytest.mark.asyncio +async def test_get_arguments_valid_path(): + """ + Invoke get_arguments function with valid path argument. + :return: None. + """ + path = os.getcwd() + actual = await parse_arguments(["-p", path]) + assert isinstance(actual.path, str) + assert actual.path == path + + +@pytest.mark.args_helper +@pytest.mark.asyncio +async def test_get_arguments_valid_log(): + """ + Invoke get_arguments function with valid path and log arguments. + :return: None. + """ + path = os.getcwd() + actual = await parse_arguments(["-p", path, "-l", path]) + assert isinstance(actual.path, str) + assert isinstance(actual.log, str) + assert actual.log == path + assert actual.path == path + + +@pytest.mark.args_helper +@pytest.mark.asyncio +async def test_get_arguments_invalid_path(): + """ + Invoke get_arguments function with invalid path argument. + :return: None. + """ + args = ["-p", await get_random_string()] + with pytest.raises(NotADirectoryError): + await parse_arguments(args) + + +@pytest.mark.args_helper +@pytest.mark.asyncio +async def test_get_arguments_invalid_log(): + """ + Invoke get_arguments function with invalid log argument. + :return: None. + """ + args = ["-p", os.getcwd(), "-l", await get_random_string()] + with pytest.raises(NotADirectoryError): + await parse_arguments(args) + + +@pytest.mark.args_helper +@pytest.mark.serial +@pytest.mark.asyncio +async def test_get_arguments_no_arguments(): + """ + Invoke get_arguments function without arguments. + :return: None. + """ + with open(os.devnull, "w") as file: + try: + sys.stderr = file + with pytest.raises(SystemExit): + await parse_arguments() + finally: + sys.stderr = sys.__stderr__ + + +@pytest.mark.args_helper +@pytest.mark.asyncio +@pytest.mark.parametrize("path", ["0", os.getcwd()]) +async def test_dir_type_positive(path): + """ + Invoke dir_type function with valid path argument. + :param path: Path to directory. + :return: None. + """ + actual = get_dir_type(path) + assert isinstance(actual, str) + assert actual == path + + +@pytest.mark.args_helper +@pytest.mark.asyncio +async def test_dir_type_negative(): + """ + Invoke dir_type function with invalid path argument. + :return: None. + """ + path = await get_random_string() + with pytest.raises(NotADirectoryError): + get_dir_type(path) diff --git a/histogramer/tests/test_datetime_helper.py b/histogramer/tests/test_datetime_helper.py new file mode 100644 index 0000000..4213b96 --- /dev/null +++ b/histogramer/tests/test_datetime_helper.py @@ -0,0 +1,87 @@ +""" +Tests for datetime_helper module. +""" +from datetime import datetime, timedelta + +import pytest + +from histogramer.src.helpers.datetime_helper import (datetime_to_str, + get_duration) + +_DATETIME_STR = "2020-02-14 12:44:21.625037" + + +@pytest.mark.datetime_helper +@pytest.mark.asyncio +async def test_datetime_to_str_positive(): + """ + Invoke datetime_to_str function with valid datetime_obj argument. + :return: None. + """ + actual = await datetime_to_str(datetime_obj=datetime.utcnow()) + assert isinstance(actual, str) + + +@pytest.mark.datetime_helper +@pytest.mark.asyncio +@pytest.mark.parametrize("datetime_obj", [None, _DATETIME_STR]) +async def test_datetime_to_str_negative(datetime_obj): + """ + Invoke datetime_to_str function with invalid datetime_obj argument. + :param datetime_obj: Argument type of datetime. + :return: None. + """ + with pytest.raises(AttributeError): + await datetime_to_str(datetime_obj=datetime_obj) + + +@pytest.mark.datetime_helper +@pytest.mark.asyncio +async def test_get_duration_positive(): + """ + Invoke get_duration function with valid start and end arguments. + :return: None. + """ + start = datetime.utcnow() + end = start + timedelta(seconds=1) + actual = await get_duration(start=start, end=end) + assert isinstance(actual, float) + assert actual == round(number=timedelta.total_seconds(end - start), + ndigits=3) + + +@pytest.mark.datetime_helper +@pytest.mark.asyncio +@pytest.mark.parametrize("start, end", + [(None, datetime.utcnow()), + (_DATETIME_STR, datetime.utcnow()), + (datetime.utcnow(), None), + (datetime.utcnow(), _DATETIME_STR), + (None, _DATETIME_STR), + (None, None), + (_DATETIME_STR, _DATETIME_STR), + (_DATETIME_STR, None)]) +async def test_get_duration_negative(start, end): + """ + Invoke get_duration function with invalid start and/or end arguments. + :param start: Event start datetime. + :param end: Event end datetime. + :return: None. + """ + with pytest.raises(TypeError): + await get_duration(start=start, end=end) + + +@pytest.mark.datetime_helper +@pytest.mark.asyncio +async def test_get_duration_swapped_arguments(): + """ + Invoke get_duration function with swapped start and end arguments. + :return: None. + """ + start = datetime.utcnow() + end = start + timedelta(seconds=1) + actual = await get_duration(start=end, end=start) + assert isinstance(actual, float) + assert actual == round(number=timedelta.total_seconds(start - end), + ndigits=3) diff --git a/histogramer/tests/test_log_helper.py b/histogramer/tests/test_log_helper.py new file mode 100644 index 0000000..9776a6d --- /dev/null +++ b/histogramer/tests/test_log_helper.py @@ -0,0 +1,96 @@ +""" +Tests for log_helper module. +""" +import os +import shutil + +import pytest + +from histogramer.src.helpers.log_helper import init_logger +from histogramer.src.helpers.random_helper import get_random_string + + +def __close_logger_handlers(logger): + """ + Close all logger handlers. + :param logger: Instance of logger. + :return: None. + """ + for handler in logger.handlers: + handler.close() + + +async def _remove_dirs_tree(logger, path): + """ + Remove directory and it's sub folders. + if a directory with such path exists. + :param logger: Instance of logger. + :param path: Root directory. + :return: None. + """ + {True: lambda: __close_logger_handlers(logger)}.get( + logger is not None, lambda: None)() + {True: lambda: shutil.rmtree(path, ignore_errors=True)}.get( + os.path.isdir(path), lambda: None)() + + +@pytest.mark.log_helper +@pytest.mark.asyncio +async def test_init_logger_positive(): + """ + Invoke init_logger function with valid arguments. + :return: None. + """ + folder_name = ".test_init_logger_positive" + root_path = os.path.join(os.getcwd(), await get_random_string()) + logger = None + + try: + logger = await init_logger(folder_name, root_path) + logger.info("test_init_logger_positive") + full_path = os.path.join(root_path, folder_name) + with open(os.path.join(full_path, ".histogramer")) as file: + lines = file.readlines() + assert len(lines) == 1 + assert "[INFO] test_init_logger_positive" in lines[0] + finally: + await _remove_dirs_tree(logger, root_path) + + +@pytest.mark.log_helper +@pytest.mark.asyncio +async def test_init_logger_no_file_logging(): + """ + Invoke init_logger function with valid arguments where path == "0". + :return: None. + """ + folder_name = ".test_init_logger_no_file_logging" + root_path = "0" + full_path = os.path.join(os.getcwd(), folder_name) + full_path_2 = os.path.join(root_path, folder_name) + logger = None + + try: + logger = await init_logger(folder_name, root_path) + logger.debug("test_init_logger_positive_no_file_logging") + assert not os.path.isdir(full_path) + assert not os.path.isdir(full_path_2) + finally: + await _remove_dirs_tree(logger, full_path) + await _remove_dirs_tree(logger, full_path_2) + + +@pytest.mark.log_helper +@pytest.mark.asyncio +@pytest.mark.parametrize("folder_name, root_path", + [(None, os.getcwd()), + (os.getcwd(), None), + (None, None)]) +async def test_init_logger_negative(folder_name, root_path): + """ + Invoke init_logger function with invalid arguments. + :return: None. + """ + with pytest.raises(TypeError): + logger = await init_logger(folder_name, root_path) + await _remove_dirs_tree(logger, os.path.join(root_path, folder_name)) diff --git a/histogramer/tests/test_random_helper.py b/histogramer/tests/test_random_helper.py new file mode 100644 index 0000000..1d09ee5 --- /dev/null +++ b/histogramer/tests/test_random_helper.py @@ -0,0 +1,31 @@ +""" +Tests for random_helper. +""" +import pytest + +from histogramer.src.helpers.random_helper import get_random_string + + +@pytest.mark.random_helper +@pytest.mark.asyncio +async def test_get_random_string_no_args(): + """ + Invoke get_random_string function without arguments. + :return: None. + """ + actual = await get_random_string() + assert isinstance(actual, str) + assert len(actual) == 10 + + +@pytest.mark.random_helper +@pytest.mark.asyncio +async def test_get_random_string_positive(): + """ + Invoke get_random_string function with length argument. + :return: None. + """ + length = 5 + actual = await get_random_string(length) + assert isinstance(actual, str) + assert len(actual) == length diff --git a/requirements_main.txt b/requirements_main.txt new file mode 100644 index 0000000..1e28f3c --- /dev/null +++ b/requirements_main.txt @@ -0,0 +1,17 @@ +colorama==0.4.3 +cursor==1.3.4 +cycler==0.10.0 +halo==0.0.29 +kiwisolver==1.1.0 +log-symbols==0.0.14 +matplotlib==3.1.3 +numpy==1.18.1 +pandas==1.0.1 +pyparsing==2.4.6 +python-dateutil==2.8.1 +pytz==2019.3 +scipy==1.4.1 +seaborn==0.10.0 +six==1.14.0 +spinners==0.0.24 +termcolor==1.1.0 diff --git a/requirements_tests.txt b/requirements_tests.txt new file mode 100644 index 0000000..f21fad6 --- /dev/null +++ b/requirements_tests.txt @@ -0,0 +1,15 @@ +apipkg==1.5 +attrs==19.3.0 +execnet==1.7.1 +importlib-metadata==1.5.0 +more-itertools==8.2.0 +packaging==20.1 +pluggy==0.13.1 +py==1.8.1 +pytest==5.3.5 +pytest-asyncio==0.10.0 +pytest-forked==1.1.3 +pytest-rerunfailures==8.0 +pytest-xdist==1.31.0 +wcwidth==0.1.8 +zipp==3.0.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2c1f5bd --- /dev/null +++ b/setup.py @@ -0,0 +1,32 @@ +""" +For package installation only. +""" +import setuptools + + +def read_file(file_name): + """ + Get content of the file. + :param file_name: Name of the file. + :return: Content of the file. + """ + with open(file_name) as file: + return file.read() + + +setuptools.setup( + name="histogramer", + version="1.0.6", + author="Petr Komissarov", + author_email="jim.molecule@gmail.com", + classifiers=[ + "Programming Language :: Python :: 3", + "Operating System :: OS Independent"], + description="Tool for histogram building by words count in files", + install_requires=read_file("requirements_main.txt"), + long_description=read_file("README.md"), + long_description_content_type="text/markdown", + packages=setuptools.find_packages(exclude=["*tests"]), + python_requires=">=3.6, <3.8", + tests_require=read_file("requirements_tests.txt"), + url="https://github.com/jim-molecule/histogramer")