diff --git a/README.md b/README.md index da13bb3ce..ae9d2b644 100644 --- a/README.md +++ b/README.md @@ -31,16 +31,34 @@ from aicsimageio import AICSImage, imread im = imread("/path/to/your/file_or_buffer.ome.tiff") # For AICSImage object that -im = AICSImage("/path/to/your/file_or_buffer.ome.tiff") +with AICSImage("/path/to/your/file_or_buffer.ome.tiff") as im: + # use im object # To specify a known dimension order -im = AICSImage("/path/to/your/file_or_buffer.ome.tiff", known_dims="SYX") +with AICSImage("/path/to/your/file_or_buffer.ome.tiff", known_dims="SYX") as im: + # use im object + +# if you instantiate an AICSImage: +im = AICSImage("/path/to/your/file_or_buffer.ome.tiff") +# you should close it when done: +im.close() # Image data is stored in `data` attribute im.data # returns the image data numpy array +# Image dimension sizes can be obtained via properties: +im.size_z # returns the size of the Z dimension. X,Y,Z,C,T, and S supported. + +# Image dimensions can also be obtained as a tuple in two ways: +im.size("ZYX") # returns a tuple containing the Z, Y, and X sizes only +im.get_image_data(out_orientation="ZYX").shape # returns same as above + # Image metadata is stored in `metadata` attribute -im.metadata # returns whichever metadata parser best suites the file format +im.metadata # returns whichever metadata parser best suits the file format + +# Subsets or transposes of the image data can be requested: +im.get_image_data(out_orientation="ZYX") # returns a 3d data block containing only the ZYX dimensions + ``` ## Notes diff --git a/aicsimageio/__init__.py b/aicsimageio/__init__.py index 4f0e4f2c4..36f6dcd3f 100644 --- a/aicsimageio/__init__.py +++ b/aicsimageio/__init__.py @@ -3,4 +3,4 @@ # Do not edit this string manually, always use bumpversion # Details in CONTRIBUTING.md -__version__ = "3.0.3" +__version__ = "3.0.6" diff --git a/aicsimageio/aics_image.py b/aicsimageio/aics_image.py index 1f21f884e..8d5899b8a 100644 --- a/aicsimageio/aics_image.py +++ b/aicsimageio/aics_image.py @@ -5,9 +5,8 @@ import numpy as np from . import constants, transforms, types -from .exceptions import UnsupportedFileFormatError -from .readers import (CziReader, DefaultReader, NdArrayReader, OmeTiffReader, - TiffReader) +from .exceptions import InvalidDimensionOrderingError, UnsupportedFileFormatError +from .readers import CziReader, DefaultReader, NdArrayReader, OmeTiffReader, TiffReader from .readers.reader import Reader log = logging.getLogger(__name__) @@ -34,18 +33,29 @@ class AICSImage: zstack_t10 = data[0, 10, 0, :, :, :] # access the S=0, T=10, C=0 "ZYX" cube - File Examples ------------- + + with AICSImage("filename.ome.tif") as img: + # do work with img + data = img.data + metadata = img.metadata + # img will be closed automatically at end of scope + OmeTif img = AICSImage("filename.ome.tif") + img.close() CZI (Zeiss) img = AICSImage("filename.czi") or AICSImage("filename.czi", max_workers=8) + img.close() Tiff img = AICSImage("filename.tif") + img.close() Png/Gif/... img = AICSImage("filename.png") + img.close() img = AICSImage("filename.gif") + img.close() Bytestream Examples ------------------- @@ -70,9 +80,15 @@ class AICSImage: img.reader.dims = 'TCX' data = img.data # get a 6D ndarray back in "STCZYX" order """ + SUPPORTED_READERS = [CziReader, OmeTiffReader, TiffReader, DefaultReader] - def __init__(self, data: typing.Union[types.FileLike, np.ndarray], known_dims: Optional[str] = None, **kwargs): + def __init__( + self, + data: typing.Union[types.FileLike, np.ndarray], + known_dims: Optional[str] = None, + **kwargs, + ): """ Constructor for AICSImage class intended for providing a unified interface for dealing with microscopy images. To extend support to a new reader simply add a new reader child class of @@ -109,7 +125,13 @@ def determine_reader(data: types.ImageLike) -> Type[Reader]: """ # The order of the readers in this list is important. # Example: if TiffReader was placed before OmeTiffReader, we would never use the OmeTiffReader. - for reader_class in [NdArrayReader, CziReader, OmeTiffReader, TiffReader, DefaultReader]: + for reader_class in [ + NdArrayReader, + CziReader, + OmeTiffReader, + TiffReader, + DefaultReader, + ]: if reader_class.is_this_type(data): return reader_class @@ -126,11 +148,84 @@ def data(self): reader_data = self._reader.data # Read and reshape and handle delayed known dims reshape - self._data = transforms.reshape_data(data=reader_data, - given_dims=self._known_dims or self.reader.dims, - return_dims=self.dims) + self._data = transforms.reshape_data( + data=reader_data, + given_dims=self._known_dims or self.reader.dims, + return_dims=self.dims, + ) return self._data + def size(self, dims: str = "STCZYX"): + """ + Parameters + ---------- + dims: A string containing a list of dimensions being requested. The default is to return the 6 standard dims + + Returns + ------- + Returns a tuple with the requested dimensions filled in + """ + dims = dims.upper() + if not (all(d in "STCZYX" for d in dims)): + raise InvalidDimensionOrderingError(f"Invalid dimensions requested: {dims}") + if not (all(d in self.dims for d in dims)): + raise InvalidDimensionOrderingError(f"Invalid dimensions requested: {dims}") + return tuple([self.data.shape[self.dims.index(dim)] for dim in dims]) + + @property + def size_x(self): + """ + Returns + ------- + Returns the x size + """ + return self.size("X")[0] + + @property + def size_y(self): + """ + Returns + ------- + Returns the y size + """ + return self.size("Y")[0] + + @property + def size_z(self): + """ + Returns + ------- + Returns the z size + """ + return self.size("Z")[0] + + @property + def size_c(self): + """ + Returns + ------- + Returns the c size + """ + return self.size("C")[0] + + @property + def size_t(self): + """ + Returns + ------- + Returns the t size + """ + return self.size("T")[0] + + @property + def size_s(self): + """ + Returns + ------- + Returns the s size + """ + return self.size("S")[0] + @property def metadata(self): """ @@ -158,7 +253,9 @@ def reader(self) -> Reader: """ return self._reader - def get_image_data(self, out_orientation: str = None, copy: bool = False, **kwargs) -> np.ndarray: + def get_image_data( + self, out_orientation: str = None, copy: bool = False, **kwargs + ) -> np.ndarray: """ Parameters @@ -180,12 +277,45 @@ def get_image_data(self, out_orientation: str = None, copy: bool = False, **kwar out_orientation = self.dims if out_orientation is None else out_orientation if out_orientation == self.dims: return self.data - return transforms.reshape_data(data=self.data, given_dims=self.dims, - return_dims=out_orientation, copy=copy, **kwargs) + return transforms.reshape_data( + data=self.data, + given_dims=self.dims, + return_dims=out_orientation, + copy=copy, + **kwargs, + ) + + def get_channel_names(self, scene: int = 0): + """ + + Parameters + ---------- + scene: the index of the scene for which to return channel names + + Returns + ------- + list of strings representing the channel names + """ + try: + names = self._reader.get_channel_names(scene) + except AttributeError: + names = [str(i) for i in range(self.size_c)] + return names + + def close(self): + self.reader.close() def __repr__(self) -> str: - return f'' + return f"" + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() -def imread(data: types.ImageLike, **kwargs): - return AICSImage(data, **kwargs).data +def imread(data: types.ImageLike, **kwargs) -> np.ndarray: + with AICSImage(data, **kwargs) as image: + imagedata = image.data + return imagedata diff --git a/aicsimageio/readers/czi_reader.py b/aicsimageio/readers/czi_reader.py index 623fae078..c79c61251 100644 --- a/aicsimageio/readers/czi_reader.py +++ b/aicsimageio/readers/czi_reader.py @@ -1,7 +1,7 @@ import io import logging import warnings -from typing import Optional +from typing import Optional, Tuple from xml.etree import ElementTree import numpy as np @@ -213,3 +213,20 @@ def _is_multiscene(self): return False img_shape = self.czi.filtered_subblock_directory[0].shape return img_shape[index] != 1 + + def get_channel_names(self, scene: int = 0): + chelem = self.metadata.findall("./Metadata/Information/Image/Dimensions/Channels/Channel") + return [ch.get("Name") for ch in chelem] + + # TODO refactor this utility function into a metadata wrapper class + def _getmetadataxmltext(self, findpath, default=None): + ref = self.metadata.find(findpath) + if ref is None: + return default + return ref.text + + def get_physical_pixel_size(self, scene: int = 0) -> Tuple[float]: + px = float(self._getmetadataxmltext("./Metadata/Scaling/Items/Distance[@Id='X']/Value", "1.0")) + py = float(self._getmetadataxmltext("./Metadata/Scaling/Items/Distance[@Id='Y']/Value", "1.0")) + pz = float(self._getmetadataxmltext("./Metadata/Scaling/Items/Distance[@Id='Z']/Value", "1.0")) + return (px, py, pz) diff --git a/aicsimageio/readers/ome_tiff_reader.py b/aicsimageio/readers/ome_tiff_reader.py index 567be95b6..e74644209 100644 --- a/aicsimageio/readers/ome_tiff_reader.py +++ b/aicsimageio/readers/ome_tiff_reader.py @@ -4,6 +4,7 @@ import numpy as np import tifffile +from typing import Tuple from .. import types from ..vendor import omexml @@ -102,3 +103,10 @@ def dtype(self): def is_ome(self): return OmeTiffReader._is_this_type(self._bytes) + + def get_channel_names(self, scene: int = 0): + return self.metadata.image(scene).Pixels.get_channel_names() + + def get_physical_pixel_size(self, scene: int = 0) -> Tuple[float]: + p = self.metadata.image(scene).Pixels + return (p.get_PhysicalSizeX(), p.get_PhysicalSizeY(), p.get_PhysicalSizeZ()) diff --git a/aicsimageio/tests/readers/test_czi_reader.py b/aicsimageio/tests/readers/test_czi_reader.py index 1f2ef3e6a..13f6485ba 100644 --- a/aicsimageio/tests/readers/test_czi_reader.py +++ b/aicsimageio/tests/readers/test_czi_reader.py @@ -60,6 +60,15 @@ def test_dtype(resources_dir, test_input, expected): assert czi.dtype() == expected +@pytest.mark.parametrize("test_input, expected", [ + (TWO_DIM_CZI, (1.0833333333333333e-06, 1.0833333333333333e-06, 1.0)), + (SIX_DIM_CZI, (1.0833333333333333e-06, 1.0833333333333333e-06, 1e-06)) +]) +def test_pixel_size(resources_dir, test_input, expected): + czi = CziReader(resources_dir / test_input) + assert czi.get_physical_pixel_size() == expected + + def test_shape(resources_dir): czi = CziReader(resources_dir / SIX_DIM_CZI) data = czi.data diff --git a/aicsimageio/tests/readers/test_ome_tiff_reader.py b/aicsimageio/tests/readers/test_ome_tiff_reader.py index 708d13aa6..94db080fe 100644 --- a/aicsimageio/tests/readers/test_ome_tiff_reader.py +++ b/aicsimageio/tests/readers/test_ome_tiff_reader.py @@ -57,9 +57,15 @@ def test_loadSampleOmeTif(self): "CYX", # Inferred from metadata not shape "ZCYX", ] + physical_pixel_sizes = [ + (1.0833333333333333, 1.0833333333333333, 1.0), + (1.0, 1.0, 1.0), + (1.0833333333333333, 1.0833333333333333, 1.0), + ] for i, x in enumerate(names): with OmeTiffReader(os.path.join(self.dir_path, "..", "resources", x)) as reader: assert reader.is_ome() data = reader.data self.assertEqual(data.shape, dims[i]) self.assertEqual(reader.dims, dim_orders[i]) + self.assertEqual(reader.get_physical_pixel_size(), physical_pixel_sizes[i]) diff --git a/aicsimageio/tests/test_aics_image.py b/aicsimageio/tests/test_aics_image.py index c31293e67..65a12f497 100644 --- a/aicsimageio/tests/test_aics_image.py +++ b/aicsimageio/tests/test_aics_image.py @@ -17,17 +17,28 @@ TXT_FILE = "example.txt" -@pytest.mark.parametrize("filename, expected_reader", [ - (BMP_FILE, readers.DefaultReader), - (JPG_FILE, readers.DefaultReader), - (PNG_FILE, readers.DefaultReader), - (GIF_FILE, readers.DefaultReader), - (TIF_FILE, readers.TiffReader), - (OME_FILE, readers.OmeTiffReader), - (CZI_FILE, readers.CziReader), - pytest.param(TXT_FILE, None, marks=pytest.mark.raises(exception=exceptions.UnsupportedFileFormatError)), - pytest.param("not/a/file.czi", None, marks=pytest.mark.raises(exception=FileNotFoundError)) -]) +@pytest.mark.parametrize( + "filename, expected_reader", + [ + (BMP_FILE, readers.DefaultReader), + (JPG_FILE, readers.DefaultReader), + (PNG_FILE, readers.DefaultReader), + (GIF_FILE, readers.DefaultReader), + (TIF_FILE, readers.TiffReader), + (OME_FILE, readers.OmeTiffReader), + (CZI_FILE, readers.CziReader), + pytest.param( + TXT_FILE, + None, + marks=pytest.mark.raises(exception=exceptions.UnsupportedFileFormatError), + ), + pytest.param( + "not/a/file.czi", + None, + marks=pytest.mark.raises(exception=FileNotFoundError), + ), + ], +) def test_typing(filename, expected_reader, resources_dir): actual_reader = AICSImage.determine_reader(resources_dir / filename) assert actual_reader == expected_reader @@ -38,54 +49,85 @@ def test_file_passed_was_byte_string(): AICSImage(b"not-a-valid-image-byte-array") -@pytest.mark.parametrize("arr", [ - np.zeros((2, 2, 2)), - np.ones((2, 2, 2)), - np.random.rand(2, 2, 2), -]) +@pytest.mark.parametrize( + "arr", [np.zeros((2, 2, 2)), np.ones((2, 2, 2)), np.random.rand(2, 2, 2)] +) def test_support_for_ndarray(arr): actual_reader = AICSImage.determine_reader(arr) assert actual_reader == readers.NdArrayReader -@pytest.mark.parametrize("data, expected", [ - (np.zeros((5, 4, 3)), (1, 1, 1, 5, 4, 3)), - (np.zeros((1, 2, 3, 4, 5, 6)), (1, 2, 3, 4, 5, 6)), - (np.random.rand(10), (1, 1, 1, 1, 1, 10)), -]) +@pytest.mark.parametrize( + "data, expected", + [ + (np.zeros((5, 4, 3)), (1, 1, 1, 5, 4, 3)), + (np.zeros((1, 2, 3, 4, 5, 6)), (1, 2, 3, 4, 5, 6)), + (np.random.rand(10), (1, 1, 1, 1, 1, 10)), + ], +) def test_default_dims(data, expected): img = AICSImage(data=data) assert img.data.shape == expected -@pytest.mark.parametrize("data, dims, expected_shape", [ - (np.zeros((5, 4, 3)), "SYX", (5, 1, 1, 1, 4, 3)), - (np.zeros((1, 2, 3, 4, 5)), "STCYX", (1, 2, 3, 1, 4, 5)), - (np.zeros((10, 20)), "XY", (1, 1, 1, 1, 20, 10)), - pytest.param(np.zeros((2, 2, 2)), "ABI", None, marks=pytest.mark.raises(exception=TypeError)) -]) +@pytest.mark.parametrize( + "data, dims, expected_shape", + [ + (np.zeros((5, 4, 3)), "SYX", (5, 1, 1, 1, 4, 3)), + (np.zeros((1, 2, 3, 4, 5)), "STCYX", (1, 2, 3, 1, 4, 5)), + (np.zeros((10, 20)), "XY", (1, 1, 1, 1, 20, 10)), + pytest.param( + np.zeros((2, 2, 2)), + "ABI", + None, + marks=pytest.mark.raises(exception=TypeError), + ), + ], +) def test_known_dims(data, dims, expected_shape): img = AICSImage(data, known_dims=dims) assert img.data.shape == expected_shape - - -@pytest.mark.parametrize("data_shape, dims, expected", [ - ((5, 4, 3), "STC", (5, 4, 3, 1, 1, 1)), - ((1, 2, 3, 4, 5, 6), "XYZCTS", (6, 5, 4, 3, 2, 1)), - ((5, 4, 3), "SCY", (5, 1, 4, 1, 3, 1)), - ((1, 2, 3, 4, 5, 6), "STCZYX", (1, 2, 3, 4, 5, 6)), -]) + assert img.size_x == expected_shape[5] + assert img.size_y == expected_shape[4] + assert img.size_z == expected_shape[3] + assert img.size_c == expected_shape[2] + assert img.size_t == expected_shape[1] + assert img.size_s == expected_shape[0] + assert img.size(dims) == data.shape + + +@pytest.mark.parametrize( + "data_shape, dims, expected", + [ + ((5, 4, 3), "STC", (5, 4, 3, 1, 1, 1)), + ((1, 2, 3, 4, 5, 6), "XYZCTS", (6, 5, 4, 3, 2, 1)), + ((5, 4, 3), "SCY", (5, 1, 4, 1, 3, 1)), + ((1, 2, 3, 4, 5, 6), "STCZYX", (1, 2, 3, 4, 5, 6)), + ], +) def test_force_dims(data_shape, dims, expected): img = AICSImage(data=np.zeros(data_shape)) img._reader.dims = dims assert img.data.shape == expected assert data_shape == img.get_image_data(out_orientation=dims).shape - - -@pytest.mark.parametrize("filepath", [ - OME_FILE, - pytest.param("fakeimage.ome.tif", marks=pytest.mark.raises(exception=FileNotFoundError)), -]) + assert img.size_x == expected[5] + assert img.size_y == expected[4] + assert img.size_z == expected[3] + assert img.size_c == expected[2] + assert img.size_t == expected[1] + assert img.size_s == expected[0] + assert img.size(dims) == data_shape + + +@pytest.mark.parametrize( + "filepath", + [ + OME_FILE, + pytest.param( + "fakeimage.ome.tif", marks=pytest.mark.raises(exception=FileNotFoundError) + ), + ], +) def test_file_exceptions(resources_dir, filepath): f = resources_dir / filepath AICSImage(f) @@ -96,35 +138,81 @@ def test_file_passed_was_directory(resources_dir): AICSImage(resources_dir) -@pytest.mark.parametrize("filename, expected_metadata_type", [ - (PNG_FILE, (str, type(None))), - (TIF_FILE, (str, type(None))), - (OME_FILE, (str, omexml.OMEXML)), - (CZI_FILE, (str, etree.Element)), -]) +@pytest.mark.parametrize( + "filename, expected_metadata_type", + [ + (PNG_FILE, (str, type(None))), + (TIF_FILE, (str, type(None))), + (OME_FILE, (str, omexml.OMEXML)), + (CZI_FILE, (str, etree.Element)), + ], +) def test_metadata(resources_dir, filename, expected_metadata_type): img = AICSImage(resources_dir / filename) assert isinstance(img.metadata, expected_metadata_type) -@pytest.mark.parametrize("filename, expected_reader", [ - (PNG_FILE, readers.DefaultReader), - (TIF_FILE, readers.TiffReader), - (OME_FILE, readers.OmeTiffReader), - (CZI_FILE, readers.CziReader), - pytest.param("not/a/file.czi", None, marks=pytest.mark.raises(exception=FileNotFoundError)), -]) +@pytest.mark.parametrize( + "filename, expected_reader", + [ + (PNG_FILE, readers.DefaultReader), + (TIF_FILE, readers.TiffReader), + (OME_FILE, readers.OmeTiffReader), + (CZI_FILE, readers.CziReader), + pytest.param( + "not/a/file.czi", + None, + marks=pytest.mark.raises(exception=FileNotFoundError), + ), + ], +) def test_reader(resources_dir, filename, expected_reader): img = AICSImage(resources_dir / filename) assert isinstance(img.reader, expected_reader) -@pytest.mark.parametrize("filename, expected_shape", [ - (PNG_FILE, (1, 1, 4, 1, 800, 537)), - (TIF_FILE, (1, 1, 1, 1, 325, 475)), - (OME_FILE, (1, 1, 1, 1, 325, 475)), - (CZI_FILE, (1, 1, 1, 1, 325, 475)), -]) +@pytest.mark.parametrize( + "filename, expected_shape", + [ + (PNG_FILE, (1, 1, 4, 1, 800, 537)), + (TIF_FILE, (1, 1, 1, 1, 325, 475)), + (OME_FILE, (1, 1, 1, 1, 325, 475)), + (CZI_FILE, (1, 1, 1, 1, 325, 475)), + ], +) def test_imread(resources_dir, filename, expected_shape): img = imread(resources_dir / filename) assert img.shape == expected_shape + + +@pytest.mark.parametrize( + "filename, expected_channel_names", + [ + (PNG_FILE, ["0", "1", "2", "3"]), + (TIF_FILE, ["0"]), + (CZI_FILE, ["Bright"]), + (OME_FILE, ["Bright"]), + ], +) +def test_channel_names(resources_dir, filename, expected_channel_names): + img = AICSImage(resources_dir / filename) + assert img.get_channel_names() == expected_channel_names + + +@pytest.mark.parametrize( + "filename", + [ + PNG_FILE, + TIF_FILE, + CZI_FILE, + OME_FILE, + ], +) +def test_aicsimage_close(resources_dir, filename): + img = AICSImage(resources_dir / filename) + assert img.reader._bytes.closed is False + img.close() + + with AICSImage(resources_dir / filename) as img: + img.metadata + assert img.reader._bytes.closed is True diff --git a/aicsimageio/vendor/omexml.py b/aicsimageio/vendor/omexml.py index 2cbddcebf..6b40a27fe 100644 --- a/aicsimageio/vendor/omexml.py +++ b/aicsimageio/vendor/omexml.py @@ -273,10 +273,10 @@ def get_namespaces(node): return ns_lib -def get_float_attr(node, attribute): +def get_float_attr(node, attribute, default=None): """Cast an element attribute to a float or return None if not present""" attr = node.get(attribute) - return None if attr is None else float(attr) + return default if attr is None else float(attr) def get_int_attr(node, attribute): @@ -782,7 +782,7 @@ def set_SizeC(self, value): def get_PhysicalSizeX(self): """The dimensions of the image in the X direction in physical units""" - return get_float_attr(self.node, "PhysicalSizeX") + return get_float_attr(self.node, "PhysicalSizeX", 1.0) def set_PhysicalSizeX(self, value): self.node.set("PhysicalSizeX", str(value)) @@ -791,7 +791,7 @@ def set_PhysicalSizeX(self, value): def get_PhysicalSizeY(self): """The dimensions of the image in the Y direction in physical units""" - return get_float_attr(self.node, "PhysicalSizeY") + return get_float_attr(self.node, "PhysicalSizeY", 1.0) def set_PhysicalSizeY(self, value): self.node.set("PhysicalSizeY", str(value)) @@ -800,7 +800,7 @@ def set_PhysicalSizeY(self, value): def get_PhysicalSizeZ(self): """The dimensions of the image in the Z direction in physical units""" - return get_float_attr(self.node, "PhysicalSizeZ") + return get_float_attr(self.node, "PhysicalSizeZ", 1.0) def set_PhysicalSizeZ(self, value): self.node.set("PhysicalSizeZ", str(value)) @@ -819,6 +819,9 @@ def get_channel_count(self): """ return len(self.node.findall(qn(self.ns['ome'], "Channel"))) + def get_channel_names(self): + return [self.Channel(i).Name for i in range(self.get_channel_count())] + def set_channel_count(self, value): assert value >= 0 channel_count = self.channel_count @@ -1252,7 +1255,7 @@ def get_Description(self): return get_text(description) def set_Description(self, text): - make_text_node(self.node, NS_SPW, "Description", test) + make_text_node(self.node, self.ns['spw'], "Description", text) Description = property(get_Description, set_Description) def get_Well(self): diff --git a/setup.cfg b/setup.cfg index 5bc31361b..3aa9b9cb6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.0.3 +current_version = 3.0.6 commit = True tag = True diff --git a/setup.py b/setup.py index 1b83bd172..9a8b17568 100644 --- a/setup.py +++ b/setup.py @@ -102,6 +102,6 @@ url="https://github.com/AllenCellModeling/aicsimageio", # Do not edit this string manually, always use bumpversion # Details in CONTRIBUTING.md - version="3.0.3", + version="3.0.6", zip_safe=False, )