diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..e663ce059 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +open_collective: holoviz diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e45b20054..c72800eaa 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -20,8 +20,8 @@ jobs: shell: bash -l {0} env: CHANS_DEV: "-c pyviz/label/dev -c bokeh" - PKG_TEST_PYTHON: "--test-python=py37" - PYTHON_VERSION: "3.7" + PKG_TEST_PYTHON: "--test-python=py38" + PYTHON_VERSION: "3.8" CHANS: "-c pyviz" MPLBACKEND: "Agg" CONDA_UPLOAD_TOKEN: ${{ secrets.CONDA_UPLOAD_TOKEN }} @@ -35,7 +35,7 @@ jobs: - uses: conda-incubator/setup-miniconda@v2 with: miniconda-version: "latest" - python-version: 3.8 + python-version: ${{ matrix.python-version }} - name: Fetch unshallow run: git fetch --prune --tags --unshallow -f - name: Set output @@ -64,8 +64,8 @@ jobs: shell: bash -l {0} env: CHANS_DEV: "-c pyviz/label/dev -c bokeh" - PKG_TEST_PYTHON: "--test-python=py37" - PYTHON_VERSION: "3.7" + PKG_TEST_PYTHON: "--test-python=py38" + PYTHON_VERSION: "3.8" CHANS: "-c pyviz" MPLBACKEND: "Agg" PPU: ${{ secrets.PPU }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f19a07040..9607a9bda 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -16,20 +16,10 @@ concurrency: jobs: pre_commit: - name: Run pre-commit hooks + name: Run pre-commit runs-on: 'ubuntu-latest' steps: - - uses: actions/checkout@v3 - with: - fetch-depth: "1" - - name: set PY - run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV - - uses: actions/cache@v3 - with: - path: ~/.cache/pre-commit - key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: pre-commit - uses: pre-commit/action@v3.0.0 + - uses: holoviz-dev/holoviz_tasks/pre-commit@v0.1a17 test_suite: name: Pytest on ${{ matrix.os }} with Python ${{ matrix.python-version }} needs: [pre_commit] @@ -39,35 +29,27 @@ jobs: matrix: os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] # Run on the full set on schedule, workflow_dispatch and push&tags events, otherwise on a subset. - python-version: ${{ ( github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || ( github.event_name == 'push' && github.ref_type == 'tag' ) ) && fromJSON('["3.7", "3.8", "3.9", "3.10", "3.11"]') || fromJSON('["3.7", "3.9", "3.11"]') }} + python-version: ${{ ( github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || ( github.event_name == 'push' && github.ref_type == 'tag' ) ) && fromJSON('["3.8", "3.9", "3.10", "3.11"]') || fromJSON('["3.8", "3.11"]') }} timeout-minutes: 90 defaults: run: shell: bash -el {0} steps: - - uses: holoviz-dev/holoviz_tasks/install@v0.1a12 + - uses: holoviz-dev/holoviz_tasks/install@v0.1a17 with: name: unit_test_suite python-version: ${{ matrix.python-version }} channel-priority: strict channels: pyviz/label/dev,conda-forge,nodefaults - envs: "-o examples_tests -o tests" + envs: "-o examples_tests -o tests -o examples_conda" cache: true conda-update: true - conda-mamba: mamba id: install - - name: patch fiona/geostack on Python 3.7 / Macos - if: steps.install.outputs.cache-hit != 'true' && contains(matrix.os, 'macos') && matrix.python-version == '3.7' - run: | - conda activate test-environment - mamba install "fiona=1.8" "gdal=3.3" - name: doit test_unit run: | conda activate test-environment doit test_unit - name: test examples - # Should be removed when numba support python 3.11 - if: matrix.python-version != '3.11' run: | conda activate test-environment bokeh sampledata @@ -76,50 +58,3 @@ jobs: run: | conda activate test-environment codecov - test_suite_36: - name: Pytest on ${{ matrix.os }} with Python ${{ matrix.python-version }} - needs: [pre_commit] - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: ['macos-latest', 'windows-latest'] - python-version: ['3.6'] - timeout-minutes: 90 - defaults: - run: - shell: bash -l {0} - steps: - - uses: holoviz-dev/holoviz_tasks/install@v0.1a12 - with: - name: unit_test_suite_36 - python-version: ${{ matrix.python-version }} - channel-priority: strict - channels: pyviz/label/dev,conda-forge,nodefaults - envs: "-o tests" - cache: true - conda-update: true - conda-mamba: mamba - id: install - - name: doit develop_install py - if: steps.install.outputs.cache-hit != 'true' - run: | - conda activate test-environment - # - Pin panel on Python 3.6 because one or more dev releases on the 0.13.* series - # can be installed on Python 3.6 but are actually not compatible with Python 3.6 - # Panel 0.13 will support Python >= 3.7 only so the pin here can stay indefinitely. - # - Install importlib_resources to fix tqdm that missed adding it as a dependency - # for 3.6 (https://github.com/conda-forge/tqdm-feedstock/pull/114) - conda install "panel=0.12" "importlib_resources" --no-update-deps - - name: doit env_capture - run: | - conda activate test-environment - doit env_capture - - name: doit test_unit - run: | - conda activate test-environment - doit test_unit - - name: codecov - run: | - conda activate test-environment - codecov diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index 4797bc90b..ad84047d9 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -28,14 +28,6 @@ requirements: - {{ dep }} {% endfor %} -test: - imports: - - hvplot - requires: - {% for dep in sdata['extras_require']['tests'] %} - - {{ dep }} - {% endfor %} - about: home: {{ sdata['url'] }} summary: {{ sdata['description'] }} diff --git a/doc/getting_started/installation.md b/doc/getting_started/installation.md index b97bd7a6c..a6f0bb329 100644 --- a/doc/getting_started/installation.md +++ b/doc/getting_started/installation.md @@ -5,7 +5,7 @@ | Latest release | [![Github release](https://img.shields.io/github/release/holoviz/hvplot.svg?label=tag&colorB=11ccbb)](https://github.com/holoviz/hvplot/releases) [![PyPI version](https://img.shields.io/pypi/v/hvplot.svg?colorB=cc77dd)](https://pypi.python.org/pypi/hvplot) [![hvplot version](https://img.shields.io/conda/v/pyviz/hvplot.svg?colorB=4488ff&style=flat)](https://anaconda.org/pyviz/hvplot) [![conda-forge version](https://img.shields.io/conda/v/conda-forge/hvplot.svg?label=conda%7Cconda-forge&colorB=4488ff)](https://anaconda.org/conda-forge/hvplot) [![defaults version](https://img.shields.io/conda/v/anaconda/hvplot.svg?label=conda%7Cdefaults&style=flat&colorB=4488ff)](https://anaconda.org/anaconda/hvplot) | | Python | [![Python support](https://img.shields.io/pypi/pyversions/hvplot.svg)](https://pypi.org/project/hvplot/) | -hvPlot supports Python 3.6, 3.7, 3.8, 3.9 and 3.10 on Linux, Windows, or Mac. The recommended way to install hvPlot is using the [conda](https://conda.io/en/latest/) command provided by [Anaconda](https://docs.anaconda.com/anaconda/install/index.html) or [Miniconda](https://docs.conda.io/en/latest/miniconda.html): +hvPlot supports Python 3.8, 3.9, 3.10 and 3.11 on Linux, Windows, or Mac. The recommended way to install hvPlot is using the [conda](https://conda.io/en/latest/) command provided by [Anaconda](https://docs.anaconda.com/anaconda/install/index.html) or [Miniconda](https://docs.conda.io/en/latest/miniconda.html): conda install -c pyviz hvplot diff --git a/examples/reference/pandas/scatter.ipynb b/examples/reference/pandas/scatter.ipynb index 97adaf1a4..42a9fd1ac 100644 --- a/examples/reference/pandas/scatter.ipynb +++ b/examples/reference/pandas/scatter.ipynb @@ -50,10 +50,17 @@ "metadata": {}, "outputs": [], "source": [ - "df.hvplot.scatter(x='sepal_length', y='sepal_width', by='species', \n", - " legend='top', height=400, width=400,\n", + "df.hvplot.scatter(x='sepal_length', y='sepal_width', s='petal_length', scale=5, by='species', \n", + " legend='top', height=400, width=600,\n", " hover_cols=[\"species\", \"sepal_length\", \"sepal_width\", \"petal_width\"])" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can add the 's' parameter in scatter to specify the marker plot size and add the 'scale' parameter to specify what the scaling factor should be." + ] } ], "metadata": { @@ -63,5 +70,5 @@ } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } diff --git a/examples/user_guide/Explorer.ipynb b/examples/user_guide/Explorer.ipynb index e86b21490..bd5dae2d6 100644 --- a/examples/user_guide/Explorer.ipynb +++ b/examples/user_guide/Explorer.ipynb @@ -26,7 +26,7 @@ "source": [ "hvPlot API provides a simple and intuitive way to create plots. However when you are exploring data you don't always know in advance the best way to display it, or even what kind of plot would be best to visualize the data. You will very likely embark in an iterative process that implies choosing a kind of plot, setting various options, running some code, and repeat until you're satisfied with the output and the insights you get. The *Explorer* is a *Graphical User Interface* that allows you to easily generate customized plots, which in practice gives you the possibility to **explore** both your data and hvPlot's extensive API.\n", "\n", - "To create an *Explorer* you pass your data to the high-level `hvplot.explorer` function which returns a [Panel](https://panel.holoviz.org/) layout that can be displayed in a notebook or served in a web application. This object displays on the right-hand side a preview of the plot you are building, and on the left-hand side the various options that you can set to customize the plot.\n", + "To create an *Explorer* you pass your data to the high-level `hvplot.explorer` function which returns a [Panel](https://panel.holoviz.org/) layout that can be displayed in a notebook or served in a web application. This object displays on the right-hand side a preview of the plot you are building, and on the left-hand side the various options that you can set to customize the plot. These options can passed to the constructor if you already have pre-defined some, for example `hvplot.explorer(data, title='Penguins', width=200)`.\n", "\n", "Note that for the explorer to be displayed in a notebook you need to load the hvPlot extension, which happens automatically when you execute `import hvplot.pandas`. If instead of building Bokeh plots you would rather build Matplotlib or Plotly plot, simply execute once `hvplot.extension('matplotlib')` or `hvplot.extension('matplotlib')` before displaying the explorer." ] diff --git a/examples/user_guide/Timeseries_Data.ipynb b/examples/user_guide/Timeseries_Data.ipynb index e4ebe709f..48852d137 100644 --- a/examples/user_guide/Timeseries_Data.ipynb +++ b/examples/user_guide/Timeseries_Data.ipynb @@ -56,6 +56,23 @@ "sst.hvplot(xformatter=formatter)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Auto range\n", + "Automatic auto-ranging on the data in x or y is supported, making it easy to scale the given axes and fit the entire visible curve after a zoom or pan." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sst.hvplot(autorange=\"y\")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -166,6 +183,23 @@ "source": [ "Note that xarray supports grouping and aggregation using a similar syntax. To learn more about timeseries in xarray, see the [xarray timeseries docs](https://xarray.pydata.org/en/stable/time-series.html)." ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Downsample time series\n", + "An option when working with large time series is to downsample the data before plotting it. This can be done with `downsample=True`, which applies the `lttb` (Largest Triangle Three Buckets) algorithm to the data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sst.hvplot(label=\"original\") * sst.hvplot(downsample=True, label=\"downsampled\")" + ] } ], "metadata": { diff --git a/hvplot/converter.py b/hvplot/converter.py index c229a0efb..7a837739a 100644 --- a/hvplot/converter.py +++ b/hvplot/converter.py @@ -84,6 +84,9 @@ class HoloViewsConverter: """ Generic options --------------- + autorange (default=None): Literal['x', 'y'] | None + Whether to enable auto-ranging along the x- or y-axis when + zooming. clim: tuple Lower and upper bound of the color scale cnorm (default='linear'): str @@ -182,7 +185,7 @@ class HoloViewsConverter: check_symmetric_max (default=1000000): Size above which to stop checking for symmetry by default on the data. - Datashader options + Downsampling options ------------------ aggregator (default=None): Aggregator to use when applying rasterize or datashade operation @@ -197,6 +200,10 @@ class HoloViewsConverter: Whether to apply rasterization and shading (colormapping) using the Datashader library, returning an RGB object instead of individual points + downsample (default=False): + Whether to apply LTTB (Largest Triangle Three Buckets) + downsampling to the element (note this is only well behaved for + timeseries data). dynspread (default=False): For plots generated with datashade=True or rasterize=True, automatically increase the point size when the data is sparse @@ -381,15 +388,16 @@ def __init__( title=None, xlim=None, ylim=None, clim=None, symmetric=None, logx=None, logy=None, loglog=None, hover=None, subplots=False, label=None, invert=False, stacked=False, colorbar=None, - datashade=False, rasterize=False, aggregation_threshold=None, - row=None, col=None, debug=False, framewise=True, aggregator=None, - projection=None, global_extent=None, geo=False, - precompute=False, flip_xaxis=None, flip_yaxis=None, + datashade=False, rasterize=False, downsample=None, + aggregation_threshold=None, row=None, col=None, debug=False, framewise=True, + aggregator=None, projection=None, global_extent=None, + geo=False, precompute=False, flip_xaxis=None, flip_yaxis=None, dynspread=False, hover_cols=[], x_sampling=None, y_sampling=None, project=False, tools=[], attr_labels=None, coastline=False, tiles=False, sort_date=True, check_symmetric_max=1000000, transforms={}, stream=None, - cnorm=None, features=None, rescale_discrete_levels=None, **kwds + cnorm=None, features=None, rescale_discrete_levels=None, + autorange=None, **kwds ): # Process data and related options self._redim = fields @@ -419,13 +427,13 @@ def __init__( raise ImportError('In order to use geo-related features ' 'the geoviews library must be available. ' 'It can be installed with:\n conda ' - 'install -c pyviz geoviews') + 'install geoviews') if self.geo: if self.kind not in self._geo_types: param.main.param.warning( - "geo option cannot be used with kind=%r plot " + f"geo option cannot be used with kind={self.kind!r} plot " "type. Geographic plots are only supported for " - "following plot types: %r" % (self.kind, self._geo_types)) + f"following plot types: {self._geo_types!r}") from cartopy import crs as ccrs from geoviews.util import project_extents @@ -442,7 +450,7 @@ def __init__( else: raise ValueError( "Projection must be defined as cartopy CRS or " - "one of the following CRS string:\n {}".format(all_crs)) + f"one of the following CRS string:\n {all_crs}") projection = projection or (ccrs.GOOGLE_MERCATOR if tiles else self.crs) if tiles and projection != ccrs.GOOGLE_MERCATOR: @@ -467,6 +475,7 @@ def __init__( self.aggregation_threshold = aggregation_threshold self.datashade = datashade self.rasterize = rasterize + self.downsample = downsample if self.aggregation_threshold and not (self.datashade or self.rasterize): self.rasterize = True self.dynspread = dynspread @@ -494,6 +503,8 @@ def __init__( if ylim is not None: plot_opts['ylim'] = tuple(ylim) + plot_opts['autorange'] = autorange + self.invert = invert if loglog is not None: logx = logx or loglog @@ -591,7 +602,7 @@ def __init__( symmetric = self._process_symmetric(symmetric, clim, check_symmetric_max) if self._style_opts.get('cmap') is None: # Default to categorical camp if we detect categorical shading - if (self.datashade and (self.aggregator is None or 'count_cat' in str(self.aggregator)) and + if ((self.datashade or self.rasterize) and (self.aggregator is None or 'count_cat' in str(self.aggregator)) and ((self.by and not self.subplots) or (isinstance(self.y, list) or (self.y is None and len(set(self.variables) - set(self.indexes)) > 1)))): self._style_opts['cmap'] = self._default_cmaps['categorical'] @@ -673,8 +684,8 @@ def _process_crs(self, data, crs): # only raise error if crs was specified in kwargs if crs: raise ValueError( - "'{}' must be either a valid crs or an reference to " - "a `data.attr` containing a valid crs.".format(crs)) + f"'{crs}' must be either a valid crs or an reference to " + "a `data.attr` containing a valid crs.") def _process_data(self, kind, data, x, y, by, groupby, row, col, use_dask, persist, backlog, label, group_label, @@ -860,14 +871,14 @@ def _process_data(self, kind, data, x, y, by, groupby, row, col, groupby.append(data_dim) self.variables = list(data.coords) + data_vars if groupby and not_found: - raise ValueError('The supplied groupby dimension(s) %s ' + raise ValueError(f'The supplied groupby dimension(s) {not_found} ' 'could not be found, expected one or ' - 'more of: %s' % (not_found, list(data.coords))) + f'more of: {list(data.coords)}') else: if gridded and kind not in ('points', 'dataset'): - raise ValueError('%s plot type requires gridded data, ' + raise ValueError(f'{kind} plot type requires gridded data, ' 'e.g. a NumPy array or xarray Dataset, ' - 'found %s type' % (kind, type(self.data).__name__)) + f'found {type(self.data).__name__} type') if hasattr(data, 'columns') and hasattr(data.columns, 'name') and data.columns.name and not group_label: group_label = data.columns.name @@ -912,9 +923,9 @@ def _process_data(self, kind, data, x, y, by, groupby, row, col, not_found = [g for g in groupby+by_cols if g not in list(self.data.columns)+indexes] not_found, self.data = process_derived_datetime_pandas(self.data, not_found, indexes) if groupby and not_found: - raise ValueError('The supplied groupby dimension(s) %s ' + raise ValueError(f'The supplied groupby dimension(s) {not_found} ' 'could not be found, expected one or ' - 'more of: %s' % (not_found, list(self.data.columns))) + f'more of: {list(self.data.columns)}') if transforms: self.data = Dataset(self.data, indexes).transform(**transforms).data @@ -976,7 +987,7 @@ def _process_data(self, kind, data, x, y, by, groupby, row, col, except Exception as e: if attr_labels is True: param.main.param.warning('Unable to auto label using xarray attrs ' - 'because {e}'.format(e=e)) + f'because {e}') def _process_plot(self): kind = self.kind @@ -1265,10 +1276,28 @@ def method_wrapper(ds, x, y): projection = self._plot_opts.get('projection', ccrs.GOOGLE_MERCATOR) obj = project(obj, projection=projection) - if not (self.datashade or self.rasterize): + if not (self.datashade or self.rasterize or self.downsample): layers = self._apply_layers(obj) layers = _transfer_opts_cur_backend(layers) return layers + + opts = dict(dynamic=self.dynamic) + if self._plot_opts.get('width') is not None: + opts['width'] = self._plot_opts['width'] + if self._plot_opts.get('height') is not None: + opts['height'] = self._plot_opts['height'] + + if self.downsample: + from holoviews.operation.downsample import downsample1d + + if self.x_sampling: + opts['x_sampling'] = self.x_sampling + if self._plot_opts.get('xlim') is not None: + opts['x_range'] = self._plot_opts['xlim'] + layers = downsample1d(obj, **opts) + layers = _transfer_opts_cur_backend(layers) + return layers + try: from holoviews.operation.datashader import datashade, rasterize, dynspread from datashader import reductions @@ -1276,13 +1305,7 @@ def method_wrapper(ds, x, y): raise ImportError('In order to use datashading features ' 'the Datashader library must be available. ' 'It can be installed with:\n conda ' - 'install -c pyviz datashader') - - opts = dict(dynamic=self.dynamic) - if self._plot_opts.get('width') is not None: - opts['width'] = self._plot_opts['width'] - if self._plot_opts.get('height') is not None: - opts['height'] = self._plot_opts['height'] + 'install datashader') categorical = False if self.by and not self.subplots: @@ -1313,8 +1336,6 @@ def method_wrapper(ds, x, y): opts['x_range'] = self._plot_opts['xlim'] if self._plot_opts.get('ylim') is not None: opts['y_range'] = self._plot_opts['ylim'] - if not self.dynamic: - opts['dynamic'] = self.dynamic if 'cmap' in self._style_opts and self.datashade: levels = self._plot_opts.get('color_levels') @@ -1339,7 +1360,10 @@ def method_wrapper(ds, x, y): opts['rescale_discrete_levels'] = self._plot_opts['rescale_discrete_levels'] else: operation = rasterize - eltype = 'Image' + if Version(hv.__version__) < Version('1.18.0a1'): + eltype = 'Image' + else: + eltype = 'ImageStack' if self.by else 'Image' if 'cmap' in self._style_opts: style['cmap'] = self._style_opts['cmap'] if self._dim_ranges.get('c', (None, None)) != (None, None): @@ -1408,12 +1432,14 @@ def _apply_layers(self, obj): scale) else: feature_obj = feature_obj.opts(scale=scale) - obj = feature_obj * obj + if feature_obj.group in ["Land", "Ocean"]: + obj = feature_obj * obj # Underlay land/ocean + else: + obj = obj * feature_obj # overlay everything else if self.tiles: tile_source = 'EsriImagery' if self.tiles == 'ESRI' else self.tiles - warning = ("%s tiles not recognized, must be one of: %s or a tile object" % - (tile_source, sorted(hv.element.tile_sources))) + warning = ("{} tiles not recognized, must be one of: {} or a tile object".format(tile_source, sorted(hv.element.tile_sources))) if tile_source is True: tiles = hv.element.tiles.OSM() elif tile_source in hv.element.tile_sources.keys(): diff --git a/hvplot/interactive.py b/hvplot/interactive.py index baabfd574..844f9b9a4 100644 --- a/hvplot/interactive.py +++ b/hvplot/interactive.py @@ -227,7 +227,7 @@ def __new__(cls, obj, **kwargs): for subcls in cls.__subclasses__(): if subcls.applies(obj): clss = subcls - inst = super(Interactive, cls).__new__(clss) + inst = super().__new__(clss) inst._shared_obj = kwargs.get('_shared_obj', [obj]) inst._fn = fn return inst diff --git a/hvplot/plotting/core.py b/hvplot/plotting/core.py index 6d7e99e10..6315ff498 100644 --- a/hvplot/plotting/core.py +++ b/hvplot/plotting/core.py @@ -57,7 +57,7 @@ def __call__(self, x=None, y=None, kind=None, **kwds): if isinstance(kind, str) and kind not in self.__all__: raise NotImplementedError( - "kind='{kind}' for data of type {type}".format(kind=kind, type=type(self._data)) + f"kind='{kind}' for data of type {type(self._data)}" ) if panel_available: @@ -115,8 +115,8 @@ def __getattribute__(self, name): if "kind" in plot_opts and name in HoloViewsConverter._kind_mapping: param.main.param.warning( "Custom options for existing plot types should not " - "declare the 'kind' argument. The .%s plot method " - "was unexpectedly customized with kind=%r." % (plot_opts["kind"], name) + "declare the 'kind' argument. The .{} plot method " + "was unexpectedly customized with kind={!r}.".format(plot_opts["kind"], name) ) plot_opts["kind"] = name return hvPlotBase(self._data, **dict(self._metadata, **plot_opts)) diff --git a/hvplot/plotting/scatter_matrix.py b/hvplot/plotting/scatter_matrix.py index ecd975f97..17595c003 100644 --- a/hvplot/plotting/scatter_matrix.py +++ b/hvplot/plotting/scatter_matrix.py @@ -82,11 +82,9 @@ def scatter_matrix(data, c=None, chart='scatter', diagonal='hist', data = _hv.Dataset(_convert_col_names_to_str(data)) supported = list(HoloViewsConverter._kind_mapping) if diagonal not in supported: - raise ValueError('diagonal type must be one of: %s, found %s' % - (supported, diagonal)) + raise ValueError(f'diagonal type must be one of: {supported}, found {diagonal}') if chart not in supported: - raise ValueError('Chart type must be one of: %s, found %s' % - (supported, chart)) + raise ValueError(f'Chart type must be one of: {supported}, found {chart}') diagonal = HoloViewsConverter._kind_mapping[diagonal] chart = HoloViewsConverter._kind_mapping[chart] diff --git a/hvplot/tests/test_links.py b/hvplot/tests/test_links.py index feced6fe3..3f8d8d34e 100644 --- a/hvplot/tests/test_links.py +++ b/hvplot/tests/test_links.py @@ -60,7 +60,7 @@ def _clean_url(url: str): def _find_urls(text): url = re.findall(URL_REGEX, text) - return set(_clean_url(x[0]) for x in url if not _skip_url(x[0])) + return {_clean_url(x[0]) for x in url if not _skip_url(x[0])} def _request_a_response(url): diff --git a/hvplot/tests/testgeo.py b/hvplot/tests/testgeo.py index 6a3435421..c92c32765 100644 --- a/hvplot/tests/testgeo.py +++ b/hvplot/tests/testgeo.py @@ -173,6 +173,11 @@ def test_plot_with_specific_gv_tile_obj(self): self.assertEqual(len(plot), 2) self.assertIsInstance(plot.get(0), gv.element.WMTS) + def test_plot_with_features_properly_overlaid_underlaid(self): + # land should be under, borders should be over + plot = self.df.hvplot.points('x', 'y', features=["land", "borders"]) + assert plot.get(0).group == "Land" + assert plot.get(2).group == "Borders" class TestGeoElements(TestCase): diff --git a/hvplot/tests/testoperations.py b/hvplot/tests/testoperations.py index 947b2443d..0441f9ba6 100644 --- a/hvplot/tests/testoperations.py +++ b/hvplot/tests/testoperations.py @@ -3,17 +3,20 @@ from unittest import SkipTest from parameterized import parameterized +import colorcet as cc +import holoviews as hv import hvplot.pandas # noqa import numpy as np import pandas as pd from holoviews import Store, render -from holoviews.element import Image, QuadMesh +from holoviews.element import Image, QuadMesh, ImageStack from holoviews.core.spaces import DynamicMap from holoviews.core.overlay import Overlay from holoviews.element.chart import Scatter from holoviews.element.comparison import ComparisonTestCase from hvplot.converter import HoloViewsConverter +from packaging.version import Version class TestDatashader(ComparisonTestCase): @@ -197,6 +200,14 @@ def test_datashade_rescale_discrete_levels_default_True(self): actual = plot.callback.inputs[0].callback.operation.p['rescale_discrete_levels'] assert actual is expected + def test_rasterize_by(self): + if Version(hv.__version__) < Version('1.18.0a1'): + raise SkipTest('hv.ImageStack introduced after 1.18.0a1') + expected = 'category' + plot = self.df.hvplot(x='x', y='y', by=expected, rasterize=True, dynamic=False) + assert isinstance(plot, ImageStack) + assert plot.opts["cmap"] == cc.palette['glasbey_category10'] + @parameterized.expand([('rasterize',), ('datashade',)]) def test_aggregation_threshold(self, operation): df = pd.DataFrame( diff --git a/hvplot/tests/testoptions.py b/hvplot/tests/testoptions.py index 215d67005..e961a1261 100644 --- a/hvplot/tests/testoptions.py +++ b/hvplot/tests/testoptions.py @@ -7,7 +7,6 @@ from holoviews import Store from holoviews.core.options import Options, OptionTree -from packaging.version import Version @pytest.fixture(scope='class') @@ -544,8 +543,4 @@ def test_dataset_scatter_with_title(self, ds2, backend): ds_sel = ds2.sel(time=0, band=0, x=0, y=0) plot = ds_sel.hvplot.scatter(x='foo', y='bar') # Image plot opts = Store.lookup_options(backend, plot, 'plot') - # First assertion to remove when support for Python 3.7 is dropped. - if Version(xr.__version__) < Version('2022.6.0'): - assert opts.kwargs['title'] == 'y = 0, x = 0, time = 0, band = 0' - else: - assert opts.kwargs['title'] == 'time = 0, y = 0, x = 0, band = 0' + assert opts.kwargs['title'] == 'time = 0, y = 0, x = 0, band = 0' diff --git a/hvplot/tests/testui.py b/hvplot/tests/testui.py index d80889ce9..2d9062ecb 100644 --- a/hvplot/tests/testui.py +++ b/hvplot/tests/testui.py @@ -1,20 +1,14 @@ +import re + import holoviews as hv import hvplot.pandas -import pytest -try: - from bokeh.sampledata import penguins -except ImportError: - penguins = None +import pytest +from bokeh.sampledata import penguins from hvplot.ui import hvDataFrameExplorer -pytestmark = pytest.mark.skipif( - penguins is None, - reason='Penguins dataset not available on Python 3.6', -) - -df = penguins.data if penguins is not None else None +df = penguins.data def test_explorer_basic(): @@ -95,3 +89,17 @@ def test_explorer_save(tmp_path): explorer.save(outfile) assert outfile.exists() + + +def test_explorer_kwargs_controls(): + explorer = hvplot.explorer(df, title='Dummy title', width=200) + + assert explorer.labels.title == 'Dummy title' + assert explorer.axes.width == 200 + + +def test_explorer_kwargs_controls_error_not_supported(): + with pytest.raises( + TypeError, match=re.escape("__init__() got keyword(s) not supported by any control: {'not_a_control_kwarg': None}") + ): + hvplot.explorer(df, title='Dummy title', not_a_control_kwarg=None) diff --git a/hvplot/tests/testutil.py b/hvplot/tests/testutil.py index dd578c925..d24e8377f 100644 --- a/hvplot/tests/testutil.py +++ b/hvplot/tests/testutil.py @@ -11,7 +11,7 @@ from hvplot.util import ( check_crs, is_list_like, process_crs, process_xarray, - _convert_col_names_to_str, + _convert_col_names_to_str ) @@ -217,6 +217,14 @@ def test_proj_to_cartopy(self): assert isinstance(crs, self.ccrs.CRS) + def test_proj_to_cartopy_wkt_string(self): + from ..util import proj_to_cartopy + crs = proj_to_cartopy('GEOGCRS["unnamed",BASEGEOGCRS["unknown",DATUM["unknown",ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1,ID["EPSG",9001]]]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8901]]],DERIVINGCONVERSION["unknown",METHOD["PROJ ob_tran o_proj=latlon"],PARAMETER["o_lon_p",0,ANGLEUNIT["degree",0.0174532925199433,ID["EPSG",9122]]],PARAMETER["o_lat_p",37.5,ANGLEUNIT["degree",0.0174532925199433,ID["EPSG",9122]]],PARAMETER["lon_0",357.5,ANGLEUNIT["degree",0.0174532925199433,ID["EPSG",9122]]]],CS[ellipsoidal,2],AXIS["longitude",east,ORDER[1],ANGLEUNIT["degree",0.0174532925199433,ID["EPSG",9122]]],AXIS["latitude",north,ORDER[2],ANGLEUNIT["degree",0.0174532925199433,ID["EPSG",9122]]]]') # noqa: E501 + + assert isinstance(crs, self.ccrs.RotatedPole) + assert crs.proj4_params["lon_0"] == 357.5 + assert crs.proj4_params["o_lat_p"] == 37.5 + class TestDynamicArgs(TestCase): @@ -281,24 +289,70 @@ def test_check_crs(): assert p is None -@pytest.mark.skipif( - sys.version_info < (3, 8), - reason="PyProj is no longer releasing for Python 3.7", -) @pytest.mark.parametrize("input", [ "+init=epsg:26911", +]) +def test_process_crs(input): + pytest.importorskip("pyproj") + ccrs = pytest.importorskip("cartopy.crs") + crs = process_crs(input) + assert isinstance(crs, ccrs.CRS) + + +def test_process_crs_pyproj_crs(): + pyproj = pytest.importorskip("pyproj") + ccrs = pytest.importorskip("cartopy.crs") + crs = process_crs(pyproj.CRS.from_epsg(4326)) + assert isinstance(crs, ccrs.PlateCarree) + + +def test_process_crs_pyproj_proj(): + pyproj = pytest.importorskip("pyproj") + ccrs = pytest.importorskip("cartopy.crs") + crs = process_crs(pyproj.Proj(init='epsg:4326')) + assert isinstance(crs, ccrs.PlateCarree) + + +@pytest.mark.parametrize("input", [ "4326", 4326, "epsg:4326", "EPSG: 4326", -]) -def test_process_crs(input): + "+init=epsg:4326", + # Created with pyproj.CRS("EPSG:4326").to_wkt() + 'GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",MEMBER["World Geodetic System 1984 (Transit)"],MEMBER["World Geodetic System 1984 (G730)"],MEMBER["World Geodetic System 1984 (G873)"],MEMBER["World Geodetic System 1984 (G1150)"],MEMBER["World Geodetic System 1984 (G1674)"],MEMBER["World Geodetic System 1984 (G1762)"],MEMBER["World Geodetic System 1984 (G2139)"],ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]],ENSEMBLEACCURACY[2.0]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433]],CS[ellipsoidal,2],AXIS["geodetic latitude (Lat)",north,ORDER[1],ANGLEUNIT["degree",0.0174532925199433]],AXIS["geodetic longitude (Lon)",east,ORDER[2],ANGLEUNIT["degree",0.0174532925199433]],USAGE[SCOPE["Horizontal component of 3D system."],AREA["World."],BBOX[-90,-180,90,180]],ID["EPSG",4326]]', +], ids=lambda x: str(x)[:20]) +def test_process_crs_platecarree(input): pytest.importorskip("pyproj") ccrs = pytest.importorskip("cartopy.crs") crs = process_crs(input) + assert isinstance(crs, ccrs.PlateCarree) + + +@pytest.mark.parametrize("input", [ + "3857", + 3857, + "epsg:3857", + "EPSG: 3857", + "+init=epsg:3857", + 'PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]],PROJECTION["Mercator_1SP"],PARAMETER["central_meridian",0],PARAMETER["scale_factor",1],PARAMETER["false_easting",0],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],EXTENSION["PROJ4","+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs"],AUTHORITY["EPSG","3857"]]', + # Created with pyproj.CRS("EPSG:3857").to_wkt() + 'PROJCRS["WGS 84 / Pseudo-Mercator",BASEGEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",MEMBER["World Geodetic System 1984 (Transit)"],MEMBER["World Geodetic System 1984 (G730)"],MEMBER["World Geodetic System 1984 (G873)"],MEMBER["World Geodetic System 1984 (G1150)"],MEMBER["World Geodetic System 1984 (G1674)"],MEMBER["World Geodetic System 1984 (G1762)"],MEMBER["World Geodetic System 1984 (G2139)"],ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]],ENSEMBLEACCURACY[2.0]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433]],ID["EPSG",4326]],CONVERSION["Popular Visualisation Pseudo-Mercator",METHOD["Popular Visualisation Pseudo Mercator",ID["EPSG",1024]],PARAMETER["Latitude of natural origin",0,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8801]],PARAMETER["Longitude of natural origin",0,ANGLEUNIT["degree",0.0174532925199433],ID["EPSG",8802]],PARAMETER["False easting",0,LENGTHUNIT["metre",1],ID["EPSG",8806]],PARAMETER["False northing",0,LENGTHUNIT["metre",1],ID["EPSG",8807]]],CS[Cartesian,2],AXIS["easting (X)",east,ORDER[1],LENGTHUNIT["metre",1]],AXIS["northing (Y)",north,ORDER[2],LENGTHUNIT["metre",1]],USAGE[SCOPE["Web mapping and visualisation."],AREA["World between 85.06°S and 85.06°N."],BBOX[-85.06,-180,85.06,180]],ID["EPSG",3857]]', +], ids=lambda x: str(x)[:20]) +def test_process_crs_mercator(input): + pytest.importorskip("pyproj") + ccrs = pytest.importorskip("cartopy.crs") + crs = process_crs(input) + assert isinstance(crs, ccrs.Mercator) - assert isinstance(crs, ccrs.CRS) +def test_process_crs_rasterio(): + pytest.importorskip("pyproj") + rcrs = pytest.importorskip("rasterio.crs") + ccrs = pytest.importorskip("cartopy.crs") + input = rcrs.CRS.from_epsg(4326).to_wkt() + crs = process_crs(input) + assert isinstance(crs, ccrs.CRS) def test_process_crs_raises_error(): pytest.importorskip("pyproj") diff --git a/hvplot/ui.py b/hvplot/ui.py index 301aca48d..2c677bb37 100644 --- a/hvplot/ui.py +++ b/hvplot/ui.py @@ -369,19 +369,12 @@ def __init__(self, df, **params): df, x, y, **{k: v for k, v in params.items() if k not in ('x', 'y', 'y_multi')} ) - controller_params = {} - # Assumes the controls aren't passed on instantiation. - controls = [ - p.class_ - for p in self.param.objects().values() - if isinstance(p, param.ClassSelector) - and issubclass(p.class_, Controls) - ] - for cls in controls: - controller_params[cls] = { - k: params.pop(k) for k, v in dict(params).items() - if k in cls.param - } + # Collect kwargs passed to the constructor but meant for the controls + extras = { + k: params.pop(k) + for k in params.copy() + if k not in self.param + } super().__init__(**params) self._data = df self._converter = converter @@ -396,9 +389,26 @@ def __init__(self, df, **params): self._tabs = pn.Tabs( tabs_location='left', width=400 ) + controls = [ + p.class_ + for p in self.param.objects().values() + if isinstance(p, param.ClassSelector) + and issubclass(p.class_, Controls) + ] + controller_params = {} + for cls in controls: + controller_params[cls] = { + k: extras.pop(k) + for k in extras.copy() + if k in cls.param + } + if extras: + raise TypeError( + f'__init__() got keyword(s) not supported by any control: {extras}' + ) self._controllers = { - cls.name.lower(): cls(df, explorer=self, **params) - for cls, params in controller_params.items() + cls.name.lower(): cls(df, explorer=self, **cparams) + for cls, cparams in controller_params.items() } self.param.set_param(**self._controllers) self.param.watch(self._plot, list(self.param)) @@ -577,7 +587,7 @@ def settings(self): """ settings = {} for controller in self._controllers.values(): - params = set(controller.param) - set(['name', 'explorer']) + params = set(controller.param) - {'name', 'explorer'} for p in params: value = getattr(controller, p) if value != controller.param[p].default: diff --git a/hvplot/util.py b/hvplot/util.py index 90497af16..0d1f311d3 100644 --- a/hvplot/util.py +++ b/hvplot/util.py @@ -62,14 +62,14 @@ def check_crs(crs): Returns ------- - A valid crs if possible, otherwise None + A valid crs if possible, otherwise None. """ import pyproj try: crs_type = pyproj.crs.CRS except AttributeError: - class Dummy(): + class Dummy: pass crs_type = Dummy @@ -79,8 +79,11 @@ class Dummy(): out = pyproj.Proj(crs.to_wkt(), preserve_units=True) elif isinstance(crs, dict) or isinstance(crs, str): if isinstance(crs, str): - # quick fix for https://github.com/pyproj4/pyproj/issues/345 - crs = crs.replace(' ', '').replace('+', ' +') + try: + crs = pyproj.CRS.from_wkt(crs) + except RuntimeError: + # quick fix for https://github.com/pyproj4/pyproj/issues/345 + crs = crs.replace(' ', '').replace('+', ' +') try: out = pyproj.Proj(crs, preserve_units=True) except RuntimeError: @@ -117,7 +120,6 @@ def proj_to_cartopy(proj): a cartopy.crs.Projection object """ - import cartopy import cartopy.crs as ccrs try: from osgeo import osr @@ -125,10 +127,10 @@ def proj_to_cartopy(proj): except ImportError: has_gdal = False - proj = check_crs(proj) - - if proj_is_latlong(proj): - return ccrs.PlateCarree() + input_proj = proj + proj = check_crs(input_proj) + if proj is None: + raise ValueError(f"Invalid proj projection {input_proj!r}") srs = proj.srs if has_gdal: @@ -168,19 +170,23 @@ def proj_to_cartopy(proj): except: pass if k == 'proj': - if v == 'tmerc': + if v == "longlat": + cl = ccrs.PlateCarree + elif v == 'tmerc': cl = ccrs.TransverseMercator kw_proj['approx'] = True - if v == 'lcc': + elif v == 'lcc': cl = ccrs.LambertConformal - if v == 'merc': + elif v == 'merc': cl = ccrs.Mercator - if v == 'utm': + elif v == 'utm': cl = ccrs.UTM - if v == 'stere': + elif v == 'stere': cl = ccrs.Stereographic - if v == 'ob_tran': + elif v == 'ob_tran': cl = ccrs.RotatedPole + else: + raise NotImplementedError(f'Unknown projection {v}') if k in km_proj: if k == 'zone': v = int(v) @@ -200,7 +206,7 @@ def proj_to_cartopy(proj): if cl.__name__ == 'Mercator': kw_proj.pop('false_easting', None) kw_proj.pop('false_northing', None) - if Version(cartopy.__version__) < Version('0.15'): + if "scale_factor" in kw_proj: kw_proj.pop('latitude_true_scale', None) elif cl.__name__ == 'Stereographic': kw_proj.pop('scale_factor', None) @@ -229,7 +235,9 @@ def process_crs(crs): 1. EPSG codes: Defined as string of the form "EPSG: {code}" or an integer 2. proj.4 string: Defined as string of the form "{proj.4 string}" 3. cartopy.crs.CRS instance - 4. None defaults to crs.PlateCaree + 3. pyproj.Proj or pyproj.CRS instance + 4. WKT string: Defined as string of the form "{WKT string}" + 5. None defaults to crs.PlateCaree """ missing = [] try: @@ -249,29 +257,27 @@ def process_crs(crs): if crs is None: return ccrs.PlateCarree() + elif isinstance(crs, ccrs.CRS): + return crs + elif isinstance(crs, pyproj.CRS): + crs = crs.to_wkt() errors = [] - if isinstance(crs, str) and crs.lower().startswith('epsg'): + if isinstance(crs, (str, int)): # epsg codes try: - crs = crs[5:].lstrip().rstrip() - return ccrs.epsg(crs) + crs = pyproj.CRS.from_epsg(crs).to_wkt() except Exception as e: errors.append(e) - if isinstance(crs, int): - try: - return ccrs.epsg(crs) - except Exception as e: - crs = str(crs) - errors.append(e) - if isinstance(crs, (str, pyproj.Proj)): + if isinstance(crs, (str, pyproj.Proj)): # proj4/wkt strings try: return proj_to_cartopy(crs) except Exception as e: errors.append(e) - if isinstance(crs, ccrs.CRS): - return crs - raise ValueError("Projection must be defined as a EPSG code, proj4 string, cartopy CRS or pyproj.Proj.") from Exception(*errors) + raise ValueError( + "Projection must be defined as a EPSG code, proj4 string, " + "WKT string, cartopy CRS, pyproj.Proj, or pyproj.CRS." + ) from Exception(*errors) def is_list_like(obj): diff --git a/hvplot/utilities.py b/hvplot/utilities.py index d3f92062c..af9cb9c34 100644 --- a/hvplot/utilities.py +++ b/hvplot/utilities.py @@ -22,11 +22,14 @@ def show(obj, title=None, port=0, **kwargs): Allows specifying a specific port **kwargs: dict Additional keyword arguments passed to Panel show method. + Returns + ------- + a panel.io.server.Server | panel.io.server.StoppableThread (if threaded=true) """ if not isinstance(obj, _hv.core.Dimensioned): raise ValueError('%s type object not recognized and cannot be shown.' % type(obj).__name__) - _pn.pane.HoloViews(obj).show(title, port, **kwargs) + return _pn.pane.HoloViews(obj).show(title, port, **kwargs) class hvplot_extension(_hv.extension): diff --git a/setup.py b/setup.py index ccb00f84b..61b3c7195 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def get_setup_version(reponame): 'colorcet >=2', 'holoviews >=1.11.0', 'pandas', - 'numpy>=1.15', + 'numpy >=1.15', 'packaging', 'panel >=0.11.0', 'param >=1.9.0', @@ -86,30 +86,21 @@ def get_setup_version(reponame): 'pooch >=1.6.0', 'fiona', 'rioxarray', - # Extra dependency of cartopy on Python 3.6 only - 'pyepsg', 'matplotlib', 'plotly', 'pygraphviz', - 'ipykernel <6.18.0', # temporary - 'numpy < 1.24', # temporary, for a numba error 'ipywidgets', + 'numba >=0.51.0', + 'datashader >=0.6.5', + 'spatialpandas >=0.4.3', ] -# Packages not working on python 3.11 because of numba -if sys.version_info < (3, 11): - extras_require['examples'] += [ - 'numba >=0.51.0', - 'datashader >=0.6.5', - 'spatialpandas >=0.4.3', - ] - # Run the example tests by installing examples_tests together with tests extras_require["examples_tests"] = extras_require["examples"] + extras_require['tests_nb'] # Additional packages required to build the docs extras_require['doc'] = extras_require['examples'] + [ - 'nbsite >=0.8.0rc33', + 'nbsite >=0.8.2', ] # until pyproject.toml/equivalent is widely supported (setup_requires @@ -128,7 +119,7 @@ def get_setup_version(reponame): name='hvplot', version=get_setup_version("hvplot"), description='A high-level plotting API for the PyData ecosystem built on HoloViews.', - long_description=open("README.md", mode="r", encoding="utf-8").read(), + long_description=open("README.md", encoding="utf-8").read(), long_description_content_type="text/markdown", author= "Philipp Rudiger", author_email= "developers@pyviz.org", @@ -142,8 +133,6 @@ def get_setup_version(reponame): classifiers = [ "License :: OSI Approved :: BSD License", "Development Status :: 5 - Production/Stable", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -154,7 +143,7 @@ def get_setup_version(reponame): "Natural Language :: English", "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries"], - python_requires=">=3.6", + python_requires=">=3.8", install_requires=install_requires, extras_require=extras_require, tests_require=extras_require['tests'], diff --git a/tox.ini b/tox.ini index 6ed70eb3c..2d82b8664 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ [tox] # python version test group extra envs extra commands -envlist = {py36,py37,py38,py39,py310,py311}-{unit,examples,all}-{default}-{dev,pkg} +envlist = {py38,py39,py310,py311}-{unit,examples,all}-{default}-{dev,pkg} [_unit] description = Run unit tests with coverage