diff --git a/.cz.yaml b/.cz.yaml index 89ce0c66..6b85b2cd 100644 --- a/.cz.yaml +++ b/.cz.yaml @@ -2,7 +2,7 @@ commitizen: changelog_file: CHANGELOG.md tag_format: v_$major.$minor.$patch$prerelease update_changelog_on_bump: false - version: 2.3.0 + version: 2.4.0 version_files: - setup.py:version - sepal_ui/__init__.py:__version__ diff --git a/CHANGELOG.md b/CHANGELOG.md index beece3fe..0d8108c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## v_2.4.0 (2021-10-19) + +### Refactor + +- make v_model default and empty value as None instead of empty string +- be consistent when concatenating + +### Fix + +- replace default v_model fon VectorField as trait +- doc build failed +- only display SepalWarning in Alerts +- this assignation was overwritting the w_asset dict +- vector field method. closes #306 + +### Feat + +- filter by column and value in AOI. - closes: #296 + ## v_2.3.0 (2021-10-06) ### Fix diff --git a/README.rst b/README.rst index 2ad91485..e4af8096 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,10 @@ Sepal_ui .. image:: https://badge.fury.io/py/sepal-ui.svg :target: https://badge.fury.io/py/sepal-ui :alt: PyPI version + +.. image:: https://img.shields.io/pypi/dm/sepal-ui?color=307CC2&logo=python&logoColor=gainsboro + :target: https://pypi.org/project/sepal-ui/ + :alt: PyPI - Downloads .. image:: https://github.com/12rambau/sepal_ui/actions/workflows/unit.yml/badge.svg :target: https://github.com/12rambau/sepal_ui/actions/workflows/unit.yml diff --git a/docs/source/modules/sepal_ui.scripts.rst b/docs/source/modules/sepal_ui.scripts.rst index f5ff80bc..94e50477 100644 --- a/docs/source/modules/sepal_ui.scripts.rst +++ b/docs/source/modules/sepal_ui.scripts.rst @@ -20,6 +20,14 @@ sepal\_ui.scripts.utils module :undoc-members: :show-inheritance: +sepal\_ui.scripts.warning module +------------------------------ + +.. automodule:: sepal_ui.scripts.warning + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/sepal_ui/__init__.py b/sepal_ui/__init__.py index 80942338..b41a755e 100644 --- a/sepal_ui/__init__.py +++ b/sepal_ui/__init__.py @@ -2,7 +2,7 @@ __author__ = """Pierrick Rambaud""" __email__ = "pierrick.rambaud49@gmail.com" -__version__ = "2.3.0" +__version__ = "2.4.0" # direct access to colors from sepal_ui.frontend import styles diff --git a/sepal_ui/aoi/aoi_model.py b/sepal_ui/aoi/aoi_model.py index 5ab5323b..abcf6bc9 100644 --- a/sepal_ui/aoi/aoi_model.py +++ b/sepal_ui/aoi/aoi_model.py @@ -49,6 +49,9 @@ class AoiModel(Model): feature_collection (ee.FeatureCollection): the featurecollection of the aoi (only in ee model) gdf (geopandas.GeoDataFrame): the geopandas representation of the aoi ipygeojson (GeoJson layer): the ipyleaflet representation of the selected aoi + + ..deprecated:: 2.3.2 : 'asset_name' will be used as variable to store 'ASSET' method info. To get the destination saved asset id, please use 'dst_asset_id' variable. + """ # const params @@ -133,7 +136,9 @@ def set_default(self, vector=None, admin=None, asset=None): # save the default values self.default_vector = vector - self.default_asset = self.asset_name = asset + self.default_asset = self.asset_name = ( + {"pathname": asset, "column": "ALL", "value": None} if asset else None + ) self.default_admin = self.admin = admin # cast the vector to json @@ -187,11 +192,22 @@ def set_object(self, method=None): def _from_asset(self, asset_name): """set the ee.FeatureCollection output from an existing asset""" - # check that I have access to the asset - ee_col = ee.FeatureCollection(asset_name) - ee_col.geometry().bounds().coordinates().get( - 0 - ).getInfo() # it will raise and error if we cannot access the asset + if not (asset_name["pathname"]): + raise Exception("Please select an asset.") + + if asset_name["column"] != "ALL": + if asset_name["value"] is None: + raise Exception("Please select a value.") + + self.name = Path(asset_name["pathname"]).stem.replace(self.ASSET_SUFFIX, "") + ee_col = ee.FeatureCollection(asset_name["pathname"]) + + if asset_name["column"] != "ALL": + + column = asset_name["column"] + value = asset_name["value"] + ee_col = ee_col.filterMetadata(column, "equals", value) + self.name = f"{self.name}_{column}_{value}" # set the feature collection self.feature_collection = ee_col @@ -204,7 +220,6 @@ def _from_asset(self, asset_name): ).set_crs(epsg=4326) # set the name - self.name = Path(asset_name).stem.replace(self.ASSET_SUFFIX, "") return self @@ -264,7 +279,7 @@ def _from_vector(self, vector_json): self.name = vector_file.stem # filter it if necessary - if vector_json["value"]: + if vector_json["value"] != None: self.gdf = self.gdf[self.gdf[vector_json["column"]] == vector_json["value"]] self.name = f"{self.name}_{vector_json['column']}_{vector_json['value']}" @@ -501,8 +516,7 @@ def export_to_asset(self): asset_name = self.ASSET_SUFFIX + self.name asset_id = str(Path(self.folder, asset_name)) - # set the asset name - self.asset_name = asset_id + self.dst_asset_id = asset_id # check if the table already exist if asset_id in [a["name"] for a in gee.get_assets(self.folder)]: diff --git a/sepal_ui/aoi/aoi_view.py b/sepal_ui/aoi/aoi_view.py index b71528cb..ee6efb30 100644 --- a/sepal_ui/aoi/aoi_view.py +++ b/sepal_ui/aoi/aoi_view.py @@ -255,8 +255,8 @@ def __init__(self, methods="ALL", map_=None, gee=True, folder=None, **kwargs): if self.map_: self.w_draw = TextField(label=ms.aoi_sel.aoi_name).hide() if self.ee: - self.w_asset = sw.AssetSelect( - label=ms.aoi_sel.asset, folder=self.folder, types=["TABLE"] + self.w_asset = sw.VectorField( + label=ms.aoi_sel.asset, gee=True, folder=self.folder, types=["TABLE"] ).hide() # group them together with the same key as the select_method object diff --git a/sepal_ui/scripts/utils.py b/sepal_ui/scripts/utils.py index 187c4a80..09f940c7 100644 --- a/sepal_ui/scripts/utils.py +++ b/sepal_ui/scripts/utils.py @@ -16,6 +16,8 @@ import sepal_ui +from .warning import SepalWarning + def hide_component(widget): """ @@ -251,16 +253,38 @@ def wrapper_loading(self, *args, **kwargs): value = None try: # Catch warnings in the process function - with warnings.catch_warnings(record=True) as w: + with warnings.catch_warnings(record=True) as w_list: value = func(self, *args, **kwargs) # Check if there are warnings in the function and append them - # Use append msg due to several warnings could be triggered - if w: - [ - alert_.append_msg(warning.message.args[0], type_="warning") - for warning in w + # Use append msg as several warnings could be triggered + if w_list: + + # split the warning list + w_list_sepal = [ + w for w in w_list if isinstance(w.message, SepalWarning) + ] + + # display the sepal one + ms_list = [ + f"{w.category.__name__}: {w.message.args[0]}" + for w in w_list_sepal ] + [alert_.append_msg(ms, type_="warning") for ms in ms_list] + + # only display them in the console if debug mode + if debug: + + def custom_showwarning(w): + return warnings.showwarning( + message=w.message, + category=w.category, + filename=w.filename, + lineno=w.lineno, + line=w.line, + ) + + [custom_showwarning(w) for w in w_list] except Exception as e: alert_.add_msg(f"{e}", "error") diff --git a/sepal_ui/scripts/warning.py b/sepal_ui/scripts/warning.py new file mode 100644 index 00000000..8acf6d64 --- /dev/null +++ b/sepal_ui/scripts/warning.py @@ -0,0 +1,14 @@ +from deprecated.sphinx import versionadded + + +@versionadded( + version="2.3.1", + reason="Added to avoid display of unrelevant warning to the end user", +) +class SepalWarning(Warning): + """ + A custom warning class that will be the only one to be displayed in the Alert in voila. + The other normal warning such as lib DeprecationWarning will be displayed in the notebook but hidden to the end user + """ + + pass diff --git a/sepal_ui/sepalwidgets/inputs.py b/sepal_ui/sepalwidgets/inputs.py index 8fa022eb..da289e51 100644 --- a/sepal_ui/sepalwidgets/inputs.py +++ b/sepal_ui/sepalwidgets/inputs.py @@ -2,7 +2,7 @@ import json import ipyvuetify as v -from traitlets import link, Int, Any, List, observe +from traitlets import link, Int, Any, List, observe, Dict, Unicode from ipywidgets import jslink import pandas as pd import ee @@ -109,6 +109,7 @@ class FileInput(v.Flex, SepalWidget): """ file = Any("") + v_model = Unicode(None, allow_none=True).tag(sync=True) def __init__( self, @@ -125,10 +126,9 @@ def __init__( self.extentions = extentions self.folder = folder - self.v_model = v_model self.selected_file = v.TextField( - readonly=True, label="Selected file", class_="ml-5 mt-5", v_model=self.file + readonly=True, label="Selected file", class_="ml-5 mt-5", v_model=None ) self.loading = v.ProgressLinear( @@ -201,13 +201,13 @@ def reset(self, *args): root = Path("~").expanduser() - if self.v_model != "": + if self.v_model is not None: # move to root self._on_file_select({"new": root}) # remove v_model - self.v_model = "" + self.v_model = None return self @@ -677,11 +677,13 @@ def decrement(self, widget, event, data): class VectorField(v.Col, SepalWidget): """ - A custom input widget to load vector data. The user will provide a vector file compatible with fiona. + A custom input widget to load vector data. The user will provide a vector file compatible with fiona or a GEE feature collection. The user can then select a specific shape by setting column and value fields. Args: label (str): the label of the file input field, default to 'vector file'. + gee (bool, optional): whether to use GEE assets or local vectors. + **asset_select_kwargs: When gee=True, extra args will be used for AssetSelect Attributes: original_gdf (geopandas.gdf): The originally selected dataframe @@ -692,24 +694,33 @@ class VectorField(v.Col, SepalWidget): w_value (v.Select): The Select widget to select the value in the selected column """ - default_v_model = { - "pathname": None, - "column": None, - "value": None, - } - - column_base_items = [ - {"text": "Use all features", "value": "ALL"}, - {"divider": True}, - ] + v_model = Dict( + { + "pathname": None, + "column": None, + "value": None, + } + ) - def __init__(self, label="vector_file", **kwargs): + def __init__(self, label="vector_file", gee=False, **kwargs): # save the df for column naming (not using a gdf as geometry are useless) self.df = None + self.feature_collection = None + + self.column_base_items = [ + {"text": "Use all features", "value": "ALL"}, + {"divider": True}, + ] # set the 3 wigets - self.w_file = FileInput([".shp", ".geojson", ".gpkg", ".kml"], label=label) + if not gee: + self.w_file = FileInput([".shp", ".geojson", ".gpkg", ".kml"], label=label) + else: + # Don't care about 'types' arg. It will only work with tables. + asset_select_kwargs = {k: v for k, v in kwargs.items() if k in ["folder"]} + self.w_file = AssetSelect(types=["TABLE"], **asset_select_kwargs) + self.w_column = v.Select( _metadata={"name": "column"}, items=self.column_base_items, @@ -723,7 +734,6 @@ def __init__(self, label="vector_file", **kwargs): # create the Col Field self.children = [self.w_file, self.w_column, self.w_value] - self.v_model = self.default_v_model super().__init__(**kwargs) @@ -752,6 +762,7 @@ def _update_file(self, change): self.w_column.items = self.w_value.items = [] self.w_column.v_model = self.w_value.v_model = None self.df = None + self.feature_collection = None # set the pathname value self.v_model["pathname"] = change["new"] @@ -760,13 +771,20 @@ def _update_file(self, change): if not change["new"]: return self - # read the file - self.df = gpd.read_file(change["new"], ignore_geometry=True) + if isinstance(self.w_file, FileInput): + # read the file + self.df = gpd.read_file(change["new"], ignore_geometry=True) + columns = self.df.columns.to_list() + + elif isinstance(self.w_file, AssetSelect): + self.feature_collection = ee.FeatureCollection(change["new"]) + columns = self.feature_collection.first().getInfo()["properties"] + columns = [ + str(col) for col in columns if col not in ["system:index", "Shape_Area"] + ] # update the columns - self.w_column.items = self.column_base_items + sorted( - set(self.df.columns.to_list()) - ) + self.w_column.items = self.column_base_items + sorted(set(columns)) self.w_column.v_model = "ALL" @@ -789,7 +807,18 @@ def _update_column(self, change): return self # read the colmun - self.w_value.items = sorted(set(self.df[change["new"]].to_list())) + if isinstance(self.w_file, FileInput): + values = self.df[change["new"]].to_list() + + elif isinstance(self.w_file, AssetSelect): + values = ( + self.feature_collection.distinct(change["new"]) + .aggregate_array(change["new"]) + .getInfo() + ) + + self.w_value.items = sorted(set(values)) + su.show_component(self.w_value) return self diff --git a/setup.py b/setup.py index e50fff99..a19d3bca 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from distutils.core import setup from pathlib import Path -version = "2.3.0" +version = "2.4.0" setup( name="sepal_ui", diff --git a/tests/test_AoiModel.py b/tests/test_AoiModel.py index 4729622d..99d104fb 100644 --- a/tests/test_AoiModel.py +++ b/tests/test_AoiModel.py @@ -20,12 +20,30 @@ def test_init(self, alert, gee_dir, asset_italy, fake_vector): # with default assetId aoi_model = aoi.AoiModel(alert, asset=asset_italy, folder=gee_dir) - assert aoi_model.asset_name == asset_italy - assert aoi_model.default_asset == asset_italy + assert aoi_model.asset_name["pathname"] == asset_italy + assert aoi_model.default_asset["pathname"] == asset_italy assert all(aoi_model.gdf) != None assert aoi_model.feature_collection != None assert aoi_model.name == "italy" + # chack that wrongly defined asset_name raise errors + with pytest.raises(Exception): + aoi_model = aoi.AoiModel(alert, folder=gee_dir) + aoi_model._from_asset({"pathname": None}) + + with pytest.raises(Exception): + aoi_model = aoi.AoiModel(alert, folder=gee_dir) + aoi_model._from_asset( + {"pathname": asset_italy, "column": "ADM0_CODE", "value": None} + ) + + # it should be the same with a different name + aoi_model = aoi.AoiModel(alert, folder=gee_dir) + aoi_model._from_asset( + {"pathname": asset_italy, "column": "ADM0_CODE", "value": 122} + ) + assert aoi_model.name == "italy_ADM0_CODE_122" + # with a default admin admin = 85 # GAUL France aoi_model = aoi.AoiModel(alert, admin=admin, folder=gee_dir) diff --git a/tests/test_FileInput.py b/tests/test_FileInput.py index 770ef05d..8cdf9214 100644 --- a/tests/test_FileInput.py +++ b/tests/test_FileInput.py @@ -11,13 +11,13 @@ def test_init(self, root_dir): file_input = sw.FileInput() assert isinstance(file_input, sw.FileInput) - assert file_input.v_model == "" + assert file_input.v_model == None # init with a string file_input = sw.FileInput(folder=str(root_dir)) assert isinstance(file_input, sw.FileInput) - assert file_input.v_model == "" + assert file_input.v_model == None # get all the names assert "sepal_ui" in self.get_names(file_input) @@ -52,7 +52,7 @@ def test_on_file_select(self, root_dir, file_input, readme): file_input._on_file_select({"new": root_dir}) - assert file_input.v_model == "" + assert file_input.v_model == None assert "README.rst" in self.get_names(file_input) # select readme @@ -95,7 +95,7 @@ def test_reset(self, file_input, root_dir, readme): file_input.reset() # assert that the folder has been reset - assert file_input.v_model == "" + assert file_input.v_model == None assert file_input.folder != str(root_dir) return diff --git a/tests/test_ReclassifyModel.py b/tests/test_ReclassifyModel.py index 0ba76aab..6bd9047c 100644 --- a/tests/test_ReclassifyModel.py +++ b/tests/test_ReclassifyModel.py @@ -152,7 +152,9 @@ def test_unique_gee_image(self, model_gee_image, asset_image_aoi, no_name): image_unique_aoi = [30, 40, 50, 100, 110, 120, 130, 160] model_gee_image.band = "y1992" - model_gee_image.aoi_model._from_asset(asset_image_aoi) + model_gee_image.aoi_model._from_asset( + {"pathname": asset_image_aoi, "column": "ALL", "value": None} + ) print(model_gee_image.aoi_model.name) assert model_gee_image.unique() == {str(i): no_name for i in image_unique_aoi} @@ -218,7 +220,9 @@ def test_unique_gee_vector(self, model_gee_vector, asset_table_aoi, no_name): ] model_gee_vector.band = "CODIGO" - model_gee_vector.aoi_model._from_asset(asset_table_aoi) + model_gee_vector.aoi_model._from_asset( + {"pathname": asset_table_aoi, "column": "ALL", "value": None} + ) assert model_gee_vector.unique() == {i: no_name for i in vector_unique_aoi} model_gee_vector.aoi_model = None @@ -289,7 +293,9 @@ def test_reclassify_gee_vector(self, model_gee_vector, asset_table_aoi, alert): model_gee_vector.matrix = matrix model_gee_vector.band = "CODIGO" - model_gee_vector.aoi_model._from_asset(asset_table_aoi) + model_gee_vector.aoi_model._from_asset( + {"pathname": asset_table_aoi, "column": "ALL", "value": None} + ) model_gee_vector.reclassify() if model_gee_vector.save: @@ -316,7 +322,9 @@ def test_reclassify_gee_image(self, model_gee_image, asset_image_aoi, alert): model_gee_image.matrix = matrix model_gee_image.band = "y1992" - model_gee_image.aoi_model._from_asset(asset_image_aoi) + model_gee_image.aoi_model._from_asset( + {"pathname": asset_image_aoi, "column": "ALL", "value": None} + ) model_gee_image.reclassify() if model_gee_image.save: diff --git a/tests/test_ReclassifyView.py b/tests/test_ReclassifyView.py index 752eefe4..8ad6d283 100644 --- a/tests/test_ReclassifyView.py +++ b/tests/test_ReclassifyView.py @@ -94,7 +94,7 @@ def test_load_matrix_content( view_local.load_matrix_content(None, None, None) # When the table is not created before - view_local.import_dialog.w_file.v_model = map_file + view_local.import_dialog.w_file.v_model = str(map_file) view_local.model.table_created = False with pytest.raises(Exception): view_local.load_matrix_content(None, None, None) diff --git a/tests/test_VectorField.py b/tests/test_VectorField.py index c7b62bb5..95b0232e 100644 --- a/tests/test_VectorField.py +++ b/tests/test_VectorField.py @@ -15,7 +15,7 @@ def test_init(self, vector_field): return - def test_update_file(self, vector_field, fake_vector): + def test_update_file(self, vector_field, fake_vector, default_v_model): # change the value of the file vector_field._update_file({"new": str(fake_vector)}) @@ -30,20 +30,53 @@ def test_update_file(self, vector_field, fake_vector): # change for a empty file vector_field._update_file({"new": None}) - assert vector_field.v_model == vector_field.default_v_model + assert vector_field.v_model == default_v_model return - def test_reset(self, vector_field, fake_vector): + def test_update_file_gee(self, vector_field_gee, default_v_model, fake_asset): - # change the value of the file - vector_field._update_file({"new": str(fake_vector)}) + # Arrange + test_data = { + "pathname": fake_asset, + "column": "ALL", + "value": None, + } + + # Act + vector_field_gee._update_file({"new": fake_asset}) + + # Assert + assert vector_field_gee.v_model == test_data + + vector_field_gee._update_file({"new": None}) + assert vector_field_gee.v_model == default_v_model + + return + + def test_reset(self, vector_field, fake_vector, default_v_model): + + # trigger the event + vector_field.w_file.v_model = str(fake_vector) # reset the loadtable vector_field.reset() # assert the current values - assert vector_field.v_model == vector_field.default_v_model + assert vector_field.v_model == default_v_model + + return + + def test_reset_gee(self, vector_field_gee, default_v_model, fake_asset): + + # It will trigger the + vector_field_gee.w_file.v_model = fake_asset + + # reset the loadtable + vector_field_gee.reset() + + # assert the current values + assert vector_field_gee.v_model == default_v_model return @@ -60,6 +93,19 @@ def test_update_column(self, vector_field, fake_vector): return + def test_update_column_gee(self, vector_field_gee, fake_asset): + + # change the value of the file + vector_field_gee._update_file({"new": fake_asset}) + + # read a column + vector_field_gee.w_column.v_model = "CAMBIO" + assert vector_field_gee.v_model["column"] == "CAMBIO" + assert "d-none" not in vector_field_gee.w_value.class_ + assert vector_field_gee.w_value.items == [0, 1, 2, 3, 4, 5, 6, 7] + + return + def test_update_value(self, vector_field, fake_vector): # change the value of the file @@ -73,12 +119,41 @@ def test_update_value(self, vector_field, fake_vector): return + def test_update_value_gee(self, vector_field_gee, fake_asset): + + # change the value of the file + vector_field_gee._update_file({"new": fake_asset}) + + # read a column + vector_field_gee.w_column.v_model = "CAMBIO" + vector_field_gee.w_value.v_model = 1 + + assert vector_field_gee.v_model["value"] == 1 + + return + + @pytest.fixture + def default_v_model(self): + """Returns default v_model""" + + return { + "pathname": None, + "column": None, + "value": None, + } + @pytest.fixture def vector_field(self): """return a VectorField""" return sw.VectorField() + @pytest.fixture + def vector_field_gee(self, gee_dir): + """Instance of VectorField using GEE""" + + return sw.VectorField(gee=True, folder=gee_dir) + @pytest.fixture def fake_vector(self, tmp_dir): """return a fake vector based on the vatican file""" @@ -102,3 +177,9 @@ def fake_vector(self, tmp_dir): [f.unlink() for f in tmp_dir.glob(f"{name}.*")] return + + @pytest.fixture + def fake_asset(self, gee_dir): + """Returns a fake asset""" + + return f"{gee_dir}/reclassify_table" diff --git a/tests/test_utils.py b/tests/test_utils.py index be9d3bd7..c8552b94 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,6 @@ import pytest from unittest.mock import patch +import warnings import random import os @@ -9,6 +10,7 @@ from sepal_ui import sepalwidgets as sw from sepal_ui.scripts import utils as su +from sepal_ui.scripts.warning import SepalWarning class TestUtils: @@ -144,39 +146,61 @@ class Obj: def __init__(self): self.alert = sw.Alert() self.btn = sw.Btn() - self.func = su.loading_button( - alert=self.alert, button=self.btn, debug=False - )(self.func) - self.btn.on_event("click", self.func) - - def func(sel, *args): + @su.loading_button(debug=False) + def func1(self, *args): return 1 / 0 - obj = Obj() - obj.btn.fire_event("click", None) - - assert obj.btn.disabled == False - assert obj.alert.type == "error" - - # create a fake object that uses the decorator - class Obj: - def __init__(self): - self.alert = sw.Alert() - self.btn = sw.Btn() + @su.loading_button(debug=True) + def func2(self, *args): + return 1 / 0 - self.btn.on_event("click", self.func) + @su.loading_button(debug=False) + def func3(self, *args): + warnings.warn("toto") + warnings.warn("sepal", SepalWarning) + return 1 @su.loading_button(debug=True) - def func(self, *args): - return 1 / 0 + def func4(self, *args): + warnings.warn("toto") + warnings.warn("sepal", SepalWarning) + return 1 obj = Obj() - obj.btn.fire_event("click", None) + # should only display error in the alert + obj.func1(obj.btn, None, None) assert obj.btn.disabled == False assert obj.alert.type == "error" + # should raise an error + obj.alert.reset() + with pytest.raises(Exception): + obj.fun2(obj.btn, None, None) + assert obj.btn.disabled == False + assert obj.alert.type == "error" + + # should only display the sepal warning + obj.alert.reset() + obj.func3(obj.btn, None, None) + assert obj.btn.disabled == False + assert obj.alert.type == "warning" + assert "sepal" in obj.alert.children[1].children[0] + assert "toto" not in obj.alert.children[1].children[0] + + # should raise warnings + obj.alert.reset() + with warnings.catch_warnings(record=True) as w_list: + obj.func4(obj.btn, None, None) + assert obj.btn.disabled == False + assert obj.alert.type == "warning" + assert "sepal" in obj.alert.children[1].children[0] + assert "toto" not in obj.alert.children[1].children[0] + msg_list = [w.message.args[0] for w in w_list] + assert any("sepal" in s for s in msg_list) + assert any("toto" in s for s in msg_list) + return def test_to_colors(self):