diff --git a/.travis.yml b/.travis.yml index 6cf99d8d2..986f5be63 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,9 @@ python: - 3.8 - 3.7 - 3.6 - - 3.5 + +before_install: + - sudo apt-get -y install openslide-tools # Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors install: pip install -U tox-travis @@ -15,13 +17,13 @@ script: tox # Upload test coverage reports (codecov and deepsource) after_success: -# Upload coverage to codecov -- bash <(curl -s https://codecov.io/bash) -# Install deepsource CLI -- curl https://deepsource.io/cli | sh -- export DEEPSOURCE_DSN=https://sampledsn@deepsource.io -# Report coverage artifact to 'test-coverage' analyzer --./bin/deepsource report --analyzer test-coverage --key python --value-file ./coverage.xml + # Upload coverage to codecov + - bash <(curl -s https://codecov.io/bash) + # Install deepsource CLI + - curl https://deepsource.io/cli | sh + - export DEEPSOURCE_DSN=https://sampledsn@deepsource.io + # Report coverage artifact to 'test-coverage' analyzer + - ./bin/deepsource report --analyzer test-coverage --key python --value-file ./coverage.xml # Assuming you have installed the travis-ci CLI tool, after you # create the Github repo and add it to Travis, run the diff --git a/README.rst b/README.rst index e2c04b5a8..822719eab 100644 --- a/README.rst +++ b/README.rst @@ -1,25 +1,56 @@ +.. raw:: html + +

+ +

+ =========== TIA Toolbox =========== +Computational Pathology Toolbox developed by TIA Lab + +Please try + +:: + + python -m tiatoolbox -h + +Getting Started +=============== +First, install OpenSlide `here `__. Then, create and +activate the conda environment: +pip +---- +:: + pip install -r requirements_dev.txt -Computational pathology toolbox developed by TIA Lab. +conda +----- +:: + conda env create --name tiatoolbox --file requirements.conda.yml + conda activate tiatoolbox +tiatoolbox --help +======================= -Features --------- +:: -* TODO + usage: tiatoolbox [-h] [--version] [--verbose VERBOSE] + {slide_info} + ... -Credits -------- + positional arguments: + {slide_info} + slide_info usage: python -m tiatoolbox slide_info -h -This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. + optional arguments: + -h, --help show this help message and exit + --version show program`s version number and exit + --verbose VERBOSE -.. _Cookiecutter: https://github.com/audreyr/cookiecutter -.. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage diff --git a/docs/conf.py b/docs/conf.py index 417225b0d..ce9d90bf5 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,7 +32,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/index.rst b/docs/index.rst index 20666ba40..decd3e255 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,5 +1,5 @@ Welcome to TIA Toolbox's documentation! -====================================== +======================================= .. toctree:: :maxdepth: 2 diff --git a/docs/tialab_logo.png b/docs/tialab_logo.png new file mode 100644 index 000000000..79fdc319c Binary files /dev/null and b/docs/tialab_logo.png differ diff --git a/docs/usage.rst b/docs/usage.rst index b707c0338..2c7c1b5c9 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -5,3 +5,47 @@ Usage To use TIA Toolbox in a project:: import tiatoolbox + + +---------- +Dataloader +---------- +.. automodule:: tiatoolbox.dataloader + +^^^^^^^^^^^^^^^^^^^^ +dataloader.wsireader +^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: tiatoolbox.dataloader.wsireader + :members: WSIReader + :special-members: __init__ + +^^^^^^^^^^^^^^^^^^^^^ +dataloader.slide_info +^^^^^^^^^^^^^^^^^^^^^ + +.. automodule:: tiatoolbox.dataloader.slide_info + :members: slide_info + +---------- +Decorators +---------- +.. automodule:: tiatoolbox.decorators + +^^^^^^^^^^^^^^^^^^^^ +decorators.multiproc +^^^^^^^^^^^^^^^^^^^^ +.. automodule:: tiatoolbox.decorators.multiproc + :members: TIAMultiProcess + :special-members: __init__, __call__ + +------ +Utils +------ +.. automodule:: tiatoolbox.utils + +^^^^^^^^^^ +utils.misc +^^^^^^^^^^ +.. automodule:: tiatoolbox.utils.misc + :members: save_yaml, split_path_name_ext, grab_files_from_dir diff --git a/requirements.conda.yml b/requirements.conda.yml new file mode 100644 index 000000000..219efe7e0 --- /dev/null +++ b/requirements.conda.yml @@ -0,0 +1,22 @@ +name: tiatoolbox +channels: + - conda + - conda-forge + - defaults +dependencies: + - python=3.6 + - setuptools==45.1.0 + - Click==7.0 + - cython=0.29.15 + - h5py=2.8.0 + - matplotlib-base=3.1.3 + - numpy=1.18.1 + - opencv=4.2 + - pillow=7.0.0 + - pip=20.0.2 + - pyyaml=5.3.1 + - requests=2.23.0 + - pathos==0.2.5 + - pip: + - openslide-python==1.1.1 + diff --git a/requirements_dev.txt b/requirements_dev.txt index 75845e43c..eb180fe85 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ -pip==19.2.3 +pip==20.0.2 bump2version==0.5.11 wheel==0.33.6 watchdog==0.9.0 @@ -8,6 +8,10 @@ coverage==5.1 Sphinx==1.8.5 twine==1.14.0 Click==7.0 -pytest==4.6.5 -pytest-runner==5.1 +setuptools==45.1.0 +pytest==5.4.2 +pytest-runner==5.2 +opencv-python==4.2.0.34 +pathos==0.2.5 +openslide-python==1.1.1 pytest-cov==2.9.0 diff --git a/setup.py b/setup.py index 93b60212b..ba156103a 100644 --- a/setup.py +++ b/setup.py @@ -25,19 +25,18 @@ setup( author="TIA Lab", author_email="tialab@dcs.warwick.ac.uk", - python_requires=">=3.5", + python_requires=">=3.6", classifiers=[ "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Developers", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", ], description="Computational pathology toolbox developed by TIA Lab.", - entry_points={"console_scripts": ["tiatoolbox=tiatoolbox.cli:main",],}, + entry_points={"console_scripts": ["tiatoolbox=tiatoolbox.cli:main", ], }, install_requires=requirements, long_description=readme + "\n\n" + history, include_package_data=True, diff --git a/tests/test_tiatoolbox.py b/tests/test_tiatoolbox.py index b884679e7..9cf4d8d30 100644 --- a/tests/test_tiatoolbox.py +++ b/tests/test_tiatoolbox.py @@ -1,37 +1,108 @@ #!/usr/bin/env python """Tests for `tiatoolbox` package.""" - import pytest -from click.testing import CliRunner - -from tiatoolbox import tiatoolbox +from tiatoolbox.dataloader.slide_info import slide_info +from tiatoolbox import utils from tiatoolbox import cli +from tiatoolbox import __version__ + +from click.testing import CliRunner +import requests +import os +import pathlib @pytest.fixture -def response(): - """Sample pytest fixture. +def _response_ndpi(request): + """ + Sample pytest fixture for ndpi images + Download ndpi image for pytest + """ + ndpi_file_path = pathlib.Path(__file__).parent.joinpath("CMU-1.ndpi") + if not pathlib.Path.is_file(ndpi_file_path): + r = requests.get( + "http://openslide.cs.cmu.edu/download/openslide-testdata" + "/Hamamatsu/CMU-1.ndpi" + ) + with open(ndpi_file_path, "wb") as f: + f.write(r.content) + + def close_ndpi(): + if pathlib.Path.is_file(ndpi_file_path): + os.remove(str(ndpi_file_path)) + + request.addfinalizer(close_ndpi) + return _response_ndpi - See more at: http://doc.pytest.org/en/latest/fixture.html + +@pytest.fixture +def _response_svs(request): """ - # import requests - # return requests.get('https://github.com/audreyr/cookiecutter-pypackage') + Sample pytest fixture for svs images + Download ndpi image for pytest + """ + svs_file_path = pathlib.Path(__file__).parent.joinpath("CMU-1.svs") + if not pathlib.Path.is_file(svs_file_path): + r = requests.get( + "http://openslide.cs.cmu.edu/download/openslide-testdata" + "/Hamamatsu/CMU-1.ndpi" + ) + with open(svs_file_path, "wb") as f: + f.write(r.content) + + def close_ndpi(): + if pathlib.Path.is_file(svs_file_path): + os.remove(str(svs_file_path)) + + request.addfinalizer(close_ndpi) + return _response_svs -def test_content(response): - """Sample pytest test function with the pytest fixture as an argument.""" - # from bs4 import BeautifulSoup - # assert 'GitHub' in BeautifulSoup(response.content).title.string +def test_slide_info(_response_ndpi, _response_svs): + """pytest for slide_info as a python function""" + file_types = ("*.ndpi", "*.svs", "*.mrxs") + files_all = utils.misc.grab_files_from_dir( + input_path=str(pathlib.Path(r".")), file_types=file_types, + ) + slide_params = slide_info(input_path=files_all, workers=2) + for slide_param in slide_params: + utils.misc.save_yaml(slide_param, slide_param["file_name"] + ".yaml") -def test_command_line_interface(): - """Test the CLI.""" + +def test_command_line_help_interface(): + """Test the CLI help""" runner = CliRunner() result = runner.invoke(cli.main) assert result.exit_code == 0 - assert "tiatoolbox.cli.main" in result.output help_result = runner.invoke(cli.main, ["--help"]) assert help_result.exit_code == 0 - assert "--help Show this message and exit." in help_result.output + assert help_result.output == result.output + + +def test_command_line_version(): + """pytest for version check""" + runner = CliRunner() + version_result = runner.invoke(cli.main, ["-V"]) + assert __version__ in version_result.output + + +def test_command_line_slide_info(_response_ndpi, _response_svs): + """Test the Slide information CLI.""" + runner = CliRunner() + slide_info_result = runner.invoke( + cli.main, + [ + "slide-info", + "--wsi_input", + ".", + "--file_types", + '"*.ndpi, *.svs"', + "--workers", + "2", + ], + ) + + assert slide_info_result.exit_code == 0 diff --git a/tiatoolbox/__init__.py b/tiatoolbox/__init__.py index 31d48b530..f4eee6be9 100644 --- a/tiatoolbox/__init__.py +++ b/tiatoolbox/__init__.py @@ -1,5 +1,12 @@ """Top-level package for TIA Toolbox.""" +from tiatoolbox import tiatoolbox +from tiatoolbox import dataloader +from tiatoolbox import utils + __author__ = """TIA Lab""" __email__ = "tialab@dcs.warwick.ac.uk" __version__ = "0.1.1" + +if __name__ == "__main__": + pass diff --git a/tiatoolbox/__main__.py b/tiatoolbox/__main__.py new file mode 100644 index 000000000..8f4c3111c --- /dev/null +++ b/tiatoolbox/__main__.py @@ -0,0 +1,5 @@ +"""__main__ file invoked with `python -m tiatoolbox` command""" + +from tiatoolbox.cli import main + +main() diff --git a/tiatoolbox/cli.py b/tiatoolbox/cli.py index c0a8e6d67..fc532692d 100644 --- a/tiatoolbox/cli.py +++ b/tiatoolbox/cli.py @@ -1,15 +1,71 @@ """Console script for tiatoolbox.""" +from tiatoolbox import __version__ +from tiatoolbox import dataloader +from tiatoolbox import utils import sys import click +import os -@click.command() -def main(args=None): - """Console script for tiatoolbox.""" - click.echo("Replace this message by putting your code into " "tiatoolbox.cli.main") - click.echo("See click documentation at https://click.palletsprojects.com/") +def version_msg(): + """Return a string with tiatoolbox package version and python version.""" + python_version = sys.version[:3] + location = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + message = "tiatoolbox %(version)s from {} (Python {})" + return message.format(location, python_version) + + +@click.group(context_settings=dict(help_option_names=["-h", "--help"])) +@click.version_option( + __version__, "--version", "-V", help="Version", message=version_msg() +) +def main(): + """Computational pathology toolbox developed by TIA LAB""" return 0 +@main.command() +@click.option("--wsi_input", help="input path to WSI file or directory path") +@click.option( + "--output_dir", + help="Path to output directory to save the output, default=wsi_input/../meta", +) +@click.option( + "--file_types", + help="file types to capture from directory, default='*.ndpi', '*.svs', '*.mrxs'", + default="*.ndpi, *.svs, *.mrxs", +) +@click.option( + "--mode", + help="'show' to display meta information only or 'save' to save " + "the meta information, default=show", +) +@click.option( + "--workers", + type=int, + help="num of cpu cores to use for multiprocessing, " + "default=multiprocessing.cpu_count()", +) +def slide_info(wsi_input, output_dir, file_types, mode, workers=None): + """Displays or saves WSI metadata""" + file_types = tuple(file_types.split(", ")) + if os.path.isdir(wsi_input): + files_all = utils.misc.grab_files_from_dir( + input_path=wsi_input, file_types=file_types + ) + elif os.path.isfile(wsi_input): + files_all = [ + wsi_input, + ] + else: + raise ValueError("wsi_input path is not valid") + + print(files_all) + + dataloader.slide_info.slide_info( + input_path=files_all, output_dir=output_dir, mode=mode, workers=workers, + ) + + if __name__ == "__main__": sys.exit(main()) # pragma: no cover diff --git a/tiatoolbox/dataloader/__init__.py b/tiatoolbox/dataloader/__init__.py new file mode 100644 index 000000000..36212b688 --- /dev/null +++ b/tiatoolbox/dataloader/__init__.py @@ -0,0 +1,3 @@ +"""Package to read whole slide images""" + +from tiatoolbox.dataloader import slide_info, wsireader diff --git a/tiatoolbox/dataloader/slide_info.py b/tiatoolbox/dataloader/slide_info.py new file mode 100644 index 000000000..dc7e7a867 --- /dev/null +++ b/tiatoolbox/dataloader/slide_info.py @@ -0,0 +1,48 @@ +"""Get Slide Meta Data information""" +from tiatoolbox.dataloader import wsireader +from tiatoolbox.decorators.multiproc import TIAMultiProcess + +import os + + +@TIAMultiProcess(iter_on="input_path") +def slide_info(input_path, output_dir=None): + """Single file run to output or save WSI meta data. Multiprocessing uses this function + to run slide_info in parallel + + Args: + input_path: Path to whole slide image + output_dir: Path to output directory to save the output + workers: num of cpu cores to use for multiprocessing + Returns: + list: list of dictionary Whole Slide meta information + + Examples: + >>> from tiatoolbox.dataloader.slide_info import slide_info + >>> from tiatoolbox import utils + >>> file_types = ("*.ndpi", "*.svs", "*.mrxs") + >>> files_all = utils.misc.grab_files_from_dir(input_path, + ... file_types=file_types) + >>> slide_params = slide_info(input_path=files_all, workers=2) + >>> for slide_param in slide_params: + ... utils.misc.save_yaml(slide_param, + ... slide_param["file_name"] + ".yaml") + ... print(slide_param) + + """ + + input_dir, file_name = os.path.split(input_path) + + print(file_name, flush=True) + _, file_type = os.path.splitext(file_name) + + if file_type in (".svs", ".ndpi", ".mrxs"): + wsi_reader = wsireader.WSIReader( + input_dir=input_dir, file_name=file_name, output_dir=output_dir + ) + info = wsi_reader.slide_info() + else: + print("File type not supported") + info = None + + return info diff --git a/tiatoolbox/dataloader/wsireader.py b/tiatoolbox/dataloader/wsireader.py new file mode 100644 index 000000000..be4ccae27 --- /dev/null +++ b/tiatoolbox/dataloader/wsireader.py @@ -0,0 +1,107 @@ +"""WSIReader for WSI reading or extracting metadata information from WSIs""" + +import pathlib +import numpy as np + +# from PIL import Image + +import openslide + + +class WSIReader: + """WSI Reader class to read WSI images + + Attributes: + input_dir (pathlib.Path): input path to WSI directory + file_name (str): file name of the WSI + output_dir (pathlib.Path): output directory to save the output + openslide_obj (:obj:`openslide.OpenSlide`) + tile_objective_value (int): objective value at which tile is generated + tile_read_size (int): [tile width, tile height] + objective_power (int): objective value at which whole slide image is scanned + level_count (int): The number of pyramid levels in the slide + level_dimensions (int): A list of `(width, height)` tuples, one for each level + of the slide + level_downsamples (int): A list of down sample factors for each level + of the slide + + """ + + def __init__( + self, + input_dir=".", + file_name=None, + output_dir="./output", + tile_objective_value=20, + tile_read_size_w=5000, + tile_read_size_h=5000, + ): + """ + Args: + input_dir (str, pathlib.Path): input path to WSI directory + file_name (str): file name of the WSI + output_dir (str, pathlib.Path): output directory to save the output, + default=./output + tile_objective_value (int): objective value at which tile is generated, + default=20 + tile_read_size_w (int): tile width, default=5000 + tile_read_size_h (int): tile height, default=5000 + + """ + + self.input_dir = pathlib.Path(input_dir) + self.file_name = pathlib.Path(file_name).name + if output_dir is not None: + self.output_dir = pathlib.Path(output_dir, self.file_name) + + self.openslide_obj = openslide.OpenSlide( + filename=str(pathlib.Path(self.input_dir, self.file_name)) + ) + self.tile_objective_value = np.int(tile_objective_value) + self.tile_read_size = np.array([tile_read_size_w, tile_read_size_h]) + self.objective_power = np.int( + self.openslide_obj.properties[openslide.PROPERTY_NAME_OBJECTIVE_POWER] + ) + self.level_count = self.openslide_obj.level_count + self.level_dimensions = self.openslide_obj.level_dimensions + self.level_downsamples = self.openslide_obj.level_downsamples + + def slide_info(self): + """WSI meta data reader + Args: + + Returns: + dict: dictionary containing meta information + + """ + input_dir = self.input_dir + if self.objective_power == 0: + self.objective_power = np.int( + self.openslide_obj.properties[openslide.PROPERTY_NAME_OBJECTIVE_POWER] + ) + objective_power = self.objective_power + slide_dimension = self.openslide_obj.level_dimensions[0] + tile_objective_value = self.tile_objective_value + rescale = np.int(objective_power / tile_objective_value) + filename = self.file_name + tile_read_size = self.tile_read_size + level_count = self.level_count + level_dimensions = self.level_dimensions + level_downsamples = self.level_downsamples + file_name = self.file_name + + param = { + "input_dir": input_dir, + "objective_power": objective_power, + "slide_dimension": slide_dimension, + "rescale": rescale, + "tile_objective_value": tile_objective_value, + "filename": filename, + "tile_read_size": tile_read_size.tolist(), + "level_count": level_count, + "level_dimensions": level_dimensions, + "level_downsamples": level_downsamples, + "file_name": file_name, + } + + return param diff --git a/tiatoolbox/decorators/__init__.py b/tiatoolbox/decorators/__init__.py new file mode 100644 index 000000000..d9608869c --- /dev/null +++ b/tiatoolbox/decorators/__init__.py @@ -0,0 +1,2 @@ +"""Package defines decorators for the toolbox""" +from tiatoolbox.decorators import multiproc diff --git a/tiatoolbox/decorators/multiproc.py b/tiatoolbox/decorators/multiproc.py new file mode 100644 index 000000000..6e6cf2fc3 --- /dev/null +++ b/tiatoolbox/decorators/multiproc.py @@ -0,0 +1,69 @@ +"""Multiprocessing decorators required by the tiatoolbox.""" + +import multiprocessing +from functools import partial + +from pathos.multiprocessing import ProcessingPool as Pool + + +class TIAMultiProcess: + """Multiprocessing class decorator for the toolbox, requires a list `iter_on` + as input on which multiprocessing will run + + Attributes: + iter_on (str): Variable on which iterations will be performed. + workers (int): num of cpu cores to use for multiprocessing. + + Examples: + >>> from tiatoolbox.decorators.multiproc import TIAMultiProcess + >>> import cv2 + >>> @TIAMultiProcess(iter_on="input_path") + ... def read_images(input_path, output_dir=None): + ... img = cv2.imread(input_path) + ... return img + >>> imgs = read_images(input_path) + + """ + + def __init__(self, iter_on): + """ + Args: + iter_on: Variable on which iterations will be performed. + """ + self.iter_on = iter_on + self.workers = multiprocessing.cpu_count() + + def __call__(self, func): + """ + Args: + func: function to be run with multiprocessing + + Returns: + + """ + + def func_wrap(*args, **kwargs): + """Wrapping function for decorator call + Args: + *args: args inputs + **kwargs: kwargs inputs + + Returns: + + """ + if "workers" in kwargs: + self.workers = kwargs.pop("workers") + try: + iter_value = kwargs.pop(self.iter_on) + except ValueError: + raise ValueError("Please specify iter_on in multiprocessing decorator") + + with Pool(self.workers) as p: + results = p.map(partial(func, **kwargs), iter_value,) + p.clear() + + return results + + func_wrap.__doc__ = func.__doc__ + + return func_wrap diff --git a/tiatoolbox/utils/__init__.py b/tiatoolbox/utils/__init__.py new file mode 100644 index 000000000..1dabdcb08 --- /dev/null +++ b/tiatoolbox/utils/__init__.py @@ -0,0 +1,2 @@ +"""Utils package for toolbox utilities""" +from tiatoolbox.utils import misc diff --git a/tiatoolbox/utils/misc.py b/tiatoolbox/utils/misc.py new file mode 100644 index 000000000..c7ac0ce26 --- /dev/null +++ b/tiatoolbox/utils/misc.py @@ -0,0 +1,77 @@ +"""Miscellaneous small functions repeatedly used in tiatoolbox""" +import os +import pathlib +import yaml + + +def split_path_name_ext(full_path): + """Split path of a file to directory path, file name and extension + + Args: + full_path (str): Path to a file + + Returns: + tuple: Three sections of the input file path + (input directory path, file name, file extension) + + Examples: + >>> from tiatoolbox import utils + >>> dir_path, file_name, extension = + ... utils.misc.split_path_name_ext(full_path) + + """ + input_dir, file_name = os.path.split(full_path) + file_name, ext = os.path.splitext(file_name) + return input_dir, file_name, ext + + +def grab_files_from_dir(input_path, file_types=("*.jpg", "*.png", "*.tif")): + """Grabs file paths specified by file extensions + + Args: + input_path (str, pathlib.Path): Path to the directory where files + need to be searched + file_types (str, tuple): File types (extensions) to be searched + + Returns: + list: File paths as a python list + + Examples: + >>> from tiatoolbox import utils + >>> file_types = ("*.ndpi", "*.svs", "*.mrxs") + >>> files_all = utils.misc.grab_files_from_dir(input_path, + ... file_types=file_types,) + + """ + input_path = pathlib.Path(input_path) + + if type(file_types) is str: + if len(file_types.split(",")) > 1: + file_types = tuple(file_types.split(",")) + else: + file_types = (file_types,) + + files_grabbed = [] + for files in file_types: + files_grabbed.extend(input_path.glob(files)) + + return list(files_grabbed) + + +def save_yaml(input_dict, output_path="output.yaml"): + """Save dictionary as yaml + Args: + input_dict (dict): A variable of type 'dict' + output_path (str, pathlib.Path): Path to save the output file + + Returns: + + Examples: + >>> from tiatoolbox import utils + >>> input_dict = {'hello': 'Hello World!'} + >>> utils.misc.save_yaml(input_dict, './hello.yaml') + + + """ + with open(pathlib.Path(output_path), "w") as yaml_file: + yaml.dump(input_dict, yaml_file) diff --git a/tox.ini b/tox.ini index c0a377640..246ec48a7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,11 @@ [tox] -envlist = py35, py36, py37, py38, flake8 +envlist = py36, py37, py38, flake8 [travis] python = 3.8: py38 3.7: py37 3.6: py36 - 3.5: py35 [testenv:flake8] basepython = python