diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25ef7f533..71e24fa96 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,8 +40,8 @@ jobs: if: ${{ matrix.python-version == '3.8' }} run: | pip uninstall -y pyarrow vegafusion vegafusion-python-embed - - name: Maybe install lowest supported Pandas version - # We install the lowest supported Pandas version for one job to test that + - name: Maybe install lowest supported pandas version + # We install the lowest supported pandas version for one job to test that # it still works. Downgrade to the oldest versions of pandas and numpy that include # Python 3.8 wheels, so only run this job for Python 3.8 if: ${{ matrix.python-version == '3.8' }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18681e585..a7a1e09c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,14 @@ git switch -c With this branch checked-out, make the desired changes to the package. +A large part of Altair's code base is automatically generated. +After you have made your manual changes, +make sure to run the following to see if there are any changes +to the automatically generated files: `python tools/generate_schema_wrapper.py`. + +For information on how to update the Vega-Lite version that Altair uses, +please read [the maintainers' notes](NOTES_FOR_MAINTAINERS.md). + ### Testing your Changes Before suggesting your contributing your changing to the main Altair repository, diff --git a/RELEASING.md b/RELEASING.md index f90fb82af..5e6373b5d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,5 @@ -1. Create a new virtual environment following the instructions in `CONTRIBUTING.md`. - Make sure to also install all dependencies for the documentation including `altair_saver` - and uninstall `vl-convert-python` (this is not needed for normal contributions to the repo, see `CONTRIBUTING.md` for details). +1. Create a new virtual environment following the instructions in `CONTRIBUTING.md`. + Make sure to also install all dependencies for the documentation. 2. Make certain your branch is in sync with head: @@ -21,7 +20,7 @@ 5. Update version to, e.g. 5.0.0: - in ``altair/__init__.py`` - - in ``doc/conf.py`` (two places) + - in ``doc/conf.py`` 6. Double-check that all vega-lite/vega/vega-embed versions are up-to-date: @@ -55,19 +54,21 @@ 12. update version to, e.g. 5.1.0dev: - in ``altair/__init__.py`` - - in ``doc/conf.py`` (two places) + - in ``doc/conf.py`` 13. add a new changelog entry for the unreleased version: - Version 5.1.0 (unreleased) - -------------------------- - - Enhancements - ~~~~~~~~~~~~ - Bug Fixes - ~~~~~~~~~ - Backward-Incompatible Changes - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ``` + Version 5.1.0 (unreleased) + -------------------------- + + Enhancements + ~~~~~~~~~~~~ + Bug Fixes + ~~~~~~~~~ + Backward-Incompatible Changes + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ``` 14. Commit change and push to main: diff --git a/altair/utils/_importers.py b/altair/utils/_importers.py index ed83b1a72..6ee41351d 100644 --- a/altair/utils/_importers.py +++ b/altair/utils/_importers.py @@ -29,7 +29,7 @@ def import_vegafusion() -> ModuleType: def import_vl_convert() -> ModuleType: - min_version = "0.13.0" + min_version = "0.14.0" try: version = importlib_version("vl-convert-python") if Version(version) < Version(min_version): diff --git a/altair/utils/core.py b/altair/utils/core.py index fa83665f5..28601db3c 100644 --- a/altair/utils/core.py +++ b/altair/utils/core.py @@ -343,7 +343,7 @@ def to_list_if_array(val): if dtype_name == "category": # Work around bug in to_json for categorical types in older versions # of pandas as they do not properly convert NaN values to null in to_json. - # We can probably remove this part once we require Pandas >= 1.0 + # We can probably remove this part once we require pandas >= 1.0 col = df[col_name].astype(object) df[col_name] = col.where(col.notnull(), None) elif dtype_name == "string": @@ -588,8 +588,10 @@ def parse_shorthand( column = dfi.get_column_by_name(unescaped_field) try: attrs["type"] = infer_vegalite_type_for_dfi_column(column) - except NotImplementedError: - # Fall back to pandas-based inference + except (NotImplementedError, AttributeError, ValueError): + # Fall back to pandas-based inference. + # Note: The AttributeError catch is a workaround for + # https://github.com/pandas-dev/pandas/issues/55332 if isinstance(data, pd.DataFrame): attrs["type"] = infer_vegalite_type(data[unescaped_field]) else: diff --git a/altair/vegalite/v5/api.py b/altair/vegalite/v5/api.py index 6b3fafcb1..a9e33e9e1 100644 --- a/altair/vegalite/v5/api.py +++ b/altair/vegalite/v5/api.py @@ -3009,7 +3009,7 @@ class RepeatChart(TopLevelMixin, core.TopLevelRepeatSpec): # Because TopLevelRepeatSpec is defined as a union as of Vega-Lite schema 4.9, # we set the arguments explicitly here. - # TODO: Should we instead use tools/schemapi/codegen._get_args? + # TODO: Should we instead use tools/schemapi/codegen.get_args? @utils.use_signature(core.TopLevelRepeatSpec) def __init__( self, diff --git a/altair/vegalite/v5/schema/__init__.py b/altair/vegalite/v5/schema/__init__.py index f8aa25ee5..92072a1f1 100644 --- a/altair/vegalite/v5/schema/__init__.py +++ b/altair/vegalite/v5/schema/__init__.py @@ -1,5 +1,5 @@ # ruff: noqa from .core import * from .channels import * -SCHEMA_VERSION = 'v5.14.1' -SCHEMA_URL = 'https://vega.github.io/schema/vega-lite/v5.14.1.json' +SCHEMA_VERSION = 'v5.15.1' +SCHEMA_URL = 'https://vega.github.io/schema/vega-lite/v5.15.1.json' diff --git a/altair/vegalite/v5/schema/core.py b/altair/vegalite/v5/schema/core.py index ab60f275a..3d12e0858 100644 --- a/altair/vegalite/v5/schema/core.py +++ b/altair/vegalite/v5/schema/core.py @@ -7054,6 +7054,13 @@ class IntervalSelectionConfig(VegaLiteSchema): An array of encoding channels. The corresponding data field values must match for a data tuple to fall within the selection. + **See also:** The `projection with encodings and fields section + `__ in the + documentation. + fields : List(:class:`FieldName`) + An array of field names whose values must match for a data tuple to fall within the + selection. + **See also:** The `projection with encodings and fields section `__ in the documentation. @@ -7121,10 +7128,11 @@ class IntervalSelectionConfig(VegaLiteSchema): """ _schema = {'$ref': '#/definitions/IntervalSelectionConfig'} - def __init__(self, type=Undefined, clear=Undefined, encodings=Undefined, mark=Undefined, - on=Undefined, resolve=Undefined, translate=Undefined, zoom=Undefined, **kwds): + def __init__(self, type=Undefined, clear=Undefined, encodings=Undefined, fields=Undefined, + mark=Undefined, on=Undefined, resolve=Undefined, translate=Undefined, zoom=Undefined, + **kwds): super(IntervalSelectionConfig, self).__init__(type=type, clear=clear, encodings=encodings, - mark=mark, on=on, resolve=resolve, + fields=fields, mark=mark, on=on, resolve=resolve, translate=translate, zoom=zoom, **kwds) @@ -7150,6 +7158,13 @@ class IntervalSelectionConfigWithoutType(VegaLiteSchema): An array of encoding channels. The corresponding data field values must match for a data tuple to fall within the selection. + **See also:** The `projection with encodings and fields section + `__ in the + documentation. + fields : List(:class:`FieldName`) + An array of field names whose values must match for a data tuple to fall within the + selection. + **See also:** The `projection with encodings and fields section `__ in the documentation. @@ -7217,11 +7232,12 @@ class IntervalSelectionConfigWithoutType(VegaLiteSchema): """ _schema = {'$ref': '#/definitions/IntervalSelectionConfigWithoutType'} - def __init__(self, clear=Undefined, encodings=Undefined, mark=Undefined, on=Undefined, - resolve=Undefined, translate=Undefined, zoom=Undefined, **kwds): + def __init__(self, clear=Undefined, encodings=Undefined, fields=Undefined, mark=Undefined, + on=Undefined, resolve=Undefined, translate=Undefined, zoom=Undefined, **kwds): super(IntervalSelectionConfigWithoutType, self).__init__(clear=clear, encodings=encodings, - mark=mark, on=on, resolve=resolve, - translate=translate, zoom=zoom, **kwds) + fields=fields, mark=mark, on=on, + resolve=resolve, translate=translate, + zoom=zoom, **kwds) class JoinAggregateFieldDef(VegaLiteSchema): @@ -21862,7 +21878,8 @@ class EventType(WindowEventType): enum('click', 'dblclick', 'dragenter', 'dragleave', 'dragover', 'keydown', 'keypress', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'mousewheel', - 'timer', 'touchend', 'touchmove', 'touchstart', 'wheel') + 'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'timer', 'touchend', + 'touchmove', 'touchstart', 'wheel') """ _schema = {'$ref': '#/definitions/EventType'} diff --git a/altair/vegalite/v5/schema/vega-lite-schema.json b/altair/vegalite/v5/schema/vega-lite-schema.json index eabaeaac4..8532a959e 100644 --- a/altair/vegalite/v5/schema/vega-lite-schema.json +++ b/altair/vegalite/v5/schema/vega-lite-schema.json @@ -8791,6 +8791,11 @@ "mouseover", "mouseup", "mousewheel", + "pointerdown", + "pointermove", + "pointerout", + "pointerover", + "pointerup", "timer", "touchend", "touchmove", @@ -12467,6 +12472,13 @@ }, "type": "array" }, + "fields": { + "description": "An array of field names whose values must match for a data tuple to fall within the selection.\n\n__See also:__ The [projection with `encodings` and `fields` section](https://vega.github.io/vega-lite/docs/selection.html#project) in the documentation.", + "items": { + "$ref": "#/definitions/FieldName" + }, + "type": "array" + }, "mark": { "$ref": "#/definitions/BrushConfig", "description": "An interval selection also adds a rectangle mark to depict the extents of the interval. The `mark` property can be used to customize the appearance of the mark.\n\n__See also:__ [`mark` examples](https://vega.github.io/vega-lite/docs/selection.html#mark) in the documentation." @@ -12535,6 +12547,13 @@ }, "type": "array" }, + "fields": { + "description": "An array of field names whose values must match for a data tuple to fall within the selection.\n\n__See also:__ The [projection with `encodings` and `fields` section](https://vega.github.io/vega-lite/docs/selection.html#project) in the documentation.", + "items": { + "$ref": "#/definitions/FieldName" + }, + "type": "array" + }, "mark": { "$ref": "#/definitions/BrushConfig", "description": "An interval selection also adds a rectangle mark to depict the extents of the interval. The `mark` property can be used to customize the appearance of the mark.\n\n__See also:__ [`mark` examples](https://vega.github.io/vega-lite/docs/selection.html#mark) in the documentation." diff --git a/doc/_static/custom.css b/doc/_static/custom.css index f5c32b728..d6546af64 100644 --- a/doc/_static/custom.css +++ b/doc/_static/custom.css @@ -70,11 +70,23 @@ properly displayed on mobile devices and not restricted to 20% */ vertical-align: text-bottom; margin-right: 3px; } - + .full-width-plot { width: 100%; } +/* This hides the Ctrl + K from the search box on the start page + * to make it less distracting on the home page. + * The shortcut still shows up when clicking the search box */ +.search-button-field > .search-button__kbd-shortcut { + display: none; +} + +/* Use old light blue color for banner since it goes better with the Altair logo */ +.bd-header-announcement { + background-color: #daebf1 !important; +} + /* Configurations for the start page ------------------------------------ */ .lead { @@ -92,4 +104,4 @@ properly displayed on mobile devices and not restricted to 20% */ /* Default is bolder which is less */ font-weight: bold; } -/* ---------------------------------- */ \ No newline at end of file +/* ---------------------------------- */ diff --git a/doc/about/citing.rst b/doc/about/citing.rst new file mode 100644 index 000000000..2e1e2fd7a --- /dev/null +++ b/doc/about/citing.rst @@ -0,0 +1,41 @@ +Citing +=============== + +Vega-Altair +----------- +If you use Vega-Altair in academic work, please consider citing +`Altair: Interactive Statistical Visualizations for Python `_ as + +.. code-block:: + + @article{VanderPlas2018, + doi = {10.21105/joss.01057}, + url = {https://doi.org/10.21105/joss.01057}, + year = {2018}, + publisher = {The Open Journal}, + volume = {3}, + number = {32}, + pages = {1057}, + author = {Jacob VanderPlas and Brian Granger and Jeffrey Heer and Dominik Moritz and Kanit Wongsuphasawat and Arvind Satyanarayan and Eitan Lees and Ilia Timofeev and Ben Welsh and Scott Sievert}, + title = {Altair: Interactive Statistical Visualizations for Python}, + journal = {Journal of Open Source Software} + } + +Vega-Lite +--------- +Please additionally consider citing the +`Vega-Lite `_ project, which Vega-Altair is based on: +`Vega-Lite: A Grammar of Interactive Graphics `_ + +.. code-block:: + + @article{Satyanarayan2017, + author={Satyanarayan, Arvind and Moritz, Dominik and Wongsuphasawat, Kanit and Heer, Jeffrey}, + title={Vega-Lite: A Grammar of Interactive Graphics}, + journal={IEEE transactions on visualization and computer graphics}, + year={2017}, + volume={23}, + number={1}, + pages={341-350}, + publisher={IEEE} + } \ No newline at end of file diff --git a/doc/about/roadmap.rst b/doc/about/roadmap.rst index 07f8fe576..9dd20ca34 100644 --- a/doc/about/roadmap.rst +++ b/doc/about/roadmap.rst @@ -162,3 +162,4 @@ Areas of focus: self code_of_conduct governance + citing diff --git a/doc/case_studies/exploring-weather.rst b/doc/case_studies/exploring-weather.rst index b041ba212..bc9776787 100644 --- a/doc/case_studies/exploring-weather.rst +++ b/doc/case_studies/exploring-weather.rst @@ -17,7 +17,7 @@ The dataset is a CSV file with columns for the temperature wind speed (in meter/second), and weather type. We have one row for each day from January 1st, 2012 to December 31st, 2015. -Altair is designed to work with data in the form of Pandas_ +Altair is designed to work with data in the form of pandas_ dataframes, and contains a loader for this and other built-in datasets: .. altair-plot:: @@ -28,7 +28,7 @@ dataframes, and contains a loader for this and other built-in datasets: df = data.seattle_weather() df.head() -The data is loaded from the web and stored in a Pandas DataFrame, and from +The data is loaded from the web and stored in a pandas DataFrame, and from here we can explore it with Altair. Let’s start by looking at the precipitation, using tick marks to see the @@ -135,7 +135,7 @@ Note that this calculation doesn't actually do any data manipulation in Python, but rather encodes and stores the operations within the plot specification, where they will be calculated by the renderer. -Of course, the same calculation could be done by using Pandas manipulations to +Of course, the same calculation could be done by using pandas manipulations to explicitly add a column to the dataframe; the disadvantage there is that the derived values would have to be stored in the plot specification rather than computed on-demand in the browser. @@ -265,4 +265,4 @@ You can find more visualizations in the :ref:`example-gallery`. If you want to further customize your charts, you can refer to Altair's :ref:`api`. -.. _Pandas: http://pandas.pydata.org/ +.. _pandas: http://pandas.pydata.org/ diff --git a/doc/getting_started/project_philosophy.rst b/doc/getting_started/project_philosophy.rst index 7681df893..1457c9cf1 100644 --- a/doc/getting_started/project_philosophy.rst +++ b/doc/getting_started/project_philosophy.rst @@ -8,7 +8,7 @@ Many excellent plotting libraries exist in Python, including: * `Seaborn `_ * `Lightning `_ * `Plotly `_ -* `Pandas built-in plotting `_ +* `pandas built-in plotting `_ * `HoloViews `_ * `VisPy `_ * `pygg `_ diff --git a/doc/getting_started/starting.rst b/doc/getting_started/starting.rst index 61fc80147..a9b4ba470 100644 --- a/doc/getting_started/starting.rst +++ b/doc/getting_started/starting.rst @@ -29,10 +29,10 @@ Here is the outline of this basic tutorial: The Data -------- -Data in Altair is built around the Pandas Dataframe. One of the defining +Data in Altair is built around the pandas Dataframe. One of the defining characteristics of statistical visualization is that it begins with `tidy `_ -Dataframes. For the purposes of this tutorial, we'll start by importing Pandas +Dataframes. For the purposes of this tutorial, we'll start by importing pandas and creating a simple DataFrame to visualize, with a categorical variable in column a and a numerical variable in column b: diff --git a/doc/releases/changes.rst b/doc/releases/changes.rst index ee81bd68a..db4ad5c82 100644 --- a/doc/releases/changes.rst +++ b/doc/releases/changes.rst @@ -15,6 +15,16 @@ Bug Fixes Backward-Incompatible Changes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Version 5.1.2 (released Oct 3, 2023) +---------------------------------------- + +- Update Vega-Lite from version 5.14.1 to version 5.15.1; + see `Vega-Lite 5.15.1 Release Notes `_. + +Bug Fixes +~~~~~~~~~ +- Remove usage of deprecated pandas parameter ``convert_dtypes`` (#3191) +- Fix encoding type inference for boolean columns when pyarrow is installed (#3210) Version 5.1.1 (released August 30, 2023) ---------------------------------------- @@ -27,7 +37,7 @@ Version 5.1.0 (released August 28, 2023) ---------------------------------------- - Update Vega-Lite from version 5.8.0 to version 5.14.1; - see `Vega-Lite Release Notes `_. + see `Vega-Lite 5.14.1 Release Notes `_. Enhancements ~~~~~~~~~~~~ @@ -35,7 +45,7 @@ Enhancements - Add ``"vegafusion"`` data transformer with mime renderer, save, and ``to_dict``/``to_json`` integration (#3094, #3107) - Add ``JupyterChart`` to support accessing params and selections from Python (See :ref:`user-guide-jupyterchart`) (#3151) - Support field encoding inference for objects that support the DataFrame Interchange Protocol (#3114) -- Support grouped bars inside time axis with time bins (see `Vega-Lite Release Notes `_) +- Support grouped bars inside time axis with time bins (see `Vega-Lite 5.9.0 Release Notes `_) - Add new transform method `transform_extent` (#3148) - Add support for new referencing logic in version 4.18 of the jsonschema package - Add configurable pixels-per-inch (ppi) metadata to saved and displayed PNG images (#3163) @@ -61,7 +71,7 @@ Version 5.0.0 (released May 9, 2023) ------------------------------------ - Update Vega-Lite from version 4.17.0 to version 5.8.0; - see `Vega-Lite Release Notes `_. + see `Vega-Lite 5.8.0 Release Notes `_. Enhancements ~~~~~~~~~~~~ @@ -140,7 +150,7 @@ Version 4.2.0 (released Dec 29, 2021) ------------------------------------- - Update Vega-Lite from version 4.8.1 to version 4.17.0; - see `Vega-Lite Release Notes `_. + see `Vega-Lite 4.17.0 Release Notes `_. Enhancements ~~~~~~~~~~~~ @@ -167,7 +177,7 @@ Version 4.1.0 (released April 1, 2020) - Minimum Python version is now 3.6 - Update Vega-Lite to version 4.8.1; many new features and bug fixes from Vega-Lite - versions 4.1 through 4.8; see `Vega-Lite Release Notes `_. + versions 4.1 through 4.8; see `Vega-Lite 4.8.0 Release Notes `_. Enhancements ~~~~~~~~~~~~ diff --git a/doc/user_guide/customization.rst b/doc/user_guide/customization.rst index be44055b1..04058d0a5 100644 --- a/doc/user_guide/customization.rst +++ b/doc/user_guide/customization.rst @@ -138,7 +138,7 @@ By default an Altair chart does not have a title, as seen in this example. color="source:N" ) -You can add a simple title by passing the `title` keyword argument with the data. +You can add a simple title by passing the ``title`` keyword argument with the data. .. altair-plot:: @@ -148,7 +148,7 @@ You can add a simple title by passing the `title` keyword argument with the data color="source:N" ) -It is also possible to add a subtitle by passing in an `alt.Title` object. +It is also possible to add a subtitle by passing in an ``alt.Title`` object. .. altair-plot:: @@ -204,7 +204,7 @@ Adjusting Axis Limits --------------------- The default axis limit used by Altair is dependent on the type of the data. To fine-tune the axis limits beyond these defaults, you can use the -:class:`Scale` property of the axis encodings. For example, consider the +:meth:`scale` method of the axis encodings. For example, consider the following plot: .. altair-plot:: @@ -220,8 +220,8 @@ following plot: ) Altair inherits from Vega-Lite the convention of always including the zero-point -in quantitative axes; if you would like to turn this off, you can add a -:class:`Scale` property to the :class:`X` encoding that specifies ``zero=False``: +in quantitative axes; if you would like to turn this off, you can add the +:meth:`scale` method to the :class:`X` encoding that specifies ``zero=False``: .. altair-plot:: @@ -273,8 +273,10 @@ For example consider this plot: .. altair-plot:: import pandas as pd - df = pd.DataFrame({'x': [0.03, 0.04, 0.05, 0.12, 0.07, 0.15], - 'y': [10, 35, 39, 50, 24, 35]}) + df = pd.DataFrame( + {'x': [0.03, 0.04, 0.05, 0.12, 0.07, 0.15], + 'y': [10, 35, 39, 50, 24, 35] + }) alt.Chart(df).mark_circle().encode( x='x', @@ -283,7 +285,7 @@ For example consider this plot: To fine-tune the formatting of the tick labels and to add a custom title to each axis, we can pass to the :class:`X` and :class:`Y` encoding a custom -:class:`Axis` definition. +axis definition within the :meth:`axis` method. Here is an example of formatting the x labels as a percentage, and the y labels as a dollar value: @@ -294,7 +296,7 @@ the y labels as a dollar value: alt.Y('y').axis(format='$').title('dollar amount') ) -Axis labels can also be easily removed: +Axis labels can be easily removed: .. altair-plot:: @@ -303,6 +305,21 @@ Axis labels can also be easily removed: alt.Y('y').axis(labels=False) ) +Axis title can also be rotated: + +.. altair-plot:: + + alt.Chart(df).mark_circle().encode( + alt.X('x').axis(title="x"), + alt.Y('y').axis( + title="Y Axis Title", + titleAngle=0, + titleAlign="left", + titleY=-2, + titleX=0, + ) + ) + Additional formatting codes are available; for a listing of these see the `d3 Format Code Documentation `_. @@ -310,7 +327,7 @@ Additional formatting codes are available; for a listing of these see the Adjusting the Legend -------------------- -A legend is added to the chart automatically when the `color`, `shape` or `size` arguments are passed to the :func:`encode` function. In this example we'll use `color`. +A legend is added to the chart automatically when the ``color``, ``shape`` or ``size`` arguments are passed to the :func:`encode` function. In this example we'll use ``color``. .. altair-plot:: @@ -325,9 +342,9 @@ A legend is added to the chart automatically when the `color`, `shape` or `size` color='species' ) -In this case, the legend can be customized by introducing the :class:`Color` class and taking advantage of its `legend` argument. The `shape` and `size` arguments have their own corresponding classes. +In this case, the legend can be customized by introducing the :class:`Color` class and taking advantage of its :meth:`legend` method. The ``shape`` and ``size`` arguments have their own corresponding classes. -The legend option on all of them expects a :class:`Legend` object as its input, which accepts arguments to customize many aspects of its appearance. One simple example is giving the legend a `title`. +The legend option on all of them expects a :class:`Legend` object as its input, which accepts arguments to customize many aspects of its appearance. One example is to move the legend to another position with the ``orient`` argument. .. altair-plot:: @@ -339,10 +356,10 @@ The legend option on all of them expects a :class:`Legend` object as its input, alt.Chart(iris).mark_point().encode( x='petalWidth', y='petalLength', - color=alt.Color('species').title("Species by color") + color=alt.Color('species').legend(orient="left") ) -Another thing you can do is move the legend to another position with the `orient` argument. +Another thing you can do is set a ``title``; in this case we can use the :meth:`title` method directly as a shortcut or specify the ``title`` parameter inside the :meth:`legend` method:. .. altair-plot:: @@ -354,9 +371,10 @@ Another thing you can do is move the legend to another position with the `orient alt.Chart(iris).mark_point().encode( x='petalWidth', y='petalLength', - color=alt.Color('species').legend(orient="left") + color=alt.Color('species').title("Species by color") ) + You can remove the legend entirely by submitting a null value. .. altair-plot:: @@ -392,7 +410,7 @@ As an example, let's start with a simple scatter plot. color='species' ) -First remove the grid using the :meth:`Chart.configure_axis` method. +First remove the grid using the :meth:`configure_axis` method. .. altair-plot:: @@ -410,7 +428,7 @@ First remove the grid using the :meth:`Chart.configure_axis` method. ) You'll note that while the inside rules are gone, the outside border remains. -Hide it by setting ``stroke=None`` inside :meth:`Chart.configure_view` +Hide it by setting ``stroke=None`` inside :meth:`configure_view` (``strokeWidth=0`` and ``strokeOpacity=0`` also works): .. altair-plot:: @@ -457,59 +475,83 @@ Customizing Colors As discussed in :ref:`type-legend-scale`, Altair chooses a suitable default color scheme based on the type of the data that the color encodes. These defaults can -be customized using the `scale` argument of the :class:`Color` class. - -The :class:`Scale` class passed to the `scale` argument provides a number of options -for customizing the color scale; we will discuss a few of them here. +be customized using the :meth:`scale` method of the :class:`Color` class. Color Schemes ~~~~~~~~~~~~~ + Altair includes a set of named color schemes for both categorical and sequential data, defined by the vega project; see the `Vega documentation `_ for a full gallery of available color schemes. These schemes -can be passed to the `scheme` argument of the :class:`Scale` class: +can be passed to the `scheme` argument of the :meth:`scale` method: .. altair-plot:: - import altair as alt - from vega_datasets import data + import altair as alt + from vega_datasets import data - iris = data.iris() + cars = data.cars() - alt.Chart(iris).mark_point().encode( - x='petalWidth', - y='petalLength', - color=alt.Color('species').scale(scheme='dark2') - ) + alt.Chart(cars).mark_point().encode( + x='Horsepower', + y='Miles_per_Gallon', + color=alt.Color('Acceleration').scale(scheme="lightgreyred") + ) + +The color scheme we used above highlights points on one end of the scale, +while keeping the rest muted. +If we want to highlight the lower ``Acceleration`` data to red color instead, +we can use the ``reverse`` parameter to reverse the color scheme: + +.. altair-plot:: + + alt.Chart(cars).mark_point().encode( + x='Horsepower', + y='Miles_per_Gallon', + color=alt.Color('Acceleration').scale(scheme="lightgreyred", reverse=True) + ) Color Domain and Range ~~~~~~~~~~~~~~~~~~~~~~ -To make a custom mapping of discrete values to colors, use the -`domain` and `range` parameters of the :class:`Scale` class for -values and colors respectively. +To create a custom color scales, +we can use the ``domain`` and ``range`` parameters +of the ``scale`` method for +the values and colors, respectively. +This works both for continuous scales, +where it can help highlight specific ranges of values: .. altair-plot:: - import altair as alt - from vega_datasets import data + domain = [5, 8, 10, 12, 25] + range_ = ['#9cc8e2', '#9cc8e2', 'red', '#5ba3cf', '#125ca4'] - iris = data.iris() - domain = ['setosa', 'versicolor', 'virginica'] - range_ = ['red', 'green', 'blue'] + alt.Chart(cars).mark_point().encode( + x='Horsepower', + y='Miles_per_Gallon', + color=alt.Color('Acceleration').scale(domain=domain, range=range_) + ) - alt.Chart(iris).mark_point().encode( - x='petalWidth', - y='petalLength', - color=alt.Color('species').scale(domain=domain, range=range_) - ) +And for discrete scales: + +.. altair-plot:: + + domain = ['Europe', "Japan", "USA"] + range_ = ['seagreen', 'firebrick', 'rebeccapurple'] + + alt.Chart(cars).mark_point().encode( + x='Horsepower', + y='Miles_per_Gallon', + color=alt.Color('Origin').scale(domain=domain, range=range_) + ) Raw Color Values ~~~~~~~~~~~~~~~~ + The ``scale`` is what maps the raw input values into an appropriate color encoding for displaying the data. If your data entries consist of raw color names or codes, -you can set ``scale=None`` to use those colors directly: +you can set ``scale(None)`` to use those colors directly: .. altair-plot:: @@ -531,6 +573,7 @@ you can set ``scale=None`` to use those colors directly: Adjusting the Width of Bar Marks -------------------------------- + The width of the bars in a bar plot are controlled through the ``size`` property in the :meth:`~Chart.mark_bar()`: .. altair-plot:: @@ -578,7 +621,7 @@ Here is an example of setting the step width for a discrete scale: y='value:Q' ).properties(width=alt.Step(100)) -The width of the bars are set using ``mark_bar(size=30)`` and the width that is allocated for each bar bar in the the chart is set using ``width=alt.Step(100)`` +The width of the bars are set using ``mark_bar(size=30)`` and the width that is allocated for each bar bar in the chart is set using ``width=alt.Step(100)`` .. _customization-chart-size: @@ -618,7 +661,7 @@ the subchart rather than to the overall chart: ) If you want your chart size to respond to the width of the HTML page or container in which -it is rendererd, you can set ``width`` or ``height`` to the string ``"container"``: +it is rendered, you can set ``width`` or ``height`` to the string ``"container"``: .. altair-plot:: :div_class_: full-width-plot @@ -633,7 +676,7 @@ it is rendererd, you can set ``width`` or ``height`` to the string ``"container" Note that this will only scale with the container if its parent element has a size determined outside the chart itself; For example, the container may be a ``
`` element that has style -``width: 100%; height: 300px``. +``width: 100%; height: 300px``. .. _chart-themes: diff --git a/doc/user_guide/data.rst b/doc/user_guide/data.rst index 6958f3f62..48a09db29 100644 --- a/doc/user_guide/data.rst +++ b/doc/user_guide/data.rst @@ -15,7 +15,7 @@ and :class:`FacetChart`) accepts a dataset as its first argument. There are many different ways of specifying a dataset: -- as a `Pandas DataFrame `_ +- as a `pandas DataFrame `_ - as a DataFrame that supports the DataFrame Interchange Protocol (contains a ``__dataframe__`` attribute), e.g. polars and pyarrow. This is experimental. - as a :class:`Data` or related object (i.e. :class:`UrlData`, :class:`InlineData`, :class:`NamedData`) - as a url string pointing to a ``json`` or ``csv`` formatted text file @@ -81,7 +81,7 @@ Similarly, we must also specify the data type when referencing data by URL: Encodings and their associated types are further discussed in :ref:`user-guide-encoding`. Below we go into more detail about the different ways of specifying data in an Altair chart. -Pandas DataFrame +pandas DataFrame ~~~~~~~~~~~~~~~~ .. _data-in-index: @@ -102,7 +102,7 @@ At times, relevant data appears in the index. For example: data.head() If you would like the index to be available to the chart, you can explicitly -turn it into a column using the ``reset_index()`` method of Pandas dataframes: +turn it into a column using the ``reset_index()`` method of pandas dataframes: .. altair-plot:: @@ -114,7 +114,7 @@ turn it into a column using the ``reset_index()`` method of Pandas dataframes: If the index object does not have a ``name`` attribute set, the resulting column will be called ``"index"``. More information is available in the -`Pandas documentation `_. +`pandas documentation `_. .. _data-long-vs-wide: @@ -193,11 +193,11 @@ step within the chart itself. We will detail to two approaches below. .. _data-converting-long-form: -Converting with Pandas +Converting with pandas """""""""""""""""""""" -This sort of data manipulation can be done as a preprocessing step using Pandas_, +This sort of data manipulation can be done as a preprocessing step using pandas_, and is discussed in detail in the `Reshaping and Pivot Tables`_ section of the -Pandas documentation. +pandas documentation. For converting wide-form data to the long-form data used by Altair, the ``melt`` method of dataframes can be used. The first argument to ``melt`` is the column @@ -210,7 +210,7 @@ be optionally specified: wide_form.melt('Date', var_name='company', value_name='price') -For more information on the ``melt`` method, see the `Pandas melt documentation`_. +For more information on the ``melt`` method, see the `pandas melt documentation`_. In case you would like to undo this operation and convert from long-form back to wide-form, the ``pivot`` method of dataframes is useful. @@ -220,7 +220,7 @@ to wide-form, the ``pivot`` method of dataframes is useful. long_form.pivot(index='Date', columns='company', values='price').reset_index() -For more information on the ``pivot`` method, see the `Pandas pivot documentation`_. +For more information on the ``pivot`` method, see the `pandas pivot documentation`_. Converting with Fold Transform """""""""""""""""""""""""""""" @@ -307,9 +307,9 @@ created using Altair's :func:`sphere` generator function. Here is an example: alt.layer(background, lines).project('naturalEarth1') -.. _Pandas: http://pandas.pydata.org/ -.. _Pandas pivot documentation: https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.pivot.html -.. _Pandas melt documentation: https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.melt.html#pandas.DataFrame.melt +.. _pandas: http://pandas.pydata.org/ +.. _pandas pivot documentation: https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.pivot.html +.. _pandas melt documentation: https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.melt.html#pandas.DataFrame.melt .. _Reshaping and Pivot Tables: https://pandas.pydata.org/pandas-docs/stable/reshaping.html diff --git a/doc/user_guide/data_transformers.rst b/doc/user_guide/data_transformers.rst index 44a569974..a4820cf5e 100644 --- a/doc/user_guide/data_transformers.rst +++ b/doc/user_guide/data_transformers.rst @@ -6,7 +6,7 @@ Data Transformers Before a Vega-Lite or Vega specification can be passed to a renderer, it typically has to be transformed in a number of ways: -* Pandas Dataframe has to be sanitized and serialized to JSON. +* pandas Dataframe has to be sanitized and serialized to JSON. * The rows of a Dataframe might need to be sampled or limited to a maximum number. * The Dataframe might be written to a ``.csv`` of ``.json`` file for performance reasons. @@ -19,7 +19,7 @@ These data transformations are managed by the data transformation API of Altair. API of Vega and Vega-Lite. A data transformer is a Python function that takes a Vega-Lite data ``dict`` or -Pandas ``DataFrame`` and returns a transformed version of either of these types:: +pandas ``DataFrame`` and returns a transformed version of either of these types:: from typing import Union Data = Union[dict, pd.DataFrame] @@ -30,7 +30,7 @@ Pandas ``DataFrame`` and returns a transformed version of either of these types: Dataset Consolidation ~~~~~~~~~~~~~~~~~~~~~ -Datasets passed as Pandas dataframes can be represented in the chart in two +Datasets passed as pandas dataframes can be represented in the chart in two ways: - As literal dataset values in the ``data`` attribute at any level of the diff --git a/doc/user_guide/encodings/index.rst b/doc/user_guide/encodings/index.rst index 4de8616fc..62bcc51a8 100644 --- a/doc/user_guide/encodings/index.rst +++ b/doc/user_guide/encodings/index.rst @@ -170,7 +170,7 @@ Effect of Data Type on Color Scales ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ As an example of this, here we will represent the same data three different ways, with the color encoded as a *quantitative*, *ordinal*, and *nominal* type, -using three vertically-concatenated charts (see :ref:`vconcat-chart`): +using three horizontally-concatenated charts (see :ref:`hconcat-chart`): .. altair-plot:: @@ -178,11 +178,11 @@ using three vertically-concatenated charts (see :ref:`vconcat-chart`): x='Horsepower:Q', y='Miles_per_Gallon:Q', ).properties( - width=150, - height=150 + width=140, + height=140 ) - alt.vconcat( + alt.hconcat( base.encode(color='Cylinders:Q').properties(title='quantitative'), base.encode(color='Cylinders:O').properties(title='ordinal'), base.encode(color='Cylinders:N').properties(title='nominal'), @@ -198,35 +198,49 @@ Effect of Data Type on Axis Scales ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Similarly, for x and y axis encodings, the type used for the data will affect the scales used and the characteristics of the mark. For example, here is the -difference between a ``quantitative`` and ``ordinal`` scale for an column +difference between a ``ordinal``, ``quantitative``, and ``temporal`` scale for an column that contains integers specifying a year: .. altair-plot:: - pop = data.population.url + pop = data.population() base = alt.Chart(pop).mark_bar().encode( - alt.Y('mean(people):Q').title('total population') + alt.Y('mean(people):Q').title('Total population') ).properties( - width=200, - height=200 + width=140, + height=140 ) alt.hconcat( - base.encode(x='year:Q').properties(title='year=quantitative'), - base.encode(x='year:O').properties(title='year=ordinal') + base.encode(x='year:O').properties(title='ordinal'), + base.encode(x='year:Q').properties(title='quantitative'), + base.encode(x='year:T').properties(title='temporal') ) -Because quantitative values do not have an inherent width, the bars do not +Because values on quantitative and temporal scales do not have an inherent width, the bars do not fill the entire space between the values. -This view also makes clear the missing year of data that was not immediately -apparent when we treated the years as categories. +These scales clearly show the missing year of data that was not immediately +apparent when we treated the years as ordinal data, +but the axis formatting is undesirable in both cases. + +To plot four digit integers as years with proper axis formatting, +i.e. without thousands separator, +we recommend converting the integers to strings first, +and the specifying a temporal data type in Altair. +While it is also possible to change the axis format with ``.axis(format='i')``, +it is preferred to specify the appropriate data type to Altair. + +.. altair-plot:: + + pop['year'] = pop['year'].astype(str) + + base.mark_bar().encode(x='year:T').properties(title='temporal') This kind of behavior is sometimes surprising to new users, but it emphasizes the importance of thinking carefully about your data types when visualizing data: a visual encoding that is suitable for categorical data may not be -suitable for quantitative data, and vice versa. - +suitable for quantitative data or temporal data, and vice versa. .. _shorthand-description: @@ -265,7 +279,7 @@ in some data structures. The recommended thing to do when you have special characters in a column name is to rename your columns. -For example, in Pandas you could replace ``:`` with ``_`` +For example, in pandas you could replace ``:`` with ``_`` via ``df.rename(columns = lambda x: x.replace(':', '_'))``. If you don't want to rename your columns you will need to escape the special characters using a backslash: @@ -408,8 +422,7 @@ options available to change the sort order: sort. For example ``sort='-x'`` would sort by the x channel in descending order. - Passing a list to ``sort`` allows you to explicitly set the order in which you would like the encoding to appear -- Passing a :class:`EncodingSortField` class to ``sort`` allows you to sort - an axis by the value of some other field in the dataset. +- Using the ``field`` and ``op`` parameters to specify a field and aggregation operation to sort by. Here is an example of applying these five different sort approaches on the x-axis, using the barley dataset: @@ -475,7 +488,9 @@ x-axis, using the barley dataset: The last two charts are the same because the default aggregation (see :ref:`encoding-aggregates`) is ``mean``. To highlight the difference between sorting via channel and sorting via field consider the -following example where we don't aggregate the data: +following example where we don't aggregate the data +and use the `op` parameter to specify a different aggregation than `mean` +to use when sorting: .. altair-plot:: @@ -498,34 +513,45 @@ following example where we don't aggregate the data: sortfield = base.encode( alt.X('site:N').sort(field='yield', op='max') ).properties( - title='By Min Yield' + title='By Max Yield' ) sortchannel | sortfield -By passing a :class:`EncodingSortField` class to ``sort`` we have more control over -the sorting process. - Sorting Legends ^^^^^^^^^^^^^^^ -While the above examples show sorting of axes by specifying ``sort`` in the +Just as how the above examples show sorting of axes by specifying ``sort`` in the :class:`X` and :class:`Y` encodings, legends can be sorted by specifying -``sort`` in the :class:`Color` encoding: +``sort`` in the encoding used in the legend (e.g. color, shape, size, etc). +Below we show an example using the :class:`Color` encoding: .. altair-plot:: - alt.Chart(barley).mark_rect().encode( - alt.X('mean(yield):Q').sort('ascending'), - alt.Y('site:N').sort('descending'), + alt.Chart(barley).mark_bar().encode( + alt.X('mean(yield):Q'), + alt.Y('site:N').sort('x'), alt.Color('site:N').sort([ 'Morris', 'Duluth', 'Grand Rapids', 'University Farm', 'Waseca', 'Crookston' ]) ) -Here the y-axis is sorted reverse-alphabetically, while the color legend is +Here the y-axis is sorted based on the x-values, while the color legend is sorted in the specified order, beginning with ``'Morris'``. +In the next example, +specifying ``field``, ``op`` and ``order``, +sorts the legend sorted based on a chosen data field +and operation. + +.. altair-plot:: + + alt.Chart(barley).mark_bar().encode( + alt.X('mean(yield):Q'), + alt.Y('site:N').sort('x'), + color=alt.Color('site').sort(field='yield', op='max', order='ascending') + ) + Datum and Value ~~~~~~~~~~~~~~~ diff --git a/doc/user_guide/internals.rst b/doc/user_guide/internals.rst index 831c29cc3..c5773dad2 100644 --- a/doc/user_guide/internals.rst +++ b/doc/user_guide/internals.rst @@ -195,7 +195,7 @@ you can use the :meth:`~Chart.from_dict` method to construct the chart object: With a bit more effort and some judicious copying and pasting, we can manually convert this into more idiomatic Altair code for the same chart, -including constructing a Pandas dataframe from the data values: +including constructing a pandas dataframe from the data values: .. altair-plot:: diff --git a/doc/user_guide/large_datasets.rst b/doc/user_guide/large_datasets.rst index 2f21934ae..18376da41 100644 --- a/doc/user_guide/large_datasets.rst +++ b/doc/user_guide/large_datasets.rst @@ -290,10 +290,10 @@ whereas `vl-convert`_ is expected to provide the better performance. .. _preaggregate-and-filter: -Preaggregate and Filter in Pandas +Preaggregate and Filter in pandas ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Another common approach is to perform data transformations such as aggregations -and filters using Pandas before passing the data to Altair. +and filters using pandas before passing the data to Altair. For example, to create a bar chart for the ``barley`` dataset summing up ``yield`` grouped by ``site``, it is convenient to pass the unaggregated data to Altair: @@ -322,7 +322,7 @@ only the necessary columns: y=alt.Y("site:N").sort("-x") ) -You could also precalculate the sum in Pandas which would reduce the size of the dataset even more: +You could also precalculate the sum in pandas which would reduce the size of the dataset even more: .. altair-plot:: @@ -357,7 +357,7 @@ in Altair. color=alt.Color("Origin").legend(None) ) -If you have a lot of data, you can perform the necessary calculations in Pandas and only +If you have a lot of data, you can perform the necessary calculations in pandas and only pass the resulting summary statistics to Altair. First, let's define a few parameters where ``k`` stands for the multiplier which is used diff --git a/doc/user_guide/marks/image.rst b/doc/user_guide/marks/image.rst index 673fc1057..2c1e1d9fe 100644 --- a/doc/user_guide/marks/image.rst +++ b/doc/user_guide/marks/image.rst @@ -43,3 +43,150 @@ Scatter Plot with Image Marks ) alt.Chart(source).mark_image(width=50, height=50).encode(x="x", y="y", url="img") + +Show Image Marks with Selection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +This example demonstrates how to display image marks with drag selection. We create two charts: +one with point marks and the other with image marks, applying the selection filter only to the latter. +By combining these two charts, we can achieve the desired result. + +.. altair-plot:: + + import altair as alt + import pandas as pd + + source = pd.DataFrame.from_records( + [{'a': 1, 'b': 1, 'image': 'https://altair-viz.github.io/_static/altair-logo-light.png'}, + {'a': 2, 'b': 2, 'image': 'https://avatars.githubusercontent.com/u/11796929?s=200&v=4'}] + ) + + brush = alt.selection_interval() + point = alt.Chart(source).mark_circle(size=100).encode( + x='a', + y='b', + ).add_params( + brush + ) + + img = alt.Chart(source).mark_image(width=50, height=75).encode( + x='a', + y='b', + url='image' + ).transform_filter( + brush + ) + + point + img + +In the layered chart, images may overlap one other. +An alternative is to use a faceted image chart beside the original chart: + +.. altair-plot:: + + img_faceted = alt.Chart(source, width=50, height=75).mark_image().encode( + url='image' + ).facet( + alt.Facet('image', title='', header=alt.Header(labelFontSize=0)) + ).transform_filter( + brush + ) + + point | img_faceted + +If we want the images to not be visible in the initial chart +we could add ``empty=False`` to the interval selection. +However, +Altair will not automatically resize the chart area to include the faceted chart +when a selection is made, +which means it seems like the selection has no effect. +In order to resize the chart automatically, +we need to explicitly set the ``autosize`` option in the ``configure`` method. + +.. altair-plot:: + + brush = alt.selection_interval(empty=False) + point = alt.Chart(source).mark_circle(size=100).encode( + x='a', + y='b', + ).add_params( + brush + ) + img_faceted = alt.Chart(source, width=50, height=75).mark_image().encode( + url='image' + ).facet( + alt.Facet('image', title='', header=alt.Header(labelFontSize=0)) + ).transform_filter( + brush + ) + + (point | img_faceted).configure( + autosize=alt.AutoSizeParams(resize=True) + ) + + +Use Local Images as Image Marks +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +We could also show local images by first converting them to base64-encoded_ strings. +In the example below, +we load two images saved in the Altair repo; +you can replace the image paths below with the location of the desired images on your machine. +This approach also works with images stored as Numpy Arrays +as can be seen in the tutorial :ref:`Displaying Numpy Images in Tooltips `. + +.. altair-plot:: + + import base64 + import altair as alt + import pandas as pd + + from io import BytesIO + from PIL import Image + + + image_paths = ["doc/_static/gray-square.png","doc/_static/altair-logo-light.png"] + base64_images = [] + + for image_path in image_paths: + pil_image = Image.open(image_path) + output = BytesIO() + pil_image.save(output, format='PNG') + base64_images.append( + "data:image/png;base64," + base64.b64encode(output.getvalue()).decode() + ) + + source = pd.DataFrame({"x": [1, 2], "y": [1, 2], "image": base64_images}) + alt.Chart(source).mark_image( + width=50, + height=50 + ).encode( + x='x', + y='y', + url='image' + ) + +Image Tooltip +^^^^^^^^^^^^^ +This example shows how to render images in tooltips. +Either URLs or local file paths can be used to reference the images. +To render the image, you must use the special column name "image" in your data +and pass it as a list to the tooltip encoding. + +.. altair-plot:: + + import altair as alt + import pandas as pd + + source = pd.DataFrame.from_records( + [{'a': 1, 'b': 1, 'image': 'https://altair-viz.github.io/_static/altair-logo-light.png'}, + {'a': 2, 'b': 2, 'image': 'https://avatars.githubusercontent.com/u/11796929?s=200&v=4'}] + ) + + alt.Chart(source).mark_circle(size=200).encode( + x='a', + y='b', + tooltip=['image'] # Must be a list containing a field called "image" + ) + + +.. _base64-encoded: https://en.wikipedia.org/wiki/Binary-to-text_encoding diff --git a/doc/user_guide/marks/text.rst b/doc/user_guide/marks/text.rst index d7acc400d..4bae2f8b5 100644 --- a/doc/user_guide/marks/text.rst +++ b/doc/user_guide/marks/text.rst @@ -163,9 +163,39 @@ You can also use ``text`` marks as labels for other marks and set offset (``dx`` bar + text +Labels Position Based on Condition +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +By default, text mark as labels in Altair are positioned above or to the right of the value. +However, when dealing with negative values, this default positioning can lead to label overlap with the bar. +To address this issue, you can set label positions via :ref:`expressions`. +Here's an example demonstrating how to do this: + +.. altair-plot:: + import altair as alt + import pandas as pd + + source = pd.DataFrame({ + "a": ["A", "B", "C"], + "b": [28, -5, 10] + }) + + bar = alt.Chart(source).mark_bar().encode( + y="a:N", + x=alt.X("b:Q").scale(domain=[-10, 35]) + ) + + text_conditioned = bar.mark_text( + align="left", + baseline="middle", + dx=alt.expr(alt.expr.if_(alt.datum.b >= 0, 10, -20)) + ).encode(text="b") + + bar + text_conditioned + + Scatter Plot with Text ^^^^^^^^^^^^^^^^^^^^^^ -Mapping a field to ``text`` channel of text mark sets the mark’s text value. For example, we can make a colored scatter plot with text marks showing the initial character of its origin, instead of ``point`` marks. +Mapping a field to ``text`` channel of text mark sets the mark's text value. For example, we can make a colored scatter plot with text marks showing the initial character of its origin, instead of ``point`` marks. .. altair-plot:: import altair as alt diff --git a/doc/user_guide/times_and_dates.rst b/doc/user_guide/times_and_dates.rst index 8f62b061c..c1db1f526 100644 --- a/doc/user_guide/times_and_dates.rst +++ b/doc/user_guide/times_and_dates.rst @@ -13,11 +13,11 @@ Altair and Vega-Lite do their best to ensure that dates are interpreted and visualized in a consistent way. -Altair and Pandas Datetimes +Altair and pandas Datetimes --------------------------- -Altair is designed to work best with `Pandas timeseries`_. A standard -timezone-agnostic date/time column in a Pandas dataframe will be both +Altair is designed to work best with `pandas timeseries`_. A standard +timezone-agnostic date/time column in a pandas dataframe will be both interpreted and displayed as local user time. For example, here is a dataset containing hourly temperatures measured in Seattle: @@ -50,9 +50,12 @@ example, we'll limit ourselves to the first two weeks of data: y='temp:Q' ) -(notice that for date/time values we use the ``T`` to indicate a temporal +Notice that for date/time values we use the ``T`` to indicate a temporal encoding: while this is optional for pandas datetime input, it is good practice -to specify a type explicitly; see :ref:`encoding-data-types` for more discussion). +to specify a type explicitly; see :ref:`encoding-data-types` for more discussion. +If you want Altair to plot four digit integers as years, +you need to cast them as strings before changing the data type to temporal, +please see the :ref:`type-axis-scale` for details. For date-time inputs like these, it can sometimes be useful to extract particular time units (e.g. hours of the day, dates of the month, etc.). @@ -88,7 +91,7 @@ time of the browser that does the rendering. If you would like your dates to instead be time-zone aware, you can set the timezone explicitly in the input dataframe. Since Seattle is in the -``US/Pacific`` timezone, we can localize the timestamps in Pandas as follows: +``US/Pacific`` timezone, we can localize the timestamps in pandas as follows: .. altair-plot:: :output: repr @@ -138,7 +141,7 @@ regardless of the system location: To make your charts as portable as possible (even in non-ES6 browsers which parse timezone-agnostic times as UTC), you can explicitly work -in UTC time, both on the Pandas side and on the Vega-Lite side: +in UTC time, both on the pandas side and on the Vega-Lite side: .. altair-plot:: @@ -152,7 +155,7 @@ in UTC time, both on the Pandas side and on the Vega-Lite side: ) This is somewhat less convenient than the default behavior for timezone-agnostic -dates, in which both Pandas and Vega-Lite assume times are local +dates, in which both pandas and Vega-Lite assume times are local (except in non-ES6 browsers; see :ref:`note-browser-compliance`), but it gets around browser incompatibilities by explicitly working in UTC, which gives similar results even in older browsers. @@ -220,5 +223,5 @@ it is ES6-compliant or because your computer locale happens to be set to the UTC+0 (GMT) timezone. .. _Coordinated Universal Time (UTC): https://en.wikipedia.org/wiki/Coordinated_Universal_Time -.. _Pandas timeseries: https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html +.. _pandas timeseries: https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html .. _ECMAScript 6: http://www.ecma-international.org/ecma-262/6.0/ diff --git a/doc/user_guide/transform/index.rst b/doc/user_guide/transform/index.rst index 8b197ea17..541922e4d 100644 --- a/doc/user_guide/transform/index.rst +++ b/doc/user_guide/transform/index.rst @@ -7,12 +7,12 @@ Data Transformations It is often necessary to transform or filter data in the process of visualizing it. In Altair you can do this one of two ways: -1. Before the chart definition, using standard Pandas data transformations. +1. Before the chart definition, using standard pandas data transformations. 2. Within the chart definition, using Vega-Lite's data transformation tools. In most cases, we suggest that you use the first approach, because it is more straightforward to those who are familiar with data manipulation in Python, and -because the Pandas package offers much more flexibility than Vega-Lite in +because the pandas package offers much more flexibility than Vega-Lite in available data manipulations. The second approach becomes useful when the data source is not a dataframe, but, diff --git a/doc/user_guide/transform/lookup.rst b/doc/user_guide/transform/lookup.rst index 9337da64b..ab7bb550f 100644 --- a/doc/user_guide/transform/lookup.rst +++ b/doc/user_guide/transform/lookup.rst @@ -47,12 +47,12 @@ We know how to visualize each of these datasets separately; for example: If we would like to plot features that reference both datasets (for example, the average age within each group), we need to combine the two datasets. This can be done either as a data preprocessing step, using tools available -in Pandas, or as part of the visualization using a :class:`~LookupTransform` +in pandas, or as part of the visualization using a :class:`~LookupTransform` in Altair. Combining Datasets with pandas.merge ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Pandas provides a wide range of tools for merging and joining datasets; see +pandas provides a wide range of tools for merging and joining datasets; see `Merge, Join, and Concatenate `_ for some detailed examples. For the above data, we can merge the data and create a combined chart as follows: @@ -76,7 +76,7 @@ Combining Datasets with a Lookup Transform ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ For some data sources (e.g. data available at a URL, or data that is streaming), it is desirable to have a means of joining data without having to download -it for pre-processing in Pandas. +it for pre-processing in pandas. This is where Altair's :meth:`~Chart.transform_lookup` comes in. To reproduce the above combined plot by combining datasets within the chart specification itself, we can do the following: diff --git a/pyproject.toml b/pyproject.toml index 4def443b6..87b6c079b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dev = [ "pytest-cov", "m2r", "vega_datasets", - "vl-convert-python>=0.13.0", + "vl-convert-python>=0.14.0", "mypy", "pandas-stubs", "types-jsonschema", @@ -81,7 +81,7 @@ doc = [ "jinja2", "numpydoc", "pillow>=9,<10", - "pydata-sphinx-theme", + "pydata-sphinx-theme>=0.14.1", "geopandas", "myst-parser", "sphinx_copybutton", @@ -230,6 +230,7 @@ module = [ "pandas.lib.*", "nbformat.*", "ipykernel.*", + "m2r.*", ] ignore_missing_imports = true diff --git a/tests/examples_arguments_syntax/image_tooltip.py b/tests/examples_arguments_syntax/image_tooltip.py deleted file mode 100644 index 8077f1b90..000000000 --- a/tests/examples_arguments_syntax/image_tooltip.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Image Tooltip -------------- -This example shows how to render images in tooltips. -Either URLs or local file paths can be used to reference -the images. To render the image, you must use the special -column name "image" in your data and pass it as a list to -the tooltip encoding. -""" -# category: interactive charts - -import altair as alt -import pandas as pd - -source = pd.DataFrame.from_records( - [{'a': 1, 'b': 1, 'image': 'https://altair-viz.github.io/_static/altair-logo-light.png'}, - {'a': 2, 'b': 2, 'image': 'https://avatars.githubusercontent.com/u/11796929?s=200&v=4'}] -) -alt.Chart(source).mark_circle(size=200).encode( - x='a', - y='b', - tooltip=['image'] # Must be a list containing a field called "image" -) diff --git a/tests/examples_methods_syntax/beckers_barley_facet.py b/tests/examples_methods_syntax/beckers_barley_facet.py index d3dcac894..598183a01 100644 --- a/tests/examples_methods_syntax/beckers_barley_facet.py +++ b/tests/examples_methods_syntax/beckers_barley_facet.py @@ -25,10 +25,10 @@ .sort('-x') .axis(grid=True), alt.Color('year:N') - .legend(title="Year"), + .title("Year"), alt.Row('site:N') .title("") - .sort(alt.EncodingSortField(field='yield', op='sum', order='descending')) + .sort(field='yield', op='sum', order='descending') ).properties( height=alt.Step(20) ).configure_view(stroke="transparent") diff --git a/tests/examples_methods_syntax/isotype.py b/tests/examples_methods_syntax/isotype.py index 92850b729..ec31e6256 100644 --- a/tests/examples_methods_syntax/isotype.py +++ b/tests/examples_methods_syntax/isotype.py @@ -69,7 +69,7 @@ alt.Chart(source).mark_point(filled=True, opacity=1, size=100).encode( alt.X('x:O').axis(None), alt.Y('animal:O').axis(None), - alt.Row('country:N').header(title=''), + alt.Row('country:N').title(None), alt.Shape('animal:N').legend(None).scale(shape_scale), alt.Color('animal:N').legend(None).scale(color_scale), ).transform_window( diff --git a/tests/examples_methods_syntax/layered_chart_with_dual_axis.py b/tests/examples_methods_syntax/layered_chart_with_dual_axis.py index d14fbca57..6f6cd86d6 100644 --- a/tests/examples_methods_syntax/layered_chart_with_dual_axis.py +++ b/tests/examples_methods_syntax/layered_chart_with_dual_axis.py @@ -11,7 +11,7 @@ source = data.seattle_weather() base = alt.Chart(source).encode( - alt.X('month(date):T').axis(title=None) + alt.X('month(date):T').title(None) ) area = base.mark_area(opacity=0.3, color='#57A44C').encode( diff --git a/tests/examples_methods_syntax/scatter_with_layered_histogram.py b/tests/examples_methods_syntax/scatter_with_layered_histogram.py index 995766ead..2cec75902 100644 --- a/tests/examples_methods_syntax/scatter_with_layered_histogram.py +++ b/tests/examples_methods_syntax/scatter_with_layered_histogram.py @@ -37,8 +37,8 @@ ).add_params(selector) points = base.mark_point(filled=True, size=200).encode( - x=alt.X('mean(height):Q').scale(domain=[0,84]), - y=alt.Y('mean(weight):Q').scale(domain=[0,250]), + alt.X('mean(height):Q').scale(domain=[0,84]), + alt.Y('mean(weight):Q').scale(domain=[0,250]), color=alt.condition( selector, 'gender:N', @@ -47,13 +47,13 @@ ) hists = base.mark_bar(opacity=0.5, thickness=100).encode( - x=alt.X('age') + alt.X('age') .bin(step=5) # step keeps bin size the same .scale(domain=[0,100]), - y=alt.Y('count()') + alt.Y('count()') .stack(None) .scale(domain=[0,350]), - color=alt.Color('gender:N', scale=color_scale) + alt.Color('gender:N').scale(color_scale) ).transform_filter( selector ) diff --git a/tests/examples_methods_syntax/seattle_weather_interactive.py b/tests/examples_methods_syntax/seattle_weather_interactive.py index 46b7448e4..8c59a83d9 100644 --- a/tests/examples_methods_syntax/seattle_weather_interactive.py +++ b/tests/examples_methods_syntax/seattle_weather_interactive.py @@ -11,11 +11,10 @@ source = data.seattle_weather() -scale = alt.Scale( +color = alt.Color('weather:N').scale( domain=['sun', 'fog', 'drizzle', 'rain', 'snow'], range=['#e7ba52', '#a7a7a7', '#aec7e8', '#1f77b4', '#9467bd'] ) -color = alt.Color('weather:N', scale=scale) # We create two selections: # - a brush that is active on the top panel diff --git a/tests/utils/test_core.py b/tests/utils/test_core.py index 75db18769..27cd3b7ee 100644 --- a/tests/utils/test_core.py +++ b/tests/utils/test_core.py @@ -1,4 +1,6 @@ import types +from packaging.version import Version +from importlib.metadata import version as importlib_version import numpy as np import pandas as pd @@ -16,6 +18,8 @@ except ImportError: pa = None +PANDAS_VERSION = Version(importlib_version("pandas")) + FAKE_CHANNELS_MODULE = f''' """Fake channels module for utility tests.""" @@ -160,6 +164,10 @@ def check(s, data, **kwargs): check("month(z)", data, timeUnit="month", field="z", type="temporal") check("month(t)", data, timeUnit="month", field="t", type="temporal") + if PANDAS_VERSION >= Version("1.0.0"): + data["b"] = pd.Series([True, False, True, False, None], dtype="boolean") + check("b", data, field="b", type="nominal") + @pytest.mark.skipif(pa is None, reason="pyarrow not installed") def test_parse_shorthand_for_arrow_timestamp(): diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 65e0ac0f3..c0334533a 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -1,9 +1,10 @@ -import pytest -import warnings +import io import json +import warnings import numpy as np import pandas as pd +import pytest from altair.utils import infer_vegalite_type, sanitize_dataframe @@ -68,7 +69,7 @@ def test_sanitize_dataframe(): print(s) # Re-construct pandas dataframe - df2 = pd.read_json(s) + df2 = pd.read_json(io.StringIO(s)) # Re-order the columns to match df df2 = df2[df.columns] diff --git a/tools/generate_api_docs.py b/tools/generate_api_docs.py index 20335c116..46b997313 100644 --- a/tools/generate_api_docs.py +++ b/tools/generate_api_docs.py @@ -5,15 +5,17 @@ import sys import types from os.path import abspath, dirname, join +from typing import Final, Optional, Iterator, List +from types import ModuleType # Import Altair from head ROOT_DIR = abspath(join(dirname(__file__), "..")) sys.path.insert(0, ROOT_DIR) import altair as alt # noqa: E402 -API_FILENAME = join(ROOT_DIR, "doc", "user_guide", "api.rst") +API_FILENAME: Final = join(ROOT_DIR, "doc", "user_guide", "api.rst") -API_TEMPLATE = """\ +API_TEMPLATE: Final = """\ .. _api: API Reference @@ -68,8 +70,11 @@ def iter_objects( - mod, ignore_private=True, restrict_to_type=None, restrict_to_subclass=None -): + mod: ModuleType, + ignore_private: bool = True, + restrict_to_type: Optional[type] = None, + restrict_to_subclass: Optional[type] = None, +) -> Iterator[str]: for name in dir(mod): obj = getattr(mod, name) if ignore_private: @@ -84,26 +89,26 @@ def iter_objects( yield name -def toplevel_charts(): - return sorted(iter_objects(alt.api, restrict_to_subclass=alt.TopLevelMixin)) +def toplevel_charts() -> List[str]: + return sorted(iter_objects(alt.api, restrict_to_subclass=alt.TopLevelMixin)) # type: ignore[attr-defined] -def encoding_wrappers(): +def encoding_wrappers() -> List[str]: return sorted(iter_objects(alt.channels, restrict_to_subclass=alt.SchemaBase)) -def api_functions(): +def api_functions() -> List[str]: # Exclude typing.cast altair_api_functions = [ obj_name - for obj_name in iter_objects(alt.api, restrict_to_type=types.FunctionType) + for obj_name in iter_objects(alt.api, restrict_to_type=types.FunctionType) # type: ignore[attr-defined] if obj_name != "cast" ] return sorted(altair_api_functions) -def lowlevel_wrappers(): - objects = sorted(iter_objects(alt.schema.core, restrict_to_subclass=alt.SchemaBase)) +def lowlevel_wrappers() -> List[str]: + objects = sorted(iter_objects(alt.schema.core, restrict_to_subclass=alt.SchemaBase)) # type: ignore[attr-defined] # The names of these two classes are also used for classes in alt.channels. Due to # how imports are set up, these channel classes overwrite the two low-level classes # in the top-level Altair namespace. Therefore, they cannot be imported as e.g. @@ -113,7 +118,7 @@ def lowlevel_wrappers(): return objects -def write_api_file(): +def write_api_file() -> None: print("Updating API docs\n ->{}".format(API_FILENAME)) sep = "\n " with open(API_FILENAME, "w") as f: diff --git a/tools/generate_schema_wrapper.py b/tools/generate_schema_wrapper.py index 600ebdfbc..ee20a6349 100644 --- a/tools/generate_schema_wrapper.py +++ b/tools/generate_schema_wrapper.py @@ -6,6 +6,7 @@ import json import re from os.path import abspath, join, dirname +from typing import Final, Optional, List, Dict, Tuple, Literal, Union, Type import textwrap from urllib import request @@ -28,22 +29,19 @@ resolve_references, ) -# Map of version name to github branch name. -SCHEMA_VERSION = { - "vega-lite": {"v5": "v5.14.1"}, -} +SCHEMA_VERSION: Final = "v5.15.1" reLink = re.compile(r"(?<=\[)([^\]]+)(?=\]\([^\)]+\))", re.M) reSpecial = re.compile(r"[*_]{2,3}|`", re.M) -HEADER = """\ +HEADER: Final = """\ # The contents of this file are automatically written by # tools/generate_schema_wrapper.py. Do not modify directly. """ -SCHEMA_URL_TEMPLATE = "https://vega.github.io/schema/" "{library}/{version}.json" +SCHEMA_URL_TEMPLATE: Final = "https://vega.github.io/schema/" "{library}/{version}.json" -BASE_SCHEMA = """ +BASE_SCHEMA: Final = """ class {basename}(SchemaBase): _rootschema = load_schema() @classmethod @@ -51,7 +49,7 @@ def _default_wrapper_classes(cls): return _subclasses({basename}) """ -LOAD_SCHEMA = ''' +LOAD_SCHEMA: Final = ''' import pkgutil import json @@ -61,7 +59,7 @@ def load_schema(): ''' -CHANNEL_MIXINS = """ +CHANNEL_MIXINS: Final = """ class FieldChannelMixin: def to_dict(self, validate=True, ignore=(), context=None): context = context or {} @@ -146,7 +144,7 @@ def to_dict(self, validate=True, ignore=(), context=None): context=context) """ -MARK_METHOD = ''' +MARK_METHOD: Final = ''' def mark_{mark}({def_arglist}) -> Self: """Set the chart's mark to '{mark}' (see :class:`{mark_def}`) """ @@ -159,7 +157,7 @@ def mark_{mark}({def_arglist}) -> Self: return copy ''' -CONFIG_METHOD = """ +CONFIG_METHOD: Final = """ @use_signature(core.{classname}) def {method}(self, *args, **kwargs) -> Self: copy = self.copy(deep=False) @@ -167,7 +165,7 @@ def {method}(self, *args, **kwargs) -> Self: return copy """ -CONFIG_PROP_METHOD = """ +CONFIG_PROP_METHOD: Final = """ @use_signature(core.{classname}) def configure_{prop}(self, *args, **kwargs) -> Self: copy = self.copy(deep=['config']) @@ -189,7 +187,7 @@ class {classname}({basename}): ''' ) - def _process_description(self, description): + def _process_description(self, description: str): description = "".join( [ reSpecial.sub("", d) if i % 2 else d @@ -257,20 +255,21 @@ class {classname}(DatumChannelMixin, core.{basename}): ) -def schema_class(*args, **kwargs): +def schema_class(*args, **kwargs) -> str: return SchemaGenerator(*args, **kwargs).schema_class() -def schema_url(library, version): - version = SCHEMA_VERSION[library][version] - return SCHEMA_URL_TEMPLATE.format(library=library, version=version) +def schema_url(version: str = SCHEMA_VERSION) -> str: + return SCHEMA_URL_TEMPLATE.format(library="vega-lite", version=version) -def download_schemafile(library, version, schemapath, skip_download=False): - url = schema_url(library, version) +def download_schemafile( + version: str, schemapath: str, skip_download: bool = False +) -> str: + url = schema_url(version=version) if not os.path.exists(schemapath): os.makedirs(schemapath) - filename = os.path.join(schemapath, "{library}-schema.json".format(library=library)) + filename = os.path.join(schemapath, "vega-lite-schema.json") if not skip_download: request.urlretrieve(url, filename) elif not os.path.exists(filename): @@ -278,7 +277,7 @@ def download_schemafile(library, version, schemapath, skip_download=False): return filename -def copy_schemapi_util(): +def copy_schemapi_util() -> None: """ Copy the schemapi utility into altair/utils/ and its test file to tests/utils/ """ @@ -295,7 +294,7 @@ def copy_schemapi_util(): dest.writelines(source.readlines()) -def recursive_dict_update(schema, root, def_dict): +def recursive_dict_update(schema: dict, root: dict, def_dict: dict) -> None: if "$ref" in schema: next_schema = resolve_references(schema, root) if "properties" in next_schema: @@ -311,8 +310,8 @@ def recursive_dict_update(schema, root, def_dict): recursive_dict_update(sub_schema, root, def_dict) -def get_field_datum_value_defs(propschema, root): - def_dict = {k: None for k in ("field", "datum", "value")} +def get_field_datum_value_defs(propschema: SchemaInfo, root: dict) -> dict: + def_dict: Dict[str, Optional[str]] = {k: None for k in ("field", "datum", "value")} schema = propschema.schema if propschema.is_reference() and "properties" in schema: if "field" in schema["properties"]: @@ -325,7 +324,7 @@ def get_field_datum_value_defs(propschema, root): return {i: j for i, j in def_dict.items() if j} -def toposort(graph): +def toposort(graph: Dict[str, List[str]]) -> List[str]: """Topological sort of a directed acyclic graph. Parameters @@ -339,8 +338,10 @@ def toposort(graph): order : list topological order of input graph. """ - stack = [] - visited = {} + # Once we drop support for Python 3.8, this can potentially be replaced + # with graphlib.TopologicalSorter from the standard library. + stack: List[str] = [] + visited: Dict[str, Literal[True]] = {} def visit(nodes): for node in sorted(nodes, reverse=True): @@ -353,7 +354,7 @@ def visit(nodes): return stack -def generate_vegalite_schema_wrapper(schema_file): +def generate_vegalite_schema_wrapper(schema_file: str) -> str: """Generate a schema wrapper at the given path.""" # TODO: generate simple tests for each wrapper basename = "VegaLiteSchema" @@ -361,7 +362,7 @@ def generate_vegalite_schema_wrapper(schema_file): with open(schema_file, encoding="utf8") as f: rootschema = json.load(f) - definitions = {} + definitions: Dict[str, SchemaGenerator] = {} for name in rootschema["definitions"]: defschema = {"$ref": "#/definitions/" + name} @@ -376,17 +377,18 @@ def generate_vegalite_schema_wrapper(schema_file): rootschemarepr=CodeSnippet("{}._rootschema".format(basename)), ) - graph = {} + graph: Dict[str, List[str]] = {} for name, schema in definitions.items(): graph[name] = [] - for child in schema.subclasses(): - child = get_valid_identifier(child) - graph[name].append(child) - child = definitions[child] + for child_name in schema.subclasses(): + child_name = get_valid_identifier(child_name) + graph[name].append(child_name) + child: SchemaGenerator = definitions[child_name] if child.basename == basename: child.basename = [name] else: + assert isinstance(child.basename, list) child.basename.append(name) contents = [ @@ -411,7 +413,9 @@ def generate_vegalite_schema_wrapper(schema_file): return "\n".join(contents) -def generate_vegalite_channel_wrappers(schemafile, version, imports=None): +def generate_vegalite_channel_wrappers( + schemafile: str, version: str, imports: Optional[List[str]] = None +) -> str: # TODO: generate __all__ for top of file with open(schemafile, encoding="utf8") as f: schema = json.load(f) @@ -449,6 +453,11 @@ def generate_vegalite_channel_wrappers(schemafile, version, imports=None): defschema = {"$ref": definition} + Generator: Union[ + Type[FieldSchemaGenerator], + Type[DatumSchemaGenerator], + Type[ValueSchemaGenerator], + ] if encoding_spec == "field": Generator = FieldSchemaGenerator nodefault = [] @@ -485,7 +494,9 @@ def generate_vegalite_channel_wrappers(schemafile, version, imports=None): return "\n".join(contents) -def generate_vegalite_mark_mixin(schemafile, markdefs): +def generate_vegalite_mark_mixin( + schemafile: str, markdefs: Dict[str, str] +) -> Tuple[List[str], str]: with open(schemafile, encoding="utf8") as f: schema = json.load(f) @@ -509,16 +520,20 @@ def generate_vegalite_mark_mixin(schemafile, markdefs): info = SchemaInfo({"$ref": "#/definitions/" + mark_def}, rootschema=schema) # adapted from SchemaInfo.init_code - nonkeyword, required, kwds, invalid_kwds, additional = codegen._get_args(info) - required -= {"type"} - kwds -= {"type"} + arg_info = codegen.get_args(info) + arg_info.required -= {"type"} + arg_info.kwds -= {"type"} def_args = ["self"] + [ - "{}=Undefined".format(p) for p in (sorted(required) + sorted(kwds)) + "{}=Undefined".format(p) + for p in (sorted(arg_info.required) + sorted(arg_info.kwds)) + ] + dict_args = [ + "{0}={0}".format(p) + for p in (sorted(arg_info.required) + sorted(arg_info.kwds)) ] - dict_args = ["{0}={0}".format(p) for p in (sorted(required) + sorted(kwds))] - if additional or invalid_kwds: + if arg_info.additional or arg_info.invalid_kwds: def_args.append("**kwds") dict_args.append("**kwds") @@ -535,7 +550,7 @@ def generate_vegalite_mark_mixin(schemafile, markdefs): return imports, "\n".join(code) -def generate_vegalite_config_mixin(schemafile): +def generate_vegalite_config_mixin(schemafile: str) -> Tuple[List[str], str]: imports = ["from . import core", "from altair.utils import use_signature"] class_name = "ConfigMethodMixin" @@ -561,77 +576,69 @@ def generate_vegalite_config_mixin(schemafile): return imports, "\n".join(code) -def vegalite_main(skip_download=False): - library = "vega-lite" - - for version in SCHEMA_VERSION[library]: - path = abspath(join(dirname(__file__), "..", "altair", "vegalite", version)) - schemapath = os.path.join(path, "schema") - schemafile = download_schemafile( - library=library, - version=version, - schemapath=schemapath, - skip_download=skip_download, - ) +def vegalite_main(skip_download: bool = False) -> None: + version = SCHEMA_VERSION + path = abspath( + join(dirname(__file__), "..", "altair", "vegalite", version.split(".")[0]) + ) + schemapath = os.path.join(path, "schema") + schemafile = download_schemafile( + version=version, + schemapath=schemapath, + skip_download=skip_download, + ) - # Generate __init__.py file - outfile = join(schemapath, "__init__.py") - print("Writing {}".format(outfile)) - with open(outfile, "w", encoding="utf8") as f: - f.write("# ruff: noqa\n") - f.write("from .core import *\nfrom .channels import *\n") - f.write( - "SCHEMA_VERSION = {!r}\n" "".format(SCHEMA_VERSION[library][version]) - ) - f.write("SCHEMA_URL = {!r}\n" "".format(schema_url(library, version))) - - # Generate the core schema wrappers - outfile = join(schemapath, "core.py") - print("Generating\n {}\n ->{}".format(schemafile, outfile)) - file_contents = generate_vegalite_schema_wrapper(schemafile) - with open(outfile, "w", encoding="utf8") as f: - f.write(file_contents) - - # Generate the channel wrappers - outfile = join(schemapath, "channels.py") - print("Generating\n {}\n ->{}".format(schemafile, outfile)) - code = generate_vegalite_channel_wrappers(schemafile, version=version) - with open(outfile, "w", encoding="utf8") as f: - f.write(code) - - # generate the mark mixin - if version == "v2": - markdefs = {"Mark": "MarkDef"} - else: - markdefs = { - k: k + "Def" for k in ["Mark", "BoxPlot", "ErrorBar", "ErrorBand"] - } - outfile = join(schemapath, "mixins.py") - print("Generating\n {}\n ->{}".format(schemafile, outfile)) - mark_imports, mark_mixin = generate_vegalite_mark_mixin(schemafile, markdefs) - config_imports, config_mixin = generate_vegalite_config_mixin(schemafile) - try_except_imports = [ - "if sys.version_info >= (3, 11):", - " from typing import Self", - "else:", - " from typing_extensions import Self", - ] - stdlib_imports = ["import sys"] - imports = sorted(set(mark_imports + config_imports)) - with open(outfile, "w", encoding="utf8") as f: - f.write(HEADER) - f.write("\n".join(stdlib_imports)) - f.write("\n\n") - f.write("\n".join(imports)) - f.write("\n\n") - f.write("\n".join(try_except_imports)) - f.write("\n\n\n") - f.write(mark_mixin) - f.write("\n\n\n") - f.write(config_mixin) - - -def main(): + # Generate __init__.py file + outfile = join(schemapath, "__init__.py") + print("Writing {}".format(outfile)) + with open(outfile, "w", encoding="utf8") as f: + f.write("# ruff: noqa\n") + f.write("from .core import *\nfrom .channels import *\n") + f.write(f"SCHEMA_VERSION = '{version}'\n") + f.write("SCHEMA_URL = {!r}\n" "".format(schema_url(version))) + + # Generate the core schema wrappers + outfile = join(schemapath, "core.py") + print("Generating\n {}\n ->{}".format(schemafile, outfile)) + file_contents = generate_vegalite_schema_wrapper(schemafile) + with open(outfile, "w", encoding="utf8") as f: + f.write(file_contents) + + # Generate the channel wrappers + outfile = join(schemapath, "channels.py") + print("Generating\n {}\n ->{}".format(schemafile, outfile)) + code = generate_vegalite_channel_wrappers(schemafile, version=version) + with open(outfile, "w", encoding="utf8") as f: + f.write(code) + + # generate the mark mixin + markdefs = {k: k + "Def" for k in ["Mark", "BoxPlot", "ErrorBar", "ErrorBand"]} + outfile = join(schemapath, "mixins.py") + print("Generating\n {}\n ->{}".format(schemafile, outfile)) + mark_imports, mark_mixin = generate_vegalite_mark_mixin(schemafile, markdefs) + config_imports, config_mixin = generate_vegalite_config_mixin(schemafile) + try_except_imports = [ + "if sys.version_info >= (3, 11):", + " from typing import Self", + "else:", + " from typing_extensions import Self", + ] + stdlib_imports = ["import sys"] + imports = sorted(set(mark_imports + config_imports)) + with open(outfile, "w", encoding="utf8") as f: + f.write(HEADER) + f.write("\n".join(stdlib_imports)) + f.write("\n\n") + f.write("\n".join(imports)) + f.write("\n\n") + f.write("\n".join(try_except_imports)) + f.write("\n\n\n") + f.write(mark_mixin) + f.write("\n\n\n") + f.write(config_mixin) + + +def main() -> None: parser = argparse.ArgumentParser( prog="generate_schema_wrapper.py", description="Generate the Altair package." ) diff --git a/tools/schemapi/codegen.py b/tools/schemapi/codegen.py index acf99d88b..3f97bdf2f 100644 --- a/tools/schemapi/codegen.py +++ b/tools/schemapi/codegen.py @@ -1,39 +1,56 @@ """Code generation utilities""" -from .utils import SchemaInfo, is_valid_identifier, indent_docstring, indent_arglist - -import textwrap import re +import textwrap +from typing import Set, Final, Optional, List, Iterable, Union +from dataclasses import dataclass + +from .utils import ( + SchemaInfo, + is_valid_identifier, + indent_docstring, + indent_arglist, + SchemaProperties, +) class CodeSnippet: """Object whose repr() is a string of code""" - def __init__(self, code): + def __init__(self, code: str): self.code = code - def __repr__(self): + def __repr__(self) -> str: return self.code -def _get_args(info): +@dataclass +class ArgInfo: + nonkeyword: bool + required: Set[str] + kwds: Set[str] + invalid_kwds: Set[str] + additional: bool + + +def get_args(info: SchemaInfo) -> ArgInfo: """Return the list of args & kwds for building the __init__ function""" # TODO: - set additional properties correctly # - handle patternProperties etc. - required = set() - kwds = set() - invalid_kwds = set() + required: Set[str] = set() + kwds: Set[str] = set() + invalid_kwds: Set[str] = set() # TODO: specialize for anyOf/oneOf? if info.is_allOf(): # recursively call function on all children - arginfo = [_get_args(child) for child in info.allOf] - nonkeyword = all(args[0] for args in arginfo) - required = set.union(set(), *(args[1] for args in arginfo)) - kwds = set.union(set(), *(args[2] for args in arginfo)) + arginfo = [get_args(child) for child in info.allOf] + nonkeyword = all(args.nonkeyword for args in arginfo) + required = set.union(set(), *(args.required for args in arginfo)) + kwds = set.union(set(), *(args.kwds for args in arginfo)) kwds -= required - invalid_kwds = set.union(set(), *(args[3] for args in arginfo)) - additional = all(args[4] for args in arginfo) + invalid_kwds = set.union(set(), *(args.invalid_kwds for args in arginfo)) + additional = all(args.additional for args in arginfo) elif info.is_empty() or info.is_compound(): nonkeyword = True additional = True @@ -53,7 +70,13 @@ def _get_args(info): else: raise ValueError("Schema object not understood") - return (nonkeyword, required, kwds, invalid_kwds, additional) + return ArgInfo( + nonkeyword=nonkeyword, + required=required, + kwds=kwds, + invalid_kwds=invalid_kwds, + additional=additional, + ) class SchemaGenerator: @@ -67,7 +90,7 @@ class SchemaGenerator: The dictionary defining the schema class rootschema : dict (optional) The root schema for the class - basename : string or tuple (default: "SchemaBase") + basename : string or list of strings (default: "SchemaBase") The name(s) of the base class(es) to use in the class definition schemarepr : CodeSnippet or object, optional An object whose repr will be used in the place of the explicit schema. @@ -92,47 +115,51 @@ class {classname}({basename}): ''' ) - init_template = textwrap.dedent( + init_template: Final = textwrap.dedent( """ def __init__({arglist}): super({classname}, self).__init__({super_arglist}) """ ).lstrip() - def _process_description(self, description): + def _process_description(self, description: str): return description def __init__( self, - classname, - schema, - rootschema=None, - basename="SchemaBase", - schemarepr=None, - rootschemarepr=None, - nodefault=(), - haspropsetters=False, + classname: str, + schema: dict, + rootschema: Optional[dict] = None, + basename: Union[str, List[str]] = "SchemaBase", + schemarepr: Optional[object] = None, + rootschemarepr: Optional[object] = None, + nodefault: Optional[List[str]] = None, + haspropsetters: bool = False, **kwargs, - ): + ) -> None: self.classname = classname self.schema = schema self.rootschema = rootschema self.basename = basename self.schemarepr = schemarepr self.rootschemarepr = rootschemarepr - self.nodefault = nodefault + self.nodefault = nodefault or () self.haspropsetters = haspropsetters self.kwargs = kwargs - def subclasses(self): + def subclasses(self) -> List[str]: """Return a list of subclass names, if any.""" info = SchemaInfo(self.schema, self.rootschema) return [child.refname for child in info.anyOf if child.is_reference()] - def schema_class(self): + def schema_class(self) -> str: """Generate code for a schema class""" - rootschema = self.rootschema if self.rootschema is not None else self.schema - schemarepr = self.schemarepr if self.schemarepr is not None else self.schema + rootschema: dict = ( + self.rootschema if self.rootschema is not None else self.schema + ) + schemarepr: object = ( + self.schemarepr if self.schemarepr is not None else self.schema + ) rootschemarepr = self.rootschemarepr if rootschemarepr is None: if rootschema is self.schema: @@ -154,11 +181,11 @@ def schema_class(self): **self.kwargs, ) - def docstring(self, indent=0): + def docstring(self, indent: int = 0) -> str: # TODO: add a general description at the top, derived from the schema. # for example, a non-object definition should list valid type, enum # values, etc. - # TODO: use _get_args here for more information on allOf objects + # TODO: use get_args here for more information on allOf objects info = SchemaInfo(self.schema, self.rootschema) doc = ["{} schema wrapper".format(self.classname), "", info.medium_description] if info.description: @@ -172,9 +199,13 @@ def docstring(self, indent=0): doc = [line for line in doc if ":raw-html:" not in line] if info.properties: - nonkeyword, required, kwds, invalid_kwds, additional = _get_args(info) + arg_info = get_args(info) doc += ["", "Parameters", "----------", ""] - for prop in sorted(required) + sorted(kwds) + sorted(invalid_kwds): + for prop in ( + sorted(arg_info.required) + + sorted(arg_info.kwds) + + sorted(arg_info.invalid_kwds) + ): propinfo = info.properties[prop] doc += [ "{} : {}".format(prop, propinfo.short_description), @@ -186,33 +217,38 @@ def docstring(self, indent=0): doc += [""] return indent_docstring(doc, indent_level=indent, width=100, lstrip=True) - def init_code(self, indent=0): + def init_code(self, indent: int = 0) -> str: """Return code suitable for the __init__ function of a Schema class""" info = SchemaInfo(self.schema, rootschema=self.rootschema) - nonkeyword, required, kwds, invalid_kwds, additional = _get_args(info) + arg_info = get_args(info) nodefault = set(self.nodefault) - required -= nodefault - kwds -= nodefault + arg_info.required -= nodefault + arg_info.kwds -= nodefault - args = ["self"] - super_args = [] + args: List[str] = ["self"] + super_args: List[str] = [] - self.init_kwds = sorted(kwds) + self.init_kwds = sorted(arg_info.kwds) if nodefault: args.extend(sorted(nodefault)) - elif nonkeyword: + elif arg_info.nonkeyword: args.append("*args") super_args.append("*args") - args.extend("{}=Undefined".format(p) for p in sorted(required) + sorted(kwds)) + args.extend( + "{}=Undefined".format(p) + for p in sorted(arg_info.required) + sorted(arg_info.kwds) + ) super_args.extend( "{0}={0}".format(p) - for p in sorted(nodefault) + sorted(required) + sorted(kwds) + for p in sorted(nodefault) + + sorted(arg_info.required) + + sorted(arg_info.kwds) ) - if additional: + if arg_info.additional: args.append("**kwds") super_args.append("**kwds") @@ -240,9 +276,9 @@ def init_code(self, indent=0): "null": "None", } - def get_args(self, si): + def get_args(self, si: SchemaInfo) -> List[str]: contents = ["self"] - props = [] + props: Union[List[str], SchemaProperties] = [] if si.is_anyOf(): props = sorted({p for si_sub in si.anyOf for p in si_sub.properties}) elif si.properties: @@ -275,7 +311,9 @@ def get_args(self, si): return contents - def get_signature(self, attr, sub_si, indent, has_overload=False): + def get_signature( + self, attr: str, sub_si: SchemaInfo, indent: int, has_overload: bool = False + ) -> List[str]: lines = [] if has_overload: lines.append("@overload # type: ignore[no-overload-impl]") @@ -284,14 +322,16 @@ def get_signature(self, attr, sub_si, indent, has_overload=False): lines.append(indent * " " + "...\n") return lines - def setter_hint(self, attr, indent): + def setter_hint(self, attr: str, indent: int) -> List[str]: si = SchemaInfo(self.schema, self.rootschema).properties[attr] if si.is_anyOf(): return self._get_signature_any_of(si, attr, indent) else: return self.get_signature(attr, si, indent) - def _get_signature_any_of(self, si: SchemaInfo, attr, indent): + def _get_signature_any_of( + self, si: SchemaInfo, attr: str, indent: int + ) -> List[str]: signatures = [] for sub_si in si.anyOf: if sub_si.is_anyOf(): @@ -303,7 +343,7 @@ def _get_signature_any_of(self, si: SchemaInfo, attr, indent): ) return list(flatten(signatures)) - def method_code(self, indent=0): + def method_code(self, indent: int = 0) -> Optional[str]: """Return code to assist setter methods""" if not self.haspropsetters: return None @@ -313,7 +353,7 @@ def method_code(self, indent=0): return ("\n" + indent * " ").join(type_hints) -def flatten(container): +def flatten(container: Iterable) -> Iterable: """Flatten arbitrarily flattened list From https://stackoverflow.com/a/10824420 diff --git a/tools/schemapi/utils.py b/tools/schemapi/utils.py index e05cac91a..7a3d27408 100644 --- a/tools/schemapi/utils.py +++ b/tools/schemapi/utils.py @@ -4,16 +4,20 @@ import re import textwrap import urllib +from typing import Final, Optional, List, Dict, Any from .schemapi import _resolve_references as resolve_references -EXCLUDE_KEYS = ("definitions", "title", "description", "$schema", "id") +EXCLUDE_KEYS: Final = ("definitions", "title", "description", "$schema", "id") def get_valid_identifier( - prop, replacement_character="", allow_unicode=False, url_decode=True -): + prop: str, + replacement_character: str = "", + allow_unicode: bool = False, + url_decode: bool = True, +) -> str: """Given a string property, generate a valid Python identifier Parameters @@ -70,7 +74,7 @@ def get_valid_identifier( return valid -def is_valid_identifier(var, allow_unicode=False): +def is_valid_identifier(var: str, allow_unicode: bool = False): """Return true if var contains a valid Python identifier Parameters @@ -88,15 +92,20 @@ def is_valid_identifier(var, allow_unicode=False): class SchemaProperties: """A wrapper for properties within a schema""" - def __init__(self, properties, schema, rootschema=None): + def __init__( + self, + properties: Dict[str, Any], + schema: dict, + rootschema: Optional[dict] = None, + ) -> None: self._properties = properties self._schema = schema self._rootschema = rootschema or schema - def __bool__(self): + def __bool__(self) -> bool: return bool(self._properties) - def __dir__(self): + def __dir__(self) -> List[str]: return list(self._properties.keys()) def __getattr__(self, attr): @@ -127,22 +136,19 @@ def values(self): class SchemaInfo: """A wrapper for inspecting a JSON schema""" - def __init__(self, schema, rootschema=None): - if hasattr(schema, "_schema"): - if hasattr(schema, "_rootschema"): - schema, rootschema = schema._schema, schema._rootschema - else: - schema, rootschema = schema._schema, schema._schema - elif not rootschema: + def __init__( + self, schema: Dict[str, Any], rootschema: Optional[Dict[str, Any]] = None + ) -> None: + if not rootschema: rootschema = schema self.raw_schema = schema self.rootschema = rootschema self.schema = resolve_references(schema, rootschema) - def child(self, schema): + def child(self, schema: dict) -> "SchemaInfo": return self.__class__(schema, rootschema=self.rootschema) - def __repr__(self): + def __repr__(self) -> str: keys = [] for key in sorted(self.schema.keys()): val = self.schema[key] @@ -157,21 +163,21 @@ def __repr__(self): return "SchemaInfo({\n " + "\n ".join(keys) + "\n})" @property - def title(self): + def title(self) -> str: if self.is_reference(): return get_valid_identifier(self.refname) else: return "" @property - def short_description(self): + def short_description(self) -> str: if self.title: # use RST syntax for generated sphinx docs return ":class:`{}`".format(self.title) else: return self.medium_description - _simple_types = { + _simple_types: Dict[str, str] = { "string": "string", "number": "float", "integer": "integer", @@ -182,12 +188,8 @@ def short_description(self): } @property - def medium_description(self): - if self.is_list(): - return "[{0}]".format( - ", ".join(self.child(s).short_description for s in self.schema) - ) - elif self.is_empty(): + def medium_description(self) -> str: + if self.is_empty(): return "Any" elif self.is_enum(): return "enum({})".format(", ".join(map(repr, self.enum))) @@ -226,81 +228,80 @@ def medium_description(self): stacklevel=1, ) return "any" + else: + raise ValueError( + "No medium_description available for this schema for schema" + ) @property - def long_description(self): - # TODO - return "Long description including arguments and their types" - - @property - def properties(self): + def properties(self) -> SchemaProperties: return SchemaProperties( self.schema.get("properties", {}), self.schema, self.rootschema ) @property - def definitions(self): + def definitions(self) -> SchemaProperties: return SchemaProperties( self.schema.get("definitions", {}), self.schema, self.rootschema ) @property - def required(self): + def required(self) -> list: return self.schema.get("required", []) @property - def patternProperties(self): + def patternProperties(self) -> dict: return self.schema.get("patternProperties", {}) @property - def additionalProperties(self): + def additionalProperties(self) -> bool: return self.schema.get("additionalProperties", True) @property - def type(self): + def type(self) -> Optional[str]: return self.schema.get("type", None) @property - def anyOf(self): + def anyOf(self) -> List["SchemaInfo"]: return [self.child(s) for s in self.schema.get("anyOf", [])] @property - def oneOf(self): + def oneOf(self) -> List["SchemaInfo"]: return [self.child(s) for s in self.schema.get("oneOf", [])] @property - def allOf(self): + def allOf(self) -> List["SchemaInfo"]: return [self.child(s) for s in self.schema.get("allOf", [])] @property - def not_(self): + def not_(self) -> "SchemaInfo": return self.child(self.schema.get("not", {})) @property - def items(self): + def items(self) -> dict: return self.schema.get("items", {}) @property - def enum(self): + def enum(self) -> list: return self.schema.get("enum", []) @property - def refname(self): + def refname(self) -> str: return self.raw_schema.get("$ref", "#/").split("/")[-1] @property - def ref(self): + def ref(self) -> Optional[str]: return self.raw_schema.get("$ref", None) @property - def description(self): + def description(self) -> str: return self._get_description(include_sublevels=False) @property - def deep_description(self): + def deep_description(self) -> str: return self._get_description(include_sublevels=True) - def _get_description(self, include_sublevels: bool = False): + def _get_description(self, include_sublevels: bool = False) -> str: desc = self.raw_schema.get("description", self.schema.get("description", "")) if not desc and include_sublevels: for item in self.anyOf: @@ -316,34 +317,31 @@ def _get_description(self, include_sublevels: bool = False): desc = sub_desc return desc - def is_list(self): - return isinstance(self.schema, list) - - def is_reference(self): + def is_reference(self) -> bool: return "$ref" in self.raw_schema - def is_enum(self): + def is_enum(self) -> bool: return "enum" in self.schema - def is_empty(self): + def is_empty(self) -> bool: return not (set(self.schema.keys()) - set(EXCLUDE_KEYS)) - def is_compound(self): + def is_compound(self) -> bool: return any(key in self.schema for key in ["anyOf", "allOf", "oneOf"]) - def is_anyOf(self): + def is_anyOf(self) -> bool: return "anyOf" in self.schema - def is_allOf(self): + def is_allOf(self) -> bool: return "allOf" in self.schema - def is_oneOf(self): + def is_oneOf(self) -> bool: return "oneOf" in self.schema - def is_not(self): + def is_not(self) -> bool: return "not" in self.schema - def is_object(self): + def is_object(self) -> bool: if self.type == "object": return True elif self.type is not None: @@ -358,40 +356,16 @@ def is_object(self): else: raise ValueError("Unclear whether schema.is_object() is True") - def is_value(self): + def is_value(self) -> bool: return not self.is_object() - def is_array(self): + def is_array(self) -> bool: return self.type == "array" - def schema_type(self): - if self.is_empty(): - return "empty" - elif self.is_compound(): - for key in ["anyOf", "oneOf", "allOf"]: - if key in self.schema: - return key - elif self.is_object(): - return "object" - elif self.is_array(): - return "array" - elif self.is_value(): - return "value" - else: - raise ValueError("Unknown type with keys {}".format(self.schema)) - - def property_name_map(self): - """ - Return a mapping of schema property names to valid Python attribute names - - Only properties which are not valid Python identifiers will be included in - the dictionary. - """ - pairs = [(prop, get_valid_identifier(prop)) for prop in self.properties] - return {prop: val for prop, val in pairs if prop != val} - -def indent_arglist(args, indent_level, width=100, lstrip=True): +def indent_arglist( + args: List[str], indent_level: int, width: int = 100, lstrip: bool = True +) -> str: """Indent an argument list for use in generated code""" wrapper = textwrap.TextWrapper( width=width, @@ -405,7 +379,9 @@ def indent_arglist(args, indent_level, width=100, lstrip=True): return wrapped -def indent_docstring(lines, indent_level, width=100, lstrip=True): +def indent_docstring( + lines: List[str], indent_level: int, width: int = 100, lstrip=True +) -> str: """Indent a docstring for use in generated code""" final_lines = [] @@ -467,7 +443,7 @@ def indent_docstring(lines, indent_level, width=100, lstrip=True): return wrapped -def fix_docstring_issues(docstring): +def fix_docstring_issues(docstring: str) -> str: # All lists should start with '*' followed by a whitespace. Fixes the ones # which either do not have a whitespace or/and start with '-' by first replacing # "-" with "*" and then adding a whitespace where necessary diff --git a/tools/update_init_file.py b/tools/update_init_file.py index c02e63a8c..e90def7b7 100644 --- a/tools/update_init_file.py +++ b/tools/update_init_file.py @@ -15,15 +15,15 @@ else: from typing_extensions import Self -from typing import Literal +from typing import Literal, Final # Import Altair from head -ROOT_DIR = abspath(join(dirname(__file__), "..")) +ROOT_DIR: Final = abspath(join(dirname(__file__), "..")) sys.path.insert(0, ROOT_DIR) import altair as alt # noqa: E402 -def update__all__variable(): +def update__all__variable() -> None: """Updates the __all__ variable to all relevant attributes of top-level Altair. This is for example useful to hide deprecated attributes from code completion in Jupyter. @@ -65,7 +65,7 @@ def update__all__variable(): f.write(new_file_content) -def _is_relevant_attribute(attr_name): +def _is_relevant_attribute(attr_name: str) -> bool: attr = getattr(alt, attr_name) if ( getattr(attr, "_deprecated", False) is True