├── altair_saver ├── tests │ ├── __init__.py │ ├── test_entrypoint.py │ ├── test_utils.py │ └── test_core.py ├── savers │ ├── tests │ │ ├── __init__.py │ │ ├── testcases │ │ │ ├── bar.pdf │ │ │ ├── bar.png │ │ │ ├── scatter.pdf │ │ │ ├── scatter.png │ │ │ ├── scatter.vl.json │ │ │ ├── bar.vl.json │ │ │ ├── scatter.vg.json │ │ │ ├── bar.vg.json │ │ │ ├── bar.svg │ │ │ └── scatter.svg │ │ ├── test_basic.py │ │ ├── test_node.py │ │ ├── test_selenium.py │ │ └── test_html.py │ ├── __init__.py │ ├── _basic.py │ ├── _node.py │ ├── _saver.py │ ├── _html.py │ └── _selenium.py ├── _types.py ├── __init__.py ├── _utils.py └── _core.py ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── MANIFEST.in ├── Makefile ├── tools └── generate.py ├── .github └── workflows │ ├── lint.yml │ └── build.yml ├── mypy.ini ├── RELEASING.md ├── LICENSE ├── CHANGES.md ├── .gitignore ├── setup.py └── README.md /altair_saver/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | flake8 3 | mypy 4 | pillow 5 | pypdf2 6 | pytest 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | altair 2 | altair_data_server >= 0.4.0 3 | altair_viewer 4 | selenium 5 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/bar.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakevdp/altair_saver/master/altair_saver/savers/tests/testcases/bar.pdf -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakevdp/altair_saver/master/altair_saver/savers/tests/testcases/bar.png -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/scatter.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakevdp/altair_saver/master/altair_saver/savers/tests/testcases/scatter.pdf -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/scatter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakevdp/altair_saver/master/altair_saver/savers/tests/testcases/scatter.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = E203, E266, E501, W503 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | 7 | [metadata] 8 | description-file = README.md 9 | license_file = LICENSE 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include *.py 3 | include setup.cfg 4 | include mypy.ini 5 | include LICENSE 6 | include requirements.txt 7 | include requirements-dev.txt 8 | recursive-include altair_saver *.py *.json *.png *.svg *.pdf -------------------------------------------------------------------------------- /altair_saver/_types.py: -------------------------------------------------------------------------------- 1 | __all__ = ["JSON", "JSONDict", "MimebundleContent", "Mimebundle"] 2 | 3 | from typing import Any, Dict, List, Union 4 | 5 | JSON = Union[str, int, float, bool, None, Dict[str, Any], List[Any]] 6 | JSONDict = Dict[str, JSON] 7 | MimebundleContent = Union[str, bytes, JSONDict] 8 | Mimebundle = Dict[str, MimebundleContent] 9 | -------------------------------------------------------------------------------- /altair_saver/savers/__init__.py: -------------------------------------------------------------------------------- 1 | from ._saver import Saver 2 | from ._basic import BasicSaver 3 | from ._html import HTMLSaver 4 | from ._node import NodeSaver 5 | from ._selenium import SeleniumSaver, JavascriptError 6 | 7 | __all__ = [ 8 | "Saver", 9 | "BasicSaver", 10 | "HTMLSaver", 11 | "NodeSaver", 12 | "SeleniumSaver", 13 | "JavascriptError", 14 | ] 15 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/scatter.vl.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": {"sequence": {"start": 0, "stop": 50, "as": "x"}}, 3 | "transform": [{"calculate": "sin(datum.x)", "as": "y"}], 4 | "mark": "point", 5 | "encoding": { 6 | "x": {"field": "x", "type": "quantitative"}, 7 | "y": {"field": "y", "type": "quantitative"} 8 | }, 9 | "width": 400, 10 | "height": 200 11 | } 12 | -------------------------------------------------------------------------------- /altair_saver/__init__.py: -------------------------------------------------------------------------------- 1 | """Tools for saving altair charts""" 2 | from ._core import render, save, available_formats 3 | from .savers import Saver, BasicSaver, HTMLSaver, NodeSaver, SeleniumSaver 4 | 5 | __version__ = "0.4.0.dev0" 6 | __all__ = [ 7 | "available_formats", 8 | "render", 9 | "save", 10 | "Saver", 11 | "BasicSaver", 12 | "HTMLSaver", 13 | "NodeSaver", 14 | "SeleniumSaver", 15 | ] 16 | -------------------------------------------------------------------------------- /altair_saver/savers/_basic.py: -------------------------------------------------------------------------------- 1 | """A basic vega-lite saver""" 2 | from typing import Dict, List 3 | from altair_saver.savers import Saver 4 | from altair_saver._types import MimebundleContent 5 | 6 | 7 | class BasicSaver(Saver): 8 | """Basic chart output.""" 9 | 10 | valid_formats: Dict[str, List[str]] = { 11 | "vega": ["json", "vega"], 12 | "vega-lite": ["json", "vega-lite"], 13 | } 14 | 15 | def _serialize(self, fmt: str, content_type: str) -> MimebundleContent: 16 | return self._spec 17 | -------------------------------------------------------------------------------- /altair_saver/tests/test_entrypoint.py: -------------------------------------------------------------------------------- 1 | import altair as alt 2 | from altair import vega 3 | from altair_saver import render 4 | 5 | 6 | def test_entrypoint_exists(): 7 | assert "altair_saver" in alt.renderers.names() 8 | assert "altair_saver" in vega.renderers.names() 9 | 10 | 11 | def test_entrypoint_identity(): 12 | with alt.renderers.enable("altair_saver"): 13 | assert alt.renderers.get() is render 14 | 15 | with vega.renderers.enable("altair_saver"): 16 | assert vega.renderers.get() is render 17 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/bar.vl.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "values": [ 4 | {"a": "A", "b": 28}, 5 | {"a": "B", "b": 55}, 6 | {"a": "C", "b": 43}, 7 | {"a": "D", "b": 91}, 8 | {"a": "E", "b": 81}, 9 | {"a": "F", "b": 53}, 10 | {"a": "G", "b": 19}, 11 | {"a": "H", "b": 87}, 12 | {"a": "I", "b": 52} 13 | ] 14 | }, 15 | "mark": "bar", 16 | "encoding": { 17 | "x": {"field": "a", "type": "ordinal"}, 18 | "y": {"field": "b", "type": "quantitative"} 19 | } 20 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test : 2 | black . 3 | flake8 . 4 | mypy . 5 | rm -rf build 6 | python setup.py build &&\ 7 | cd build/lib &&\ 8 | python -m pytest --pyargs --doctest-modules altair_saver 9 | 10 | test-coverage: 11 | python setup.py build &&\ 12 | cd build/lib &&\ 13 | python -m pytest --pyargs --doctest-modules --cov=altair_saver --cov-report term altair_saver 14 | 15 | test-coverage-html: 16 | python setup.py build &&\ 17 | cd build/lib &&\ 18 | python -m pytest --pyargs --doctest-modules --cov=altair_saver --cov-report html altair_saver 19 | -------------------------------------------------------------------------------- /tools/generate.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | 5 | sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 6 | from altair_saver import save # noqa: E402 7 | 8 | testcases = os.path.join( 9 | os.path.dirname(__file__), "..", "altair_saver", "savers", "tests", "testcases" 10 | ) 11 | cases = sorted(set(f.split(".")[0] for f in os.listdir(testcases))) 12 | 13 | for name in sorted(cases): 14 | with open(os.path.join(testcases, f"{name}.vl.json")) as f: 15 | spec = json.load(f) 16 | 17 | for extension in ["svg", "png", "pdf", "vg.json"]: 18 | save(spec, os.path.join(testcases, f"{name}.{extension}")) 19 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | name: flake8-black-mypy 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: Set up Python 3.8 12 | uses: actions/setup-python@v1 13 | with: 14 | python-version: 3.8 15 | - name: Lint with flake8 16 | run: | 17 | pip install flake8 18 | flake8 . --statistics 19 | - name: Check formatting with black 20 | run: | 21 | pip install black 22 | black --check . 23 | - name: Check types with mypy 24 | run: | 25 | pip install mypy 26 | mypy . 27 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.8 3 | 4 | [mypy-altair.*] 5 | ignore_missing_imports = True 6 | 7 | [mypy-altair_data_server.*] 8 | ignore_missing_imports = True 9 | 10 | [mypy-altair_viewer.*] 11 | ignore_missing_imports = True 12 | 13 | [mypy-pandas.*] 14 | ignore_missing_imports = True 15 | 16 | [mypy-PIL.*] 17 | ignore_missing_imports = True 18 | 19 | [mypy-PyPDF2.*] 20 | ignore_missing_imports = True 21 | 22 | [mypy-_pytest.*] 23 | ignore_missing_imports = True 24 | 25 | [mypy-pytest.*] 26 | ignore_missing_imports = True 27 | 28 | [mypy-selenium.*] 29 | ignore_missing_imports = True 30 | 31 | [mypy-setuptools.*] 32 | ignore_missing_imports = True 33 | 34 | [mypy-xml.dom.*] 35 | ignore_missing_imports = True -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | 1. Update version to, e.g. 1.0.0 in ``altair_saver/__init__.py`` 2 | 3 | 2. Make sure ``CHANGES.md`` is up to date for the release 4 | 5 | 3. Commit change and push to master 6 | 7 | git add . -u 8 | git commit -m "MAINT: bump version to 1.0.0" 9 | git push upstream master 10 | 11 | 4. Tag the release: 12 | 13 | git tag -a v1.0.0 -m "version 1.0.0 release" 14 | git push upstream v1.0.0 15 | 16 | 5. Build source & wheel distributions 17 | 18 | rm -r dist build # clean old builds & distributions 19 | python setup.py sdist # create a source distribution 20 | python setup.py bdist_wheel # create a universal wheel 21 | 22 | 6. publish to PyPI (Requires correct PyPI owner permissions) 23 | 24 | twine upload dist/* 25 | 26 | 7. update version to, e.g. 1.1.0.dev0 in ``altair_saver/__init__.py`` 27 | 28 | 8. add a new changelog entry for the unreleased version 29 | 30 | 9. Commit change and push to master 31 | 32 | git add . -u 33 | git commit -m "MAINT: bump version to 1.1.0.dev0" 34 | git push origin master 35 | 36 | 10. Publish release on github, copying changelog entry. 37 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import Iterator, Tuple 4 | 5 | import pytest 6 | 7 | from altair_saver.savers import BasicSaver 8 | from altair_saver._utils import JSONDict 9 | 10 | 11 | def get_testcases() -> Iterator[Tuple[str, str, JSONDict]]: 12 | directory = os.path.join(os.path.dirname(__file__), "testcases") 13 | cases = set(f.split(".")[0] for f in os.listdir(directory)) 14 | for case in sorted(cases): 15 | for mode, filename in [ 16 | ("vega-lite", f"{case}.vl.json"), 17 | ("vega", f"{case}.vg.json"), 18 | ]: 19 | with open(os.path.join(directory, filename)) as f: 20 | spec = json.load(f) 21 | yield case, mode, spec 22 | 23 | 24 | @pytest.mark.parametrize("case, mode, spec", get_testcases()) 25 | def test_basic_saver(case: str, mode: str, spec: JSONDict) -> None: 26 | saver = BasicSaver(spec) 27 | bundle = saver.mimebundle([mode, "json"]) 28 | for output in bundle.values(): 29 | assert output == spec 30 | 31 | 32 | def test_bad_format() -> None: 33 | saver = BasicSaver({}) 34 | with pytest.raises(ValueError): 35 | saver.mimebundle("vega") 36 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [ '3.6', '3.7', '3.8' ] 11 | name: Python ${{ matrix.python-version }} 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v1 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Set Up Node 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: '10.x' 22 | - name: Set Up Chromedriver 23 | run: | 24 | sudo apt-get update 25 | sudo apt-get --only-upgrade install google-chrome-stable 26 | sudo apt-get -yqq install chromium-chromedriver 27 | - name: Set Up vega CLI 28 | run: | 29 | npm install -g vega-lite vega-cli canvas 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install pillow pypdf2 # optional test requirements 34 | pip install . 35 | - name: Test with pytest 36 | run: | 37 | pip install pytest 38 | pytest --doctest-modules altair_saver 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, Jake Vanderplas 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Altair Saver Change Log 2 | 3 | ## Version 0.4.0 (unreleased) 4 | 5 | - Added top-level ``available_formats()`` function, which returns the set of 6 | available formats. (#43) 7 | 8 | ## Version 0.3.1 9 | 10 | - Fix bug in detecting npm binary path (#42) 11 | 12 | ## Version 0.3.0 13 | 14 | ### Behavior changes 15 | 16 | - ``save()`` now returns the serialized chart if ``fp`` is not specified (#41). 17 | - ``fmt="json"`` now saves the input spec directly for both vega and vega-lite input. 18 | Additionally, the ``json`` format in ``render()`` outputs a JSON mimetype rather than 19 | a vega-lite mimetype (#34). 20 | - ``render()`` and ``save()`` with HTML format now have a ``standalone`` argument 21 | that defaults to True for ``save()`` and False for ``render()``, so that HTML 22 | output will work better in a variety of notebook frontends (#33). 23 | - HTML and Selenium output now respects embedding options set via 24 | ``alt.renderers.set_embed_options`` (#30, #31). 25 | 26 | ### Maintenance 27 | - much improved documentation & test coverage. 28 | 29 | ## Version 0.2.0 30 | 31 | ### Behavior changes 32 | - selenium: prefer chromedriver over geckodriver when both are available (#27) 33 | 34 | ### Bug Fixes 35 | - selenium: altair_saver respects altair themes (#22) 36 | - selenium: improve javascript-side error handling (#19) 37 | 38 | ## Version 0.1.0 39 | 40 | Initial release including: 41 | 42 | - top-level ``save()`` function 43 | - basic export (``.vl.json``, ``.html``) 44 | - Selenium-based export (``.vg.json``, ``.png``, ``.svg``) 45 | - Node-based export (``.vg.json``, ``.png``, ``.svg``, ``.pdf``) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import re 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | 11 | def read(path, encoding="utf-8"): 12 | path = os.path.join(os.path.dirname(__file__), path) 13 | with io.open(path, encoding=encoding) as fp: 14 | return fp.read() 15 | 16 | 17 | def get_install_requirements(path): 18 | content = read(path) 19 | return [req for req in content.split("\n") if req != "" and not req.startswith("#")] 20 | 21 | 22 | def version(path): 23 | """Obtain the packge version from a python file e.g. pkg/__init__.py 24 | 25 | See . 26 | """ 27 | version_file = read(path) 28 | version_match = re.search( 29 | r"""^__version__ = ['"]([^'"]*)['"]""", version_file, re.M 30 | ) 31 | if version_match: 32 | return version_match.group(1) 33 | raise RuntimeError("Unable to find version string.") 34 | 35 | 36 | HERE = os.path.abspath(os.path.dirname(__file__)) 37 | 38 | 39 | # From https://github.com/jupyterlab/jupyterlab/blob/master/setupbase.py, 40 | # BSD licensed 41 | def find_packages(top=HERE): 42 | """ 43 | Find all of the packages. 44 | """ 45 | packages = [] 46 | for d, dirs, _ in os.walk(top, followlinks=True): 47 | if os.path.exists(os.path.join(d, "__init__.py")): 48 | packages.append(os.path.relpath(d, top).replace(os.path.sep, ".")) 49 | elif d != top: 50 | # Do not look for packages in subfolders 51 | # if current is not a package 52 | dirs[:] = [] 53 | return packages 54 | 55 | 56 | setup( 57 | name="altair_saver", 58 | version=version("altair_saver/__init__.py"), 59 | description="Altair extension for saving charts to various formats.", 60 | long_description=read("README.md"), 61 | long_description_content_type="text/markdown", 62 | author="Jake VanderPlas", 63 | author_email="jakevdp@gmail.com", 64 | url="http://github.com/altair-viz/altair_saver/", 65 | download_url="http://github.com/altair-viz/altair_saver/", 66 | license="MIT", 67 | packages=find_packages(), 68 | include_package_data=True, 69 | install_requires=get_install_requirements("requirements.txt"), 70 | entry_points={ 71 | "altair.vegalite.v4.renderer": ["altair_saver=altair_saver:render"], 72 | "altair.vega.v5.renderer": ["altair_saver=altair_saver:render"], 73 | }, 74 | python_requires=">=3.6", 75 | classifiers=[ 76 | "Environment :: Console", 77 | "Intended Audience :: Science/Research", 78 | "License :: OSI Approved :: BSD License", 79 | "Natural Language :: English", 80 | "Programming Language :: Python :: 3.6", 81 | "Programming Language :: Python :: 3.7", 82 | "Programming Language :: Python :: 3.8", 83 | ], 84 | ) 85 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/test_node.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import os 4 | from typing import Any, Dict, IO, Iterator, Tuple 5 | 6 | from PIL import Image 7 | from PyPDF2 import PdfFileReader 8 | import pytest 9 | 10 | from altair_saver.savers import NodeSaver 11 | from altair_saver._utils import fmt_to_mimetype 12 | 13 | 14 | def get_testcases() -> Iterator[Tuple[str, Dict[str, Any]]]: 15 | directory = os.path.join(os.path.dirname(__file__), "testcases") 16 | cases = set(f.split(".")[0] for f in os.listdir(directory)) 17 | f: IO 18 | for case in sorted(cases): 19 | with open(os.path.join(directory, f"{case}.vl.json")) as f: 20 | vl = json.load(f) 21 | with open(os.path.join(directory, f"{case}.vg.json")) as f: 22 | vg = json.load(f) 23 | with open(os.path.join(directory, f"{case}.svg")) as f: 24 | svg = f.read() 25 | with open(os.path.join(directory, f"{case}.png"), "rb") as f: 26 | png = f.read() 27 | with open(os.path.join(directory, f"{case}.pdf"), "rb") as f: 28 | pdf = f.read() 29 | yield case, {"vega-lite": vl, "vega": vg, "svg": svg, "png": png, "pdf": pdf} 30 | 31 | 32 | def get_modes_and_formats() -> Iterator[Tuple[str, str]]: 33 | for mode in ["vega", "vega-lite"]: 34 | for fmt in NodeSaver.valid_formats[mode]: 35 | yield (mode, fmt) 36 | 37 | 38 | @pytest.mark.parametrize("name,data", get_testcases()) 39 | @pytest.mark.parametrize("mode, fmt", get_modes_and_formats()) 40 | def test_node_mimebundle(name: str, data: Any, mode: str, fmt: str) -> None: 41 | saver = NodeSaver(data[mode], mode=mode) 42 | mimetype, out = saver.mimebundle(fmt).popitem() 43 | assert mimetype == fmt_to_mimetype(fmt) 44 | if fmt == "png": 45 | assert isinstance(out, bytes) 46 | im = Image.open(io.BytesIO(out)) 47 | assert im.format == "PNG" 48 | 49 | im_expected = Image.open(io.BytesIO(data[fmt])) 50 | assert abs(im.size[0] - im_expected.size[0]) < 5 51 | assert abs(im.size[1] - im_expected.size[1]) < 5 52 | elif fmt == "pdf": 53 | assert isinstance(out, bytes) 54 | pdf = PdfFileReader(io.BytesIO(out)) 55 | box = pdf.getPage(0).mediaBox 56 | pdf_expected = PdfFileReader(io.BytesIO(data[fmt])) 57 | box_expected = pdf_expected.getPage(0).mediaBox 58 | 59 | assert abs(box.getWidth() - box_expected.getWidth()) < 5 60 | assert abs(box.getHeight() - box_expected.getHeight()) < 5 61 | elif fmt == "svg": 62 | assert isinstance(out, str) 63 | assert out.startswith(" None: 70 | fmt = "vega-lite" 71 | mode = "vega" 72 | saver = NodeSaver(data[mode], mode=mode) 73 | with pytest.raises(ValueError): 74 | saver.mimebundle(fmt) 75 | 76 | 77 | def test_enabled() -> None: 78 | assert NodeSaver.enabled() 79 | -------------------------------------------------------------------------------- /altair_saver/savers/_node.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import shutil 4 | from typing import Dict, List 5 | import warnings 6 | 7 | from altair_saver.savers import Saver 8 | from altair_saver._types import JSONDict, MimebundleContent 9 | from altair_saver._utils import check_output_with_stderr 10 | 11 | 12 | class ExecutableNotFound(RuntimeError): 13 | pass 14 | 15 | 16 | @functools.lru_cache(2) 17 | def npm_bin(global_: bool) -> str: 18 | """Locate the npm binary directory.""" 19 | npm = shutil.which("npm") 20 | if not npm: 21 | raise ExecutableNotFound("npm") 22 | cmd = [npm, "bin"] 23 | if global_: 24 | cmd.append("--global") 25 | return check_output_with_stderr(cmd).decode().strip() 26 | 27 | 28 | @functools.lru_cache(16) 29 | def exec_path(name: str) -> str: 30 | for path in [None, npm_bin(global_=True), npm_bin(global_=False)]: 31 | exc = shutil.which(name, path=path) 32 | if exc: 33 | return exc 34 | raise ExecutableNotFound(name) 35 | 36 | 37 | def vl2vg(spec: JSONDict) -> JSONDict: 38 | """Compile a Vega-Lite spec into a Vega spec.""" 39 | vl2vg = exec_path("vl2vg") 40 | vl_json = json.dumps(spec).encode() 41 | vg_json = check_output_with_stderr([vl2vg], input=vl_json) 42 | return json.loads(vg_json) 43 | 44 | 45 | def vg2png(spec: JSONDict) -> bytes: 46 | """Generate a PNG image from a Vega spec.""" 47 | vg2png = exec_path("vg2png") 48 | vg_json = json.dumps(spec).encode() 49 | return check_output_with_stderr([vg2png], input=vg_json) 50 | 51 | 52 | def vg2pdf(spec: JSONDict) -> bytes: 53 | """Generate a PDF image from a Vega spec.""" 54 | vg2pdf = exec_path("vg2pdf") 55 | vg_json = json.dumps(spec).encode() 56 | return check_output_with_stderr([vg2pdf], input=vg_json) 57 | 58 | 59 | def vg2svg(spec: JSONDict) -> str: 60 | """Generate an SVG image from a Vega spec.""" 61 | vg2svg = exec_path("vg2svg") 62 | vg_json = json.dumps(spec).encode() 63 | return check_output_with_stderr([vg2svg], input=vg_json).decode() 64 | 65 | 66 | class NodeSaver(Saver): 67 | 68 | valid_formats: Dict[str, List[str]] = { 69 | "vega": ["pdf", "png", "svg"], 70 | "vega-lite": ["pdf", "png", "svg", "vega"], 71 | } 72 | 73 | @classmethod 74 | def enabled(cls) -> bool: 75 | try: 76 | return bool(exec_path("vl2vg") and exec_path("vg2png")) 77 | except ExecutableNotFound: 78 | return False 79 | 80 | def _serialize(self, fmt: str, content_type: str) -> MimebundleContent: 81 | if self._embed_options: 82 | warnings.warn("embed_options are not supported for method='node'.") 83 | 84 | if self._mode not in ["vega", "vega-lite"]: 85 | raise ValueError("mode must be either 'vega' or 'vega-lite'") 86 | 87 | spec = self._spec 88 | 89 | if self._mode == "vega-lite": 90 | spec = vl2vg(spec) 91 | 92 | if fmt == "vega": 93 | return spec 94 | elif fmt == "png": 95 | return vg2png(spec) 96 | elif fmt == "svg": 97 | return vg2svg(spec) 98 | elif fmt == "pdf": 99 | return vg2pdf(spec) 100 | else: 101 | raise ValueError(f"Unrecognized format: {fmt!r}") 102 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/scatter.vg.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://vega.github.io/schema/vega/v5.json", 3 | "axes": [ 4 | { 5 | "domain": false, 6 | "grid": true, 7 | "gridScale": "y", 8 | "labels": false, 9 | "maxExtent": 0, 10 | "minExtent": 0, 11 | "orient": "bottom", 12 | "scale": "x", 13 | "tickCount": { 14 | "signal": "ceil(width/40)" 15 | }, 16 | "ticks": false, 17 | "zindex": 0 18 | }, 19 | { 20 | "domain": false, 21 | "grid": true, 22 | "gridScale": "x", 23 | "labels": false, 24 | "maxExtent": 0, 25 | "minExtent": 0, 26 | "orient": "left", 27 | "scale": "y", 28 | "tickCount": { 29 | "signal": "ceil(height/40)" 30 | }, 31 | "ticks": false, 32 | "zindex": 0 33 | }, 34 | { 35 | "grid": false, 36 | "labelFlush": true, 37 | "labelOverlap": true, 38 | "orient": "bottom", 39 | "scale": "x", 40 | "tickCount": { 41 | "signal": "ceil(width/40)" 42 | }, 43 | "title": "x", 44 | "zindex": 0 45 | }, 46 | { 47 | "grid": false, 48 | "labelOverlap": true, 49 | "orient": "left", 50 | "scale": "y", 51 | "tickCount": { 52 | "signal": "ceil(height/40)" 53 | }, 54 | "title": "y", 55 | "zindex": 0 56 | } 57 | ], 58 | "background": "white", 59 | "data": [ 60 | { 61 | "name": "source_0", 62 | "transform": [ 63 | { 64 | "as": "x", 65 | "start": 0, 66 | "stop": 50, 67 | "type": "sequence" 68 | }, 69 | { 70 | "as": "y", 71 | "expr": "sin(datum.x)", 72 | "type": "formula" 73 | }, 74 | { 75 | "expr": "isValid(datum[\"x\"]) && isFinite(+datum[\"x\"]) && isValid(datum[\"y\"]) && isFinite(+datum[\"y\"])", 76 | "type": "filter" 77 | } 78 | ] 79 | } 80 | ], 81 | "height": 200, 82 | "marks": [ 83 | { 84 | "encode": { 85 | "update": { 86 | "fill": { 87 | "value": "transparent" 88 | }, 89 | "opacity": { 90 | "value": 0.7 91 | }, 92 | "stroke": { 93 | "value": "#4c78a8" 94 | }, 95 | "x": { 96 | "field": "x", 97 | "scale": "x" 98 | }, 99 | "y": { 100 | "field": "y", 101 | "scale": "y" 102 | } 103 | } 104 | }, 105 | "from": { 106 | "data": "source_0" 107 | }, 108 | "name": "marks", 109 | "style": [ 110 | "point" 111 | ], 112 | "type": "symbol" 113 | } 114 | ], 115 | "padding": 5, 116 | "scales": [ 117 | { 118 | "domain": { 119 | "data": "source_0", 120 | "field": "x" 121 | }, 122 | "name": "x", 123 | "nice": true, 124 | "range": [ 125 | 0, 126 | { 127 | "signal": "width" 128 | } 129 | ], 130 | "type": "linear", 131 | "zero": true 132 | }, 133 | { 134 | "domain": { 135 | "data": "source_0", 136 | "field": "y" 137 | }, 138 | "name": "y", 139 | "nice": true, 140 | "range": [ 141 | { 142 | "signal": "height" 143 | }, 144 | 0 145 | ], 146 | "type": "linear", 147 | "zero": true 148 | } 149 | ], 150 | "style": "cell", 151 | "width": 400 152 | } -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/bar.vg.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://vega.github.io/schema/vega/v5.json", 3 | "axes": [ 4 | { 5 | "domain": false, 6 | "grid": true, 7 | "gridScale": "x", 8 | "labels": false, 9 | "maxExtent": 0, 10 | "minExtent": 0, 11 | "orient": "left", 12 | "scale": "y", 13 | "tickCount": { 14 | "signal": "ceil(height/40)" 15 | }, 16 | "ticks": false, 17 | "zindex": 0 18 | }, 19 | { 20 | "grid": false, 21 | "labelAlign": "right", 22 | "labelAngle": 270, 23 | "labelBaseline": "middle", 24 | "labelOverlap": true, 25 | "orient": "bottom", 26 | "scale": "x", 27 | "title": "a", 28 | "zindex": 0 29 | }, 30 | { 31 | "grid": false, 32 | "labelOverlap": true, 33 | "orient": "left", 34 | "scale": "y", 35 | "tickCount": { 36 | "signal": "ceil(height/40)" 37 | }, 38 | "title": "b", 39 | "zindex": 0 40 | } 41 | ], 42 | "background": "white", 43 | "data": [ 44 | { 45 | "name": "source_0", 46 | "values": [ 47 | { 48 | "a": "A", 49 | "b": 28 50 | }, 51 | { 52 | "a": "B", 53 | "b": 55 54 | }, 55 | { 56 | "a": "C", 57 | "b": 43 58 | }, 59 | { 60 | "a": "D", 61 | "b": 91 62 | }, 63 | { 64 | "a": "E", 65 | "b": 81 66 | }, 67 | { 68 | "a": "F", 69 | "b": 53 70 | }, 71 | { 72 | "a": "G", 73 | "b": 19 74 | }, 75 | { 76 | "a": "H", 77 | "b": 87 78 | }, 79 | { 80 | "a": "I", 81 | "b": 52 82 | } 83 | ] 84 | }, 85 | { 86 | "name": "data_0", 87 | "source": "source_0", 88 | "transform": [ 89 | { 90 | "expr": "isValid(datum[\"b\"]) && isFinite(+datum[\"b\"])", 91 | "type": "filter" 92 | } 93 | ] 94 | } 95 | ], 96 | "height": 200, 97 | "marks": [ 98 | { 99 | "encode": { 100 | "update": { 101 | "fill": { 102 | "value": "#4c78a8" 103 | }, 104 | "width": { 105 | "band": true, 106 | "scale": "x" 107 | }, 108 | "x": { 109 | "field": "a", 110 | "scale": "x" 111 | }, 112 | "y": { 113 | "field": "b", 114 | "scale": "y" 115 | }, 116 | "y2": { 117 | "scale": "y", 118 | "value": 0 119 | } 120 | } 121 | }, 122 | "from": { 123 | "data": "data_0" 124 | }, 125 | "name": "marks", 126 | "style": [ 127 | "bar" 128 | ], 129 | "type": "rect" 130 | } 131 | ], 132 | "padding": 5, 133 | "scales": [ 134 | { 135 | "domain": { 136 | "data": "data_0", 137 | "field": "a", 138 | "sort": true 139 | }, 140 | "name": "x", 141 | "paddingInner": 0.1, 142 | "paddingOuter": 0.05, 143 | "range": { 144 | "step": { 145 | "signal": "x_step" 146 | } 147 | }, 148 | "type": "band" 149 | }, 150 | { 151 | "domain": { 152 | "data": "data_0", 153 | "field": "b" 154 | }, 155 | "name": "y", 156 | "nice": true, 157 | "range": [ 158 | { 159 | "signal": "height" 160 | }, 161 | 0 162 | ], 163 | "type": "linear", 164 | "zero": true 165 | } 166 | ], 167 | "signals": [ 168 | { 169 | "name": "x_step", 170 | "value": 20 171 | }, 172 | { 173 | "name": "width", 174 | "update": "bandspace(domain('x').length, 0.1, 0.05) * x_step" 175 | } 176 | ], 177 | "style": "cell" 178 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Altair Saver 2 | 3 | [![github actions](https://github.com/altair-viz/altair_saver/workflows/build/badge.svg)](https://github.com/altair-viz/altair_saver/actions?query=workflow%3Abuild) 4 | [![github actions](https://github.com/altair-viz/altair_saver/workflows/lint/badge.svg)](https://github.com/altair-viz/altair_saver/actions?query=workflow%3Alint) 5 | [![code style black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/altair-viz/altair_saver/blob/master/AltairSaver.ipynb) 7 | 8 | 9 | This packge provides extensions to [Altair](http://altair-viz.github.io) for saving charts 10 | to a variety of output types. Supported output formats are: 11 | 12 | - ``.json``/``.vl.json``: Vega-Lite JSON specification 13 | - ``.vg.json``: Vega JSON specification 14 | - ``.html``: HTML output 15 | - ``.png``: PNG image 16 | - ``.svg``: SVG image 17 | - ``.pdf``: PDF image 18 | 19 | ## Usage 20 | The ``altair_saver`` library has a single public function, ``altair_saver.save()``. 21 | Given an Altair chart named ``chart``, you can use it as follows: 22 | ```python 23 | from altair_saver import save 24 | 25 | save(chart, "chart.vl.json") # Vega-Lite JSON specification 26 | save(chart, "chart.vg.json") # Vega JSON specification 27 | save(chart, "chart.html") # HTML document 28 | save(chart, "chart.html", inline=True) # HTML document with all JS code included inline 29 | save(chart, "chart.png") # PNG Image 30 | save(chart, "chart.svg") # SVG Image 31 | save(chart, "chart.pdf") # PDF Image 32 | ``` 33 | 34 | ### Renderer 35 | Additionally, altair_saver provides an [Altair Renderer](https://altair-viz.github.io/user_guide/display_frontends.html#altair-s-renderer-framework) 36 | entrypoint that can display the above outputs directly in Jupyter notebooks. 37 | For example, you can specify a vega-lite mimetype (supported by JupyterLab, nteract, and other 38 | platforms) with a PNG fallback for other frontends as follows: 39 | ```python 40 | alt.renderers.enable('altair_saver', ['vega-lite', 'png']) 41 | ``` 42 | 43 | ## Installation 44 | The ``altair_saver`` package can be installed with: 45 | ``` 46 | $ pip install altair_saver 47 | ``` 48 | Saving as ``vl.json`` and as ``html`` requires no additional setup. 49 | 50 | To install with conda, use 51 | ``` 52 | $ conda install -c conda-forge altair_saver 53 | ``` 54 | The conda package installs the *NodeJS* dependencies described below, so charts can be 55 | saved to ``png``, ``svg``, and ``pdf`` without additional setup. 56 | 57 | ### Additional Requirements 58 | 59 | Output to ``png``, ``svg``, and ``pdf`` requires execution of Javascript code, which 60 | ``altair_saver`` can do via one of two backends. 61 | 62 | #### Selenium 63 | The *selenium* backend supports the following formats: 64 | 65 | - `.vg.json` 66 | - `.png` 67 | - `.svg`. 68 | 69 | To be used, it requires the [Selenium](https://selenium.dev/selenium/docs/api/py/) Python package, 70 | and a properly configured installation of either [chromedriver](https://chromedriver.chromium.org/) or 71 | [geckodriver](https://firefox-source-docs.mozilla.org/testing/geckodriver/). 72 | 73 | On Linux systems, this can be setup as follows: 74 | ```bash 75 | $ pip install selenium 76 | $ apt-get install chromium-chromedriver 77 | ``` 78 | Using conda, the required packages can be installed as follows (a compatible version of 79 | [Google Chrome](https://www.google.com/chrome/) must be installed separately): 80 | ```bash 81 | $ conda install -c python-chromedriver-binary 82 | ``` 83 | Selenium supports [other browsers](https://selenium-python.readthedocs.io/installation.html) as well, 84 | but altair-saver is currently only tested with Chrome. 85 | 86 | #### NodeJS 87 | The *nodejs* backend supports the following formats: 88 | 89 | - `.vg.json` 90 | - `.png` 91 | - `.svg` 92 | - `.pdf` 93 | 94 | It requires [NodeJS](https://nodejs.org/), along with the [vega-lite](https://www.npmjs.com/package/vega-lite), 95 | [vega-cli](https://www.npmjs.com/package/vega-cli), and [canvas](https://www.npmjs.com/package/canvas) packages. 96 | 97 | First install NodeJS either by [direct download](https://nodejs.org/en/download/) or via a 98 | [package manager](https://nodejs.org/en/download/package-manager/), and then use the `npm` tool 99 | to install the required packages: 100 | ```bash 101 | $ npm install vega-lite vega-cli canvas 102 | ``` 103 | Using conda, node and the required packages can be installed as follows: 104 | ```bash 105 | $ conda install -c conda-forge vega-cli vega-lite-cli 106 | ``` 107 | These packages are included automatically when installing ``altair_saver`` via conda-forge. 108 | -------------------------------------------------------------------------------- /altair_saver/savers/_saver.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import json 3 | from typing import Dict, IO, Iterable, List, Optional, Union 4 | 5 | import altair as alt 6 | 7 | from altair_saver._types import Mimebundle, MimebundleContent, JSONDict 8 | from altair_saver._utils import ( 9 | extract_format, 10 | fmt_to_mimetype, 11 | infer_mode_from_spec, 12 | maybe_open, 13 | ) 14 | 15 | 16 | class Saver(metaclass=abc.ABCMeta): 17 | """ 18 | Base class for saving Altair charts. 19 | 20 | Subclasses should: 21 | - specify the valid_formats class attribute 22 | - override the _serialize() method 23 | """ 24 | 25 | # list of supported formats, or (mode, format) pairs. 26 | valid_formats: Dict[str, List[str]] = {"vega": [], "vega-lite": []} 27 | _spec: JSONDict 28 | _mode: str 29 | _embed_options: JSONDict 30 | _package_versions: Dict[str, str] 31 | 32 | def __init__( 33 | self, 34 | spec: JSONDict, 35 | mode: Optional[str] = None, 36 | embed_options: Optional[JSONDict] = None, 37 | vega_version: str = alt.VEGA_VERSION, 38 | vegalite_version: str = alt.VEGALITE_VERSION, 39 | vegaembed_version: str = alt.VEGAEMBED_VERSION, 40 | ): 41 | if mode is None: 42 | mode = infer_mode_from_spec(spec) 43 | if mode not in ["vega", "vega-lite"]: 44 | raise ValueError("mode must be either 'vega' or 'vega-lite'") 45 | self._spec = spec 46 | self._mode = mode 47 | self._embed_options = embed_options or {} 48 | self._package_versions = { 49 | "vega": vega_version, 50 | "vega-lite": vegalite_version, 51 | "vega-embed": vegaembed_version, 52 | } 53 | 54 | @abc.abstractmethod 55 | def _serialize(self, fmt: str, content_type: str) -> MimebundleContent: 56 | ... 57 | 58 | @classmethod 59 | def enabled(cls) -> bool: 60 | """Return true if this saver is enabled on the current system.""" 61 | return True 62 | 63 | def mimebundle(self, fmts: Union[str, Iterable[str]]) -> Mimebundle: 64 | """Return a mimebundle representation of the chart. 65 | 66 | Parameters 67 | ---------- 68 | fmts : list of strings 69 | A list of formats to include in the results. 70 | 71 | Returns 72 | ------- 73 | mimebundle : dict 74 | The chart's mimebundle representation. 75 | """ 76 | if isinstance(fmts, str): 77 | fmts = [fmts] 78 | bundle: Mimebundle = {} 79 | for fmt in fmts: 80 | if fmt not in self.valid_formats[self._mode]: 81 | raise ValueError( 82 | f"invalid fmt={fmt!r}; must be one of {self.valid_formats[self._mode]}." 83 | ) 84 | mimetype = fmt_to_mimetype( 85 | fmt, 86 | vega_version=self._package_versions["vega"], 87 | vegalite_version=self._package_versions["vega-lite"], 88 | ) 89 | bundle[mimetype] = self._serialize(fmt, "mimebundle") 90 | return bundle 91 | 92 | def save( 93 | self, fp: Optional[Union[IO, str]] = None, fmt: Optional[str] = None 94 | ) -> Optional[Union[str, bytes]]: 95 | """Save a chart to file 96 | 97 | Parameters 98 | ---------- 99 | fp : file or filename (optional) 100 | Location to save the result. For fmt in ["png", "pdf"], file must be binary. 101 | For fmt in ["svg", "vega", "vega-lite"], file must be text. If not specified, 102 | the serialized chart will be returned. 103 | fmt : string (optional) 104 | The format in which to save the chart. If not specified and fp is a string, 105 | fmt will be determined from the file extension. 106 | 107 | Returns 108 | ------- 109 | chart : string, bytes, or None 110 | If fp is None, the serialized chart is returned. 111 | If fp is specified, the return value is None. 112 | """ 113 | if fmt is None: 114 | if fp is None: 115 | raise ValueError("Must specify either `fp` or `fmt` when saving chart") 116 | fmt = extract_format(fp) 117 | if fmt not in self.valid_formats[self._mode]: 118 | raise ValueError(f"Got fmt={fmt}; expected one of {self.valid_formats}") 119 | 120 | content = self._serialize(fmt, "save") 121 | if fp is None: 122 | if isinstance(content, dict): 123 | return json.dumps(content) 124 | return content 125 | if isinstance(content, dict): 126 | with maybe_open(fp, "w") as f: 127 | json.dump(content, f, indent=2) 128 | elif isinstance(content, str): 129 | with maybe_open(fp, "w") as f: 130 | f.write(content) 131 | elif isinstance(content, bytes): 132 | with maybe_open(fp, "wb") as f: 133 | f.write(content) 134 | else: 135 | raise ValueError( 136 | f"Unrecognized content type: {type(content)} for fmt={fmt!r}" 137 | ) 138 | return None 139 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/test_selenium.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import os 4 | from typing import Any, Dict, IO, Iterator, Tuple 5 | from xml.dom import minidom 6 | 7 | import altair as alt 8 | import pandas as pd 9 | import pytest 10 | from PIL import Image 11 | 12 | from altair_saver.savers import SeleniumSaver, JavascriptError 13 | from altair_saver._utils import fmt_to_mimetype, internet_connected, JSONDict 14 | 15 | 16 | class _SVGImage: 17 | _svg: minidom.Element 18 | 19 | def __init__(self, svg_string: str): 20 | parsed = minidom.parseString(svg_string) 21 | self._svg = parsed.getElementsByTagName("svg")[0] 22 | 23 | @property 24 | def width(self) -> int: 25 | return int(self._svg.getAttribute("width")) 26 | 27 | @property 28 | def height(self) -> int: 29 | return int(self._svg.getAttribute("height")) 30 | 31 | 32 | @pytest.fixture(scope="module") 33 | def internet_ok(): 34 | return internet_connected() 35 | 36 | 37 | def get_testcases() -> Iterator[Tuple[str, Dict[str, Any]]]: 38 | directory = os.path.join(os.path.dirname(__file__), "testcases") 39 | cases = set(f.split(".")[0] for f in os.listdir(directory)) 40 | f: IO 41 | for case in sorted(cases): 42 | with open(os.path.join(directory, f"{case}.vl.json")) as f: 43 | vl = json.load(f) 44 | with open(os.path.join(directory, f"{case}.vg.json")) as f: 45 | vg = json.load(f) 46 | with open(os.path.join(directory, f"{case}.svg")) as f: 47 | svg = f.read() 48 | with open(os.path.join(directory, f"{case}.png"), "rb") as f: 49 | png = f.read() 50 | yield case, {"vega-lite": vl, "vega": vg, "svg": svg, "png": png} 51 | 52 | 53 | @pytest.fixture 54 | def spec() -> JSONDict: 55 | data = pd.DataFrame({"x": range(10), "y": range(10)}) 56 | return alt.Chart(data).mark_line().encode(x="x", y="y").to_dict() 57 | 58 | 59 | def get_modes_and_formats() -> Iterator[Tuple[str, str]]: 60 | for mode in ["vega", "vega-lite"]: 61 | for fmt in SeleniumSaver.valid_formats[mode]: 62 | yield (mode, fmt) 63 | 64 | 65 | @pytest.mark.parametrize("name,data", get_testcases()) 66 | @pytest.mark.parametrize("mode, fmt", get_modes_and_formats()) 67 | @pytest.mark.parametrize("offline", [True, False]) 68 | def test_selenium_mimebundle( 69 | name: str, 70 | data: Dict[str, Any], 71 | mode: str, 72 | fmt: str, 73 | offline: bool, 74 | internet_ok: bool, 75 | ) -> None: 76 | if not (offline or internet_ok): 77 | pytest.xfail("Internet not available") 78 | saver = SeleniumSaver(data[mode], mode=mode, offline=offline) 79 | if mode == "vega" and fmt == "vega-lite": 80 | with pytest.raises(ValueError): 81 | saver.mimebundle(fmt) 82 | return 83 | mimetype, out = saver.mimebundle(fmt).popitem() 84 | assert mimetype == fmt_to_mimetype(fmt) 85 | if fmt == "png": 86 | assert isinstance(out, bytes) 87 | im = Image.open(io.BytesIO(out)) 88 | assert im.format == "PNG" 89 | 90 | im_expected = Image.open(io.BytesIO(data[fmt])) 91 | assert abs(im.size[0] - im_expected.size[0]) < 5 92 | assert abs(im.size[1] - im_expected.size[1]) < 5 93 | elif fmt == "svg": 94 | assert out == data[fmt] 95 | else: 96 | assert out == data[fmt] 97 | 98 | 99 | @pytest.mark.parametrize("name,data", get_testcases()) 100 | def test_stop_and_start(name: str, data: Dict[str, Any]) -> None: 101 | saver = SeleniumSaver(data["vega-lite"]) 102 | bundle1 = saver.mimebundle("png") 103 | 104 | saver._stop_serving() 105 | assert saver._provider is None 106 | 107 | bundle2 = saver.mimebundle("png") 108 | assert bundle1 == bundle2 109 | 110 | 111 | def test_enabled() -> None: 112 | assert SeleniumSaver.enabled() 113 | 114 | 115 | def test_extract_error() -> None: 116 | saver = SeleniumSaver({}) 117 | with pytest.raises(JavascriptError) as err: 118 | saver._extract("png") 119 | assert "Invalid specification" in str(err.value) 120 | 121 | saver = SeleniumSaver({}, mode="vega") 122 | with pytest.raises(JavascriptError) as err: 123 | saver._extract("xxx") 124 | assert "Unrecognized format" in str(err.value) 125 | 126 | 127 | @pytest.mark.parametrize("fmt", ["png", "svg"]) 128 | @pytest.mark.parametrize( 129 | "kwds", 130 | [ 131 | {"scale_factor": 2}, 132 | {"embed_options": {"scaleFactor": 2}}, 133 | {"scale_factor": 3, "embed_options": {"scaleFactor": 2}}, 134 | ], 135 | ) 136 | def test_scale_factor(spec: JSONDict, fmt: str, kwds: Dict[str, Any]) -> None: 137 | saver1 = SeleniumSaver(spec) 138 | out1 = saver1.save(fmt=fmt) 139 | 140 | saver2 = SeleniumSaver(spec, **kwds) 141 | out2 = saver2.save(fmt=fmt) 142 | 143 | if fmt == "png": 144 | assert isinstance(out1, bytes) 145 | im1 = Image.open(io.BytesIO(out1)) 146 | assert im1.format == "PNG" 147 | 148 | assert isinstance(out2, bytes) 149 | im2 = Image.open(io.BytesIO(out2)) 150 | assert im2.format == "PNG" 151 | 152 | assert im2.size[0] == 2 * im1.size[0] 153 | assert im2.size[1] == 2 * im1.size[1] 154 | else: 155 | assert isinstance(out1, str) 156 | im1 = _SVGImage(out1) 157 | 158 | assert isinstance(out2, str) 159 | im2 = _SVGImage(out2) 160 | 161 | assert im2.width == 2 * im1.width 162 | assert im2.height == 2 * im1.height 163 | -------------------------------------------------------------------------------- /altair_saver/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import http 2 | import io 3 | import socket 4 | import subprocess 5 | import tempfile 6 | 7 | import pytest 8 | from _pytest.capture import SysCaptureBinary 9 | 10 | from altair_saver._types import JSONDict 11 | from altair_saver._utils import ( 12 | extract_format, 13 | fmt_to_mimetype, 14 | infer_mode_from_spec, 15 | internet_connected, 16 | maybe_open, 17 | mimetype_to_fmt, 18 | temporary_filename, 19 | check_output_with_stderr, 20 | ) 21 | 22 | 23 | @pytest.mark.parametrize("connected", [True, False]) 24 | def test_internet_connected(monkeypatch, connected: bool) -> None: 25 | def request(*args, **kwargs): 26 | if not connected: 27 | raise socket.gaierror("error") 28 | 29 | monkeypatch.setattr(http.client.HTTPConnection, "request", request) 30 | assert internet_connected() is connected 31 | 32 | 33 | @pytest.mark.parametrize( 34 | "ext,fmt", 35 | [ 36 | ("json", "json"), 37 | ("html", "html"), 38 | ("png", "png"), 39 | ("pdf", "pdf"), 40 | ("svg", "svg"), 41 | ("vg.json", "vega"), 42 | ("vl.json", "vega-lite"), 43 | ], 44 | ) 45 | @pytest.mark.parametrize("use_filename", [True, False]) 46 | def test_extract_format(ext: str, fmt: str, use_filename: bool) -> None: 47 | if use_filename: 48 | filename = f"chart.{ext}" 49 | assert extract_format(filename) == fmt 50 | else: 51 | with tempfile.NamedTemporaryFile(suffix=f".{ext}") as fp: 52 | assert extract_format(fp) == fmt 53 | 54 | 55 | def test_extract_format_failure() -> None: 56 | fp = io.StringIO() 57 | with pytest.raises(ValueError) as err: 58 | extract_format(fp) 59 | assert f"Cannot infer format from {fp}" in str(err.value) 60 | 61 | 62 | @pytest.mark.parametrize("mode", ["w", "wb"]) 63 | def test_maybe_open_filename(mode: str) -> None: 64 | content_raw = "testing maybe_open with filename\n" 65 | content = content_raw.encode() if "b" in mode else content_raw 66 | 67 | with temporary_filename() as filename: 68 | with maybe_open(filename, mode) as f: 69 | f.write(content) 70 | with open(filename, "rb" if "b" in mode else "r") as f: 71 | assert f.read() == content 72 | 73 | 74 | @pytest.mark.parametrize("mode", ["w", "wb"]) 75 | def test_maybe_open_fileobj(mode: str) -> None: 76 | content_raw = "testing maybe_open with file object\n" 77 | content = content_raw.encode() if "b" in mode else content_raw 78 | 79 | with tempfile.NamedTemporaryFile(mode + "+") as fp: 80 | with maybe_open(fp, mode) as f: 81 | f.write(content) 82 | fp.seek(0) 83 | assert fp.read() == content 84 | 85 | 86 | def test_maybe_open_errors() -> None: 87 | with pytest.raises(ValueError) as err: 88 | with maybe_open(io.BytesIO(), "w"): 89 | pass 90 | assert "fp is opened in binary mode" in str(err.value) 91 | assert "mode='w'" in str(err.value) 92 | 93 | with pytest.raises(ValueError) as err: 94 | with maybe_open(io.StringIO(), "wb"): 95 | pass 96 | assert "fp is opened in text mode" in str(err.value) 97 | assert "mode='wb'" in str(err.value) 98 | 99 | 100 | @pytest.mark.parametrize( 101 | "fmt", ["json", "vega-lite", "vega", "html", "pdf", "png", "svg"] 102 | ) 103 | def test_fmt_mimetype(fmt: str) -> None: 104 | mimetype = fmt_to_mimetype(fmt) 105 | fmt_out = mimetype_to_fmt(mimetype) 106 | assert fmt == fmt_out 107 | 108 | 109 | def test_fmt_mimetype_error() -> None: 110 | with pytest.raises(ValueError) as err: 111 | fmt_to_mimetype("bad-fmt") 112 | assert "Unrecognized fmt='bad-fmt'" in str(err.value) 113 | 114 | with pytest.raises(ValueError) as err: 115 | mimetype_to_fmt("bad-mimetype") 116 | assert "Unrecognized mimetype='bad-mimetype'" in str(err.value) 117 | 118 | 119 | @pytest.mark.parametrize( 120 | "mode, spec", 121 | [ 122 | ("vega-lite", {"$schema": "https://vega.github.io/schema/vega-lite/v4.json"}), 123 | ("vega-lite", {"$schema": None, "data": {}, "mark": {}, "encodings": {}}), 124 | ("vega-lite", {"data": {}, "mark": {}, "encodings": {}}), 125 | ("vega-lite", {}), 126 | ("vega", {"$schema": "https://vega.github.io/schema/vega/v5.json"}), 127 | ("vega", {"$schema": None, "data": [], "signals": [], "marks": []}), 128 | ("vega", {"data": [], "signals": [], "marks": []}), 129 | ], 130 | ) 131 | def test_infer_mode_from_spec(mode: str, spec: JSONDict) -> None: 132 | assert infer_mode_from_spec(spec) == mode 133 | 134 | 135 | def test_check_output_with_stderr(capsysbinary: SysCaptureBinary): 136 | output = check_output_with_stderr( 137 | r'>&2 echo "the error" && echo "the output"', shell=True 138 | ) 139 | assert output == b"the output\n" 140 | captured = capsysbinary.readouterr() 141 | assert captured.out == b"" 142 | assert captured.err == b"the error\n" 143 | 144 | 145 | def test_check_output_with_stderr_exit_1(capsysbinary: SysCaptureBinary): 146 | with pytest.raises(subprocess.CalledProcessError) as err: 147 | output = check_output_with_stderr( 148 | r'>&2 echo "the error" && echo "the output" && exit 1', shell=True 149 | ) 150 | assert output == b"the output\n" 151 | assert err.value.stderr == b"the error\n" 152 | captured = capsysbinary.readouterr() 153 | assert captured.out == b"" 154 | assert captured.err == b"the error\n" 155 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/test_html.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import os 4 | from typing import Any, Dict, IO, Iterator, Optional, Tuple 5 | 6 | from altair_data_server import Provider 7 | from PIL import Image 8 | import pytest 9 | import selenium.webdriver 10 | from selenium.webdriver.remote.webdriver import WebDriver 11 | 12 | from altair_saver._utils import internet_connected 13 | from altair_saver.savers import HTMLSaver 14 | 15 | 16 | CDN_URL = "https://cdn.jsdelivr.net" 17 | 18 | 19 | @pytest.fixture(scope="module") 20 | def internet_ok(): 21 | return internet_connected() 22 | 23 | 24 | @pytest.fixture(scope="module") 25 | def provider() -> Iterator[Provider]: 26 | provider = Provider() 27 | yield provider 28 | provider.stop() 29 | 30 | 31 | @pytest.fixture(scope="module") 32 | def driver() -> Iterator[WebDriver]: 33 | options = selenium.webdriver.chrome.options.Options() 34 | options.add_argument("--headless") 35 | if hasattr(os, "geteuid") and (os.geteuid() == 0): 36 | options.add_argument("--no-sandbox") 37 | driver = selenium.webdriver.Chrome(options=options) 38 | yield driver 39 | driver.quit() 40 | 41 | 42 | def get_testcases() -> Iterator[Tuple[str, Dict[str, Any]]]: 43 | directory = os.path.join(os.path.dirname(__file__), "testcases") 44 | cases = set(f.split(".")[0] for f in os.listdir(directory)) 45 | f: IO 46 | for case in sorted(cases): 47 | with open(os.path.join(directory, f"{case}.vl.json")) as f: 48 | vl = json.load(f) 49 | with open(os.path.join(directory, f"{case}.png"), "rb") as f: 50 | png = f.read() 51 | yield case, {"vega-lite": vl, "png": png} 52 | 53 | 54 | @pytest.mark.parametrize("inline", [True, False]) 55 | @pytest.mark.parametrize("embed_options", [None, {"theme": "dark"}]) 56 | @pytest.mark.parametrize("case, data", get_testcases()) 57 | def test_html_save( 58 | case: str, data: Dict[str, Any], embed_options: Optional[dict], inline: bool 59 | ) -> None: 60 | saver = HTMLSaver(data["vega-lite"], inline=inline, embed_options=embed_options) 61 | fp = io.StringIO() 62 | saver.save(fp, "html") 63 | html = fp.getvalue() 64 | assert isinstance(html, str) 65 | assert html.strip().startswith("") 66 | assert json.dumps(data["vega-lite"]) in html 67 | assert f"const embedOpt = {json.dumps(embed_options or {})}" in html 68 | 69 | if inline: 70 | assert CDN_URL not in html 71 | else: 72 | assert CDN_URL in html 73 | 74 | 75 | @pytest.mark.parametrize("embed_options", [None, {"theme": "dark"}]) 76 | @pytest.mark.parametrize("case, data", get_testcases()) 77 | def test_html_mimebundle( 78 | case: str, data: Dict[str, Any], embed_options: Optional[dict], 79 | ) -> None: 80 | saver = HTMLSaver(data["vega-lite"], embed_options=embed_options) 81 | bundle = saver.mimebundle("html") 82 | assert bundle.keys() == {"text/html"} 83 | 84 | html = bundle["text/html"] 85 | assert isinstance(html, str) 86 | 87 | assert html.strip().startswith(" None: 95 | saver = HTMLSaver({}) 96 | with pytest.raises(ValueError): 97 | saver.mimebundle("vega") 98 | 99 | 100 | @pytest.mark.parametrize("case, data", get_testcases()) 101 | @pytest.mark.parametrize("inline", [True, False]) 102 | def test_html_save_rendering( 103 | provider: Provider, 104 | driver: WebDriver, 105 | case: str, 106 | data: Dict[str, Any], 107 | inline: bool, 108 | internet_ok: bool, 109 | ) -> None: 110 | if not (inline or internet_ok): 111 | pytest.xfail("Internet connection not available") 112 | saver = HTMLSaver(data["vega-lite"], inline=inline) 113 | fp = io.StringIO() 114 | saver.save(fp, "html") 115 | html = fp.getvalue() 116 | 117 | resource = provider.create(content=html, extension="html") 118 | driver.set_window_size(800, 600) 119 | driver.get(resource.url) 120 | element = driver.find_element_by_class_name("vega-visualization") 121 | 122 | png = driver.get_screenshot_as_png() 123 | im = Image.open(io.BytesIO(png)) 124 | left = element.location["x"] 125 | top = element.location["y"] 126 | right = element.location["x"] + element.size["width"] 127 | bottom = element.location["y"] + element.size["height"] 128 | im = im.crop((left, top, right, bottom)) 129 | 130 | im_expected = Image.open(io.BytesIO(data["png"])) 131 | assert abs(im.size[0] - im_expected.size[0]) < 40 132 | assert abs(im.size[1] - im_expected.size[1]) < 40 133 | 134 | 135 | @pytest.mark.parametrize("requirejs", [True, False]) 136 | @pytest.mark.parametrize("case, data", get_testcases()) 137 | def test_html_mimebundle_rendering( 138 | provider: Provider, 139 | driver: WebDriver, 140 | case: str, 141 | data: Dict[str, Any], 142 | requirejs: bool, 143 | internet_ok: bool, 144 | ) -> None: 145 | if not internet_ok: 146 | pytest.xfail("Internet connection not available") 147 | saver = HTMLSaver(data["vega-lite"]) 148 | bundle = saver.mimebundle("html") 149 | html = bundle["text/html"] 150 | assert isinstance(html, str) 151 | 152 | if requirejs: 153 | html = f""" 154 | 155 | 156 | {html} 157 | 158 | """ 159 | else: 160 | html = f"{html}" 161 | 162 | resource = provider.create(content=html, extension="html") 163 | driver.set_window_size(800, 600) 164 | driver.get(resource.url) 165 | element = driver.find_element_by_class_name("vega-visualization") 166 | 167 | png = driver.get_screenshot_as_png() 168 | im = Image.open(io.BytesIO(png)) 169 | left = element.location["x"] 170 | top = element.location["y"] 171 | right = element.location["x"] + element.size["width"] 172 | bottom = element.location["y"] + element.size["height"] 173 | im = im.crop((left, top, right, bottom)) 174 | 175 | im_expected = Image.open(io.BytesIO(data["png"])) 176 | assert abs(im.size[0] - im_expected.size[0]) < 40 177 | assert abs(im.size[1] - im_expected.size[1]) < 40 178 | -------------------------------------------------------------------------------- /altair_saver/_utils.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from http import client 3 | import io 4 | import os 5 | import socket 6 | import subprocess 7 | import sys 8 | import tempfile 9 | from typing import IO, Iterator, List, Optional, Union 10 | 11 | import altair as alt 12 | from altair_saver._types import JSONDict 13 | 14 | 15 | def internet_connected(test_url: str = "cdn.jsdelivr.net") -> bool: 16 | """Return True if web connection is available.""" 17 | conn = client.HTTPConnection(test_url, timeout=5) 18 | try: 19 | conn.request("HEAD", "/") 20 | except socket.gaierror: 21 | return False 22 | else: 23 | return True 24 | finally: 25 | conn.close() 26 | 27 | 28 | def fmt_to_mimetype( 29 | fmt: str, 30 | vegalite_version: str = alt.VEGALITE_VERSION, 31 | vega_version: str = alt.VEGA_VERSION, 32 | ) -> str: 33 | """Get a mimetype given a format string.""" 34 | if fmt == "vega-lite": 35 | return "application/vnd.vegalite.v{}+json".format( 36 | vegalite_version.split(".")[0] 37 | ) 38 | elif fmt == "vega": 39 | return "application/vnd.vega.v{}+json".format(vega_version.split(".")[0]) 40 | elif fmt == "json": 41 | return "application/json" 42 | elif fmt == "pdf": 43 | return "application/pdf" 44 | elif fmt == "html": 45 | return "text/html" 46 | elif fmt == "png": 47 | return "image/png" 48 | elif fmt == "svg": 49 | return "image/svg+xml" 50 | else: 51 | raise ValueError(f"Unrecognized fmt={fmt!r}") 52 | 53 | 54 | def mimetype_to_fmt(mimetype: str) -> str: 55 | """Get a format string given a mimetype.""" 56 | if mimetype.startswith("application/vnd.vegalite"): 57 | return "vega-lite" 58 | elif mimetype.startswith("application/vnd.vega"): 59 | return "vega" 60 | elif mimetype == "application/json": 61 | return "json" 62 | elif mimetype == "application/pdf": 63 | return "pdf" 64 | elif mimetype == "text/html": 65 | return "html" 66 | elif mimetype == "image/png": 67 | return "png" 68 | elif mimetype == "image/svg+xml": 69 | return "svg" 70 | else: 71 | raise ValueError(f"Unrecognized mimetype={mimetype!r}") 72 | 73 | 74 | def infer_mode_from_spec(spec: JSONDict) -> str: 75 | """Given a spec, return the inferred mode. 76 | 77 | This uses the '$schema' value if present, and otherwise tries to 78 | infer the type based on top-level keys. If both approaches fail, 79 | it returns "vega-lite" by default. 80 | 81 | Parameters 82 | ---------- 83 | spec : dict 84 | The vega or vega-lite specification 85 | 86 | Returns 87 | ------- 88 | mode : str 89 | Either "vega" or "vega-lite" 90 | """ 91 | if "$schema" in spec: 92 | schema = spec["$schema"] 93 | if not isinstance(schema, str): 94 | pass 95 | elif "/vega-lite/" in schema: 96 | return "vega-lite" 97 | elif "/vega/" in schema: 98 | return "vega" 99 | 100 | # Check several vega-only top-level properties. 101 | for key in ["axes", "legends", "marks", "projections", "scales", "signals"]: 102 | if key in spec: 103 | return "vega" 104 | 105 | return "vega-lite" 106 | 107 | 108 | @contextlib.contextmanager 109 | def temporary_filename( 110 | suffix: Optional[str] = None, 111 | prefix: Optional[str] = None, 112 | dir: Optional[str] = None, 113 | text: bool = False, 114 | ) -> Iterator[str]: 115 | """Create and clean-up a temporary file 116 | 117 | Arguments are passed directly to tempfile.mkstemp() 118 | 119 | We could use tempfile.NamedTemporaryFile here, but that causes issues on 120 | windows (see https://bugs.python.org/issue14243). 121 | """ 122 | filedescriptor, filename = tempfile.mkstemp( 123 | suffix=suffix, prefix=prefix, dir=dir, text=text 124 | ) 125 | os.close(filedescriptor) 126 | 127 | try: 128 | yield filename 129 | finally: 130 | if os.path.exists(filename): 131 | os.remove(filename) 132 | 133 | 134 | @contextlib.contextmanager 135 | def maybe_open(fp: Union[IO, str], mode: str = "w") -> Iterator[IO]: 136 | """Context manager to write to a file specified by filename or file-like object""" 137 | if isinstance(fp, str): 138 | with open(fp, mode) as f: 139 | yield f 140 | elif isinstance(fp, io.TextIOBase) and "b" in mode: 141 | raise ValueError( 142 | f"fp is opened in text mode; mode={mode!r} requires binary mode." 143 | ) 144 | elif isinstance(fp, io.BufferedIOBase) and "b" not in mode: 145 | raise ValueError( 146 | f"fp is opened in binary mode; mode={mode!r} requires text mode." 147 | ) 148 | else: 149 | yield fp 150 | 151 | 152 | def extract_format(fp: Union[IO, str]) -> str: 153 | """Extract the altair_saver output format from a file or filename.""" 154 | filename: Optional[str] 155 | if isinstance(fp, str): 156 | filename = fp 157 | else: 158 | filename = getattr(fp, "name", None) 159 | if filename is None: 160 | raise ValueError(f"Cannot infer format from {fp}") 161 | if filename.endswith(".vg.json"): 162 | return "vega" 163 | elif filename.endswith(".vl.json"): 164 | return "vega-lite" 165 | else: 166 | return filename.split(".")[-1] 167 | 168 | 169 | def check_output_with_stderr( 170 | cmd: Union[str, List[str]], shell: bool = False, input: Optional[bytes] = None 171 | ) -> bytes: 172 | """Run a command in a subprocess, printing stderr to sys.stderr. 173 | 174 | Arguments are passed directly to subprocess.run(). 175 | 176 | This is important because subprocess stderr in notebooks is printed to the 177 | terminal rather than the notebook. 178 | """ 179 | try: 180 | ps = subprocess.run( 181 | cmd, 182 | shell=shell, 183 | check=True, 184 | stdout=subprocess.PIPE, 185 | stderr=subprocess.PIPE, 186 | input=input, 187 | ) 188 | except subprocess.CalledProcessError as err: 189 | if err.stderr: 190 | sys.stderr.write(err.stderr.decode()) 191 | sys.stderr.flush() 192 | raise 193 | else: 194 | if ps.stderr: 195 | sys.stderr.write(ps.stderr.decode()) 196 | sys.stderr.flush() 197 | return ps.stdout 198 | -------------------------------------------------------------------------------- /altair_saver/tests/test_core.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | from typing import Any, Dict, List, Union, Type 4 | 5 | import altair as alt 6 | import pandas as pd 7 | import pytest 8 | 9 | from altair_saver import ( 10 | available_formats, 11 | save, 12 | render, 13 | BasicSaver, 14 | HTMLSaver, 15 | NodeSaver, 16 | Saver, 17 | SeleniumSaver, 18 | ) 19 | from altair_saver._utils import ( 20 | JSONDict, 21 | fmt_to_mimetype, 22 | mimetype_to_fmt, 23 | temporary_filename, 24 | ) 25 | 26 | FORMATS = ["html", "pdf", "png", "svg", "vega", "vega-lite", "json"] 27 | 28 | 29 | def check_output(out: Union[str, bytes], fmt: str) -> None: 30 | """Do basic checks on output to confirm correct type, and non-empty.""" 31 | if fmt in ["png", "pdf"]: 32 | assert isinstance(out, bytes) 33 | elif fmt in ["vega", "vega-lite"]: 34 | assert isinstance(out, str) 35 | dct = json.loads(out) 36 | assert len(dct) > 0 37 | else: 38 | assert isinstance(out, str) 39 | assert len(out) > 0 40 | 41 | 42 | @pytest.fixture 43 | def chart() -> alt.Chart: 44 | data = pd.DataFrame({"x": range(10), "y": range(10)}) 45 | return alt.Chart(data).mark_line().encode(x="x", y="y") 46 | 47 | 48 | @pytest.fixture 49 | def spec(chart: alt.Chart) -> JSONDict: 50 | return chart.to_dict() 51 | 52 | 53 | @pytest.mark.parametrize("fmt", FORMATS) 54 | def test_save_chart(chart: alt.TopLevelMixin, fmt: str) -> None: 55 | fp: Union[io.BytesIO, io.StringIO] 56 | if fmt in ["png", "pdf"]: 57 | fp = io.BytesIO() 58 | else: 59 | fp = io.StringIO() 60 | 61 | result = save(chart, fp, fmt=fmt) 62 | assert result is None 63 | check_output(fp.getvalue(), fmt) 64 | 65 | 66 | @pytest.mark.parametrize("fmt", FORMATS) 67 | def test_save_spec(spec: JSONDict, fmt: str) -> None: 68 | fp: Union[io.BytesIO, io.StringIO] 69 | if fmt in ["png", "pdf"]: 70 | fp = io.BytesIO() 71 | else: 72 | fp = io.StringIO() 73 | 74 | result = save(spec, fp, fmt=fmt) 75 | assert result is None 76 | check_output(fp.getvalue(), fmt) 77 | 78 | 79 | @pytest.mark.parametrize("fmt", FORMATS) 80 | def test_save_return_value(spec: JSONDict, fmt: str) -> None: 81 | fp: Union[io.BytesIO, io.StringIO] 82 | result = save(spec, fmt=fmt) 83 | assert result is not None 84 | check_output(result, fmt) 85 | 86 | 87 | @pytest.mark.parametrize("method", ["node", "selenium", BasicSaver, HTMLSaver]) 88 | @pytest.mark.parametrize("fmt", FORMATS) 89 | def test_save_chart_method( 90 | spec: JSONDict, fmt: str, method: Union[str, Type[Saver]] 91 | ) -> None: 92 | fp: Union[io.BytesIO, io.StringIO] 93 | if fmt in ["png", "pdf"]: 94 | fp = io.BytesIO() 95 | else: 96 | fp = io.StringIO() 97 | 98 | valid_formats: Dict[str, List[str]] = {} 99 | if method == "node": 100 | valid_formats = NodeSaver.valid_formats 101 | elif method == "selenium": 102 | valid_formats = SeleniumSaver.valid_formats 103 | elif isinstance(method, type): 104 | valid_formats = method.valid_formats 105 | else: 106 | raise ValueError(f"unrecognized method: {method}") 107 | 108 | if fmt not in valid_formats["vega-lite"]: 109 | with pytest.raises(ValueError): 110 | save(spec, fp, fmt=fmt, method=method) 111 | else: 112 | save(spec, fp, fmt=fmt, method=method) 113 | check_output(fp.getvalue(), fmt) 114 | 115 | 116 | @pytest.mark.parametrize("inline", [True, False]) 117 | def test_html_inline(spec: JSONDict, inline: bool) -> None: 118 | fp = io.StringIO() 119 | save(spec, fp, fmt="html", inline=inline) 120 | html = fp.getvalue() 121 | 122 | cdn_url = "https://cdn.jsdelivr.net" 123 | if inline: 124 | assert cdn_url not in html 125 | else: 126 | assert cdn_url in html 127 | 128 | 129 | def test_render_spec(spec: JSONDict) -> None: 130 | bundle = render(spec, fmts=FORMATS) 131 | assert len(bundle) == len(FORMATS) 132 | for mimetype, content in bundle.items(): 133 | fmt = mimetype_to_fmt(mimetype) 134 | if isinstance(content, dict): 135 | check_output(json.dumps(content), fmt) 136 | else: 137 | check_output(content, fmt) 138 | 139 | 140 | def test_infer_mode(spec: JSONDict) -> None: 141 | mimetype, vg_spec = render(spec, "vega").popitem() 142 | assert mimetype == fmt_to_mimetype("vega") 143 | 144 | mimetype, vl_svg = render(spec, "svg").popitem() 145 | assert mimetype == fmt_to_mimetype("svg") 146 | 147 | mimetype, vg_svg = render(vg_spec, "svg").popitem() 148 | assert mimetype == fmt_to_mimetype("svg") 149 | 150 | assert vl_svg == vg_svg 151 | 152 | 153 | @pytest.mark.parametrize("embed_options", [{}, {"padding": 20}]) 154 | def test_embed_options_render_html(spec: JSONDict, embed_options: JSONDict) -> None: 155 | with alt.renderers.set_embed_options(**embed_options): 156 | mimetype, html = render(spec, "html").popitem() 157 | assert mimetype == "text/html" 158 | assert json.dumps(embed_options or {}) in html 159 | 160 | 161 | @pytest.mark.parametrize("inline", [True, False]) 162 | @pytest.mark.parametrize("embed_options", [{}, {"padding": 20}]) 163 | def test_embed_options_save_html( 164 | spec: JSONDict, inline: bool, embed_options: JSONDict 165 | ) -> None: 166 | fp = io.StringIO() 167 | with alt.renderers.set_embed_options(**embed_options): 168 | save(spec, fp, "html", inline=inline) 169 | html = fp.getvalue() 170 | assert f"const embedOpt = {json.dumps(embed_options or {})};" in html 171 | 172 | 173 | def test_embed_options_save_html_override(spec: JSONDict) -> None: 174 | fp = io.StringIO() 175 | embed_options: JSONDict = {"renderer": "svg"} 176 | alt_embed_options: JSONDict = {"padding": 20} 177 | with alt.renderers.set_embed_options(**alt_embed_options): 178 | save(spec, fp, "html", embed_options=embed_options) 179 | html = fp.getvalue() 180 | assert f"const embedOpt = {json.dumps(embed_options)};" in html 181 | 182 | 183 | def test_infer_format(spec: JSONDict) -> None: 184 | with temporary_filename(suffix=".html") as filename: 185 | with open(filename, "w") as fp: 186 | save(spec, fp) 187 | with open(filename, "r") as fp: 188 | html = fp.read() 189 | assert html.strip().startswith("") 190 | 191 | 192 | @pytest.mark.parametrize("mode", ["vega", "vega-lite"]) 193 | def test_available_formats(monkeypatch: Any, mode: str) -> None: 194 | monkeypatch.setattr(NodeSaver, "enabled", lambda: False) 195 | monkeypatch.setattr(SeleniumSaver, "enabled", lambda: False) 196 | expected = {mode, "json", "html"} 197 | assert available_formats(mode) == expected 198 | 199 | monkeypatch.setattr(SeleniumSaver, "enabled", lambda: True) 200 | expected |= {"vega", "png", "svg"} 201 | assert available_formats(mode) == expected 202 | 203 | monkeypatch.setattr(NodeSaver, "enabled", lambda: True) 204 | expected |= {"pdf"} 205 | assert available_formats(mode) == expected 206 | -------------------------------------------------------------------------------- /altair_saver/savers/_html.py: -------------------------------------------------------------------------------- 1 | """An HTML altair saver""" 2 | import json 3 | from typing import Dict, List, Optional 4 | import uuid 5 | import warnings 6 | 7 | import altair as alt 8 | from altair_saver.savers import Saver 9 | from altair_saver._types import JSONDict, MimebundleContent 10 | from altair_viewer import get_bundled_script 11 | 12 | # This is the basic HTML template for embedding charts on a page. 13 | HTML_TEMPLATE = """ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 28 | 29 | 30 | """ 31 | 32 | # This is like the basic template, but includes vega javascript inline 33 | # so that the resulting file is not dependent on external resources. 34 | INLINE_HTML_TEMPLATE = """ 35 | 36 | 37 | 38 | 46 | 47 | 48 |
49 | 54 | 55 | 56 | """ 57 | 58 | # This is the HTML template that should be used in render(), because it 59 | # will display properly in a variety of notebook environments. It is 60 | # modeled off of Altair's default HTML display. 61 | RENDERER_HTML_TEMPLATE = """ 62 |
63 | 106 | """ 107 | 108 | 109 | CDN_URL = "https://cdn.jsdelivr.net/npm/{package}@{version}" 110 | 111 | 112 | class HTMLSaver(Saver): 113 | """Basic chart output.""" 114 | 115 | valid_formats: Dict[str, List[str]] = {"vega": ["html"], "vega-lite": ["html"]} 116 | _inline: bool 117 | _standalone: Optional[bool] 118 | 119 | def __init__( 120 | self, 121 | spec: JSONDict, 122 | mode: Optional[str] = None, 123 | embed_options: Optional[JSONDict] = None, 124 | vega_version: str = alt.VEGA_VERSION, 125 | vegalite_version: str = alt.VEGALITE_VERSION, 126 | vegaembed_version: str = alt.VEGAEMBED_VERSION, 127 | inline: bool = False, 128 | standalone: Optional[bool] = None, 129 | ) -> None: 130 | self._inline = inline 131 | self._standalone = standalone 132 | super().__init__( 133 | spec=spec, 134 | mode=mode, 135 | embed_options=embed_options, 136 | vega_version=vega_version, 137 | vegalite_version=vegalite_version, 138 | vegaembed_version=vegaembed_version, 139 | ) 140 | 141 | def _package_url(self, package: str) -> str: 142 | return CDN_URL.format(package=package, version=self._package_versions[package]) 143 | 144 | def _serialize(self, fmt: str, content_type: str) -> MimebundleContent: 145 | standalone = self._standalone 146 | if standalone is None: 147 | standalone = content_type == "save" 148 | 149 | output_div = f"vega-visualization-{uuid.uuid4().hex}" 150 | 151 | if not standalone: 152 | if self._inline: 153 | warnings.warn("inline ignored for non-standalone HTML.") 154 | return RENDERER_HTML_TEMPLATE.format( 155 | spec=json.dumps(self._spec), 156 | embed_options=json.dumps(self._embed_options), 157 | vega_url=self._package_url("vega"), 158 | vegalite_url=self._package_url("vega-lite"), 159 | vegaembed_url=self._package_url("vega-embed"), 160 | output_div=output_div, 161 | ) 162 | elif self._inline: 163 | return INLINE_HTML_TEMPLATE.format( 164 | spec=json.dumps(self._spec), 165 | embed_options=json.dumps(self._embed_options), 166 | vega_version=self._package_versions["vega"], 167 | vegalite_version=self._package_versions["vega-lite"], 168 | vegaembed_version=self._package_versions["vega-embed"], 169 | vega_script=get_bundled_script("vega", self._package_versions["vega"]), 170 | vegalite_script=get_bundled_script( 171 | "vega-lite", self._package_versions["vega-lite"] 172 | ), 173 | vegaembed_script=get_bundled_script( 174 | "vega-embed", self._package_versions["vega-embed"] 175 | ), 176 | output_div=output_div, 177 | ) 178 | else: 179 | return HTML_TEMPLATE.format( 180 | spec=json.dumps(self._spec), 181 | embed_options=json.dumps(self._embed_options), 182 | vega_url=self._package_url("vega"), 183 | vegalite_url=self._package_url("vega-lite"), 184 | vegaembed_url=self._package_url("vega-embed"), 185 | output_div=output_div, 186 | ) 187 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/bar.svg: -------------------------------------------------------------------------------- 1 | ABCDEFGHIa020406080100b -------------------------------------------------------------------------------- /altair_saver/savers/_selenium.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import base64 3 | import os 4 | from typing import Dict, List, Optional, Union 5 | import warnings 6 | 7 | import altair as alt 8 | from altair_saver.savers import Saver 9 | from altair_saver._types import JSONDict, MimebundleContent 10 | 11 | from altair_data_server import Provider, Resource 12 | from altair_viewer import get_bundled_script 13 | 14 | import selenium.webdriver 15 | from selenium.webdriver.remote.webdriver import WebDriver 16 | from selenium.common.exceptions import NoSuchElementException, WebDriverException 17 | 18 | 19 | class JavascriptError(RuntimeError): 20 | pass 21 | 22 | 23 | CDN_URL = "https://cdn.jsdelivr.net/npm/{package}@{version}" 24 | 25 | HTML_TEMPLATE = """ 26 | 27 | 28 | 29 | Embedding Vega-Lite 30 | 31 | 32 | 33 | 34 | 35 |
36 | 37 | 38 | """ 39 | 40 | EXTRACT_CODE = """ 41 | let spec = arguments[0]; 42 | const embedOpt = arguments[1]; 43 | const format = arguments[2]; 44 | const done = arguments[3]; 45 | 46 | if (format === 'vega') { 47 | if (embedOpt.mode === 'vega-lite') { 48 | vegaLite = (typeof vegaLite === "undefined") ? vl : vegaLite; 49 | try { 50 | const compiled = vegaLite.compile(spec); 51 | spec = compiled.spec; 52 | } catch(error) { 53 | done({error: error.toString()}) 54 | } 55 | } 56 | done({result: spec}); 57 | } 58 | 59 | vegaEmbed('#vis', spec, embedOpt).then(function(result) { 60 | if (format === 'png') { 61 | result.view 62 | .toCanvas(embedOpt.scaleFactor || 1) 63 | .then(function(canvas){return canvas.toDataURL('image/png');}) 64 | .then(result => done({result})) 65 | .catch(function(err) { 66 | console.error(err); 67 | done({error: err.toString()}); 68 | }); 69 | } else if (format === 'svg') { 70 | result.view 71 | .toSVG(embedOpt.scaleFactor || 1) 72 | .then(result => done({result})) 73 | .catch(function(err) { 74 | console.error(err); 75 | done({error: err.toString()}); 76 | }); 77 | } else { 78 | const error = "Unrecognized format: " + format; 79 | console.error(error); 80 | done({error}); 81 | } 82 | }).catch(function(err) { 83 | console.error(err); 84 | done({error: err.toString()}); 85 | }); 86 | """ 87 | 88 | 89 | class _DriverRegistry: 90 | """Registry of web driver singletons. 91 | 92 | This prevents the need to start and stop drivers repeatedly. 93 | """ 94 | 95 | drivers: Dict[str, WebDriver] 96 | 97 | def __init__(self) -> None: 98 | self.drivers = {} 99 | 100 | def get(self, webdriver: Union[str, WebDriver], driver_timeout: float) -> WebDriver: 101 | """Get a webdriver by name. 102 | 103 | Parameters 104 | ---------- 105 | webdriver : string or WebDriver 106 | The webdriver to use. 107 | driver_timeout : float 108 | The per-page driver timeout. 109 | 110 | Returns 111 | ------- 112 | webdriver : WebDriver 113 | """ 114 | webdriver = self.drivers.get(webdriver, webdriver) 115 | if isinstance(webdriver, WebDriver): 116 | return webdriver 117 | 118 | if webdriver == "chrome": 119 | webdriver_class = selenium.webdriver.Chrome 120 | webdriver_options_class = selenium.webdriver.chrome.options.Options 121 | elif webdriver == "firefox": 122 | webdriver_class = selenium.webdriver.Firefox 123 | webdriver_options_class = selenium.webdriver.firefox.options.Options 124 | else: 125 | raise ValueError( 126 | f"Unrecognized webdriver: '{webdriver}'. Expected 'chrome' or 'firefox'" 127 | ) 128 | 129 | webdriver_options = webdriver_options_class() 130 | webdriver_options.add_argument("--headless") 131 | 132 | if issubclass(webdriver_class, selenium.webdriver.Chrome): 133 | # for linux/osx root user, need to add --no-sandbox option. 134 | # since geteuid doesn't exist on windows, we don't check it 135 | if hasattr(os, "geteuid") and (os.geteuid() == 0): 136 | webdriver_options.add_argument("--no-sandbox") 137 | 138 | driver_obj = webdriver_class(options=webdriver_options) 139 | atexit.register(driver_obj.quit) 140 | driver_obj.set_page_load_timeout(driver_timeout) 141 | self.drivers[webdriver] = driver_obj 142 | 143 | return driver_obj 144 | 145 | 146 | class SeleniumSaver(Saver): 147 | """Save charts using a selenium engine.""" 148 | 149 | valid_formats: Dict[str, List[str]] = { 150 | "vega": ["png", "svg"], 151 | "vega-lite": ["png", "svg", "vega"], 152 | } 153 | _registry: _DriverRegistry = _DriverRegistry() 154 | _provider: Optional[Provider] = None 155 | _resources: Dict[str, Resource] = {} 156 | 157 | def __init__( 158 | self, 159 | spec: JSONDict, 160 | mode: Optional[str] = None, 161 | embed_options: Optional[JSONDict] = None, 162 | vega_version: str = alt.VEGA_VERSION, 163 | vegalite_version: str = alt.VEGALITE_VERSION, 164 | vegaembed_version: str = alt.VEGAEMBED_VERSION, 165 | driver_timeout: int = 20, 166 | webdriver: Optional[Union[str, WebDriver]] = None, 167 | offline: bool = True, 168 | scale_factor: Optional[float] = 1, 169 | ) -> None: 170 | self._driver_timeout = driver_timeout 171 | self._webdriver = ( 172 | self._select_webdriver(driver_timeout) if webdriver is None else webdriver 173 | ) 174 | self._offline = offline 175 | if scale_factor != 1: 176 | embed_options = embed_options or {} 177 | embed_options.setdefault("scaleFactor", scale_factor) 178 | super().__init__( 179 | spec=spec, 180 | mode=mode, 181 | embed_options=embed_options, 182 | vega_version=vega_version, 183 | vegalite_version=vegalite_version, 184 | vegaembed_version=vegaembed_version, 185 | ) 186 | 187 | @classmethod 188 | def _select_webdriver(cls, driver_timeout: int) -> Optional[str]: 189 | for driver in ["chrome", "firefox"]: 190 | try: 191 | cls._registry.get(driver, driver_timeout) 192 | except WebDriverException: 193 | pass 194 | except Exception as e: 195 | warnings.warn( 196 | f"Unexpected exception when attempting WebDriver creation: {e}" 197 | ) 198 | else: 199 | return driver 200 | return None 201 | 202 | @classmethod 203 | def enabled(cls) -> bool: 204 | return cls._select_webdriver(20) is not None 205 | 206 | @classmethod 207 | def _serve(cls, content: str, js_resources: Dict[str, str]) -> str: 208 | if cls._provider is None: 209 | cls._provider = Provider() 210 | resource = cls._provider.create( 211 | content=content, route="", headers={"Access-Control-Allow-Origin": "*"}, 212 | ) 213 | cls._resources[resource.url] = resource 214 | for route, content in js_resources.items(): 215 | cls._resources[route] = cls._provider.create(content=content, route=route,) 216 | return resource.url 217 | 218 | @classmethod 219 | def _stop_serving(cls) -> None: 220 | if cls._provider is not None: 221 | cls._provider.stop() 222 | cls._provider = None 223 | 224 | def _extract(self, fmt: str) -> MimebundleContent: 225 | if fmt == "vega" and self._mode == "vega": 226 | return self._spec 227 | 228 | driver = self._registry.get(self._webdriver, self._driver_timeout) 229 | 230 | if self._offline: 231 | js_resources = { 232 | "vega.js": get_bundled_script("vega", self._package_versions["vega"]), 233 | "vega-lite.js": get_bundled_script( 234 | "vega-lite", self._package_versions["vega-lite"] 235 | ), 236 | "vega-embed.js": get_bundled_script( 237 | "vega-embed", self._package_versions["vega-embed"] 238 | ), 239 | } 240 | html = HTML_TEMPLATE.format( 241 | vega_url="/vega.js", 242 | vegalite_url="/vega-lite.js", 243 | vegaembed_url="/vega-embed.js", 244 | ) 245 | else: 246 | js_resources = {} 247 | html = HTML_TEMPLATE.format( 248 | vega_url=CDN_URL.format( 249 | package="vega", version=self._package_versions["vega"] 250 | ), 251 | vegalite_url=CDN_URL.format( 252 | package="vega-lite", version=self._package_versions["vega-lite"] 253 | ), 254 | vegaembed_url=CDN_URL.format( 255 | package="vega-embed", version=self._package_versions["vega-embed"] 256 | ), 257 | ) 258 | 259 | url = self._serve(html, js_resources) 260 | driver.get("about:blank") 261 | driver.get(url) 262 | try: 263 | driver.find_element_by_id("vis") 264 | except NoSuchElementException: 265 | raise RuntimeError(f"Could not load {url}") 266 | if not self._offline: 267 | online = driver.execute_script("return navigator.onLine") 268 | if not online: 269 | raise RuntimeError( 270 | f"Internet connection required for saving chart as {fmt} with offline=False." 271 | ) 272 | opt = self._embed_options.copy() 273 | opt["mode"] = self._mode 274 | result = driver.execute_async_script(EXTRACT_CODE, self._spec, opt, fmt) 275 | if "error" in result: 276 | raise JavascriptError(result["error"]) 277 | return result["result"] 278 | 279 | def _serialize(self, fmt: str, content_type: str) -> MimebundleContent: 280 | out = self._extract(fmt) 281 | if fmt == "png": 282 | assert isinstance(out, str) 283 | return base64.b64decode(out.split(",", 1)[1].encode()) 284 | elif fmt == "svg": 285 | return out 286 | elif fmt == "vega": 287 | return out 288 | else: 289 | raise ValueError(f"Unrecognized format: {fmt}") 290 | -------------------------------------------------------------------------------- /altair_saver/_core.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from typing import Any, Dict, IO, Iterable, Optional, Set, Type, Union 3 | 4 | import altair as alt 5 | 6 | from altair_saver.savers import ( 7 | Saver, 8 | BasicSaver, 9 | HTMLSaver, 10 | NodeSaver, 11 | SeleniumSaver, 12 | ) 13 | from altair_saver._types import JSONDict, Mimebundle 14 | from altair_saver._utils import extract_format, infer_mode_from_spec 15 | 16 | _SAVER_METHODS: Dict[str, Type[Saver]] = OrderedDict( 17 | [ 18 | ("basic", BasicSaver), 19 | ("html", HTMLSaver), 20 | ("selenium", SeleniumSaver), 21 | ("node", NodeSaver), 22 | ] 23 | ) 24 | 25 | 26 | def _select_saver( 27 | method: Optional[Union[str, Type[Saver]]], 28 | mode: str, 29 | fmt: Optional[str] = None, 30 | fp: Optional[Union[IO, str]] = None, 31 | ) -> Type[Saver]: 32 | """Get an enabled Saver class that supports the specified format. 33 | 34 | Parameters 35 | ---------- 36 | method : string or Saver class or None 37 | The saver class to use. If None, the saver class will be chosen 38 | automatically. 39 | mode : string 40 | One of "vega" or "vega-lite". 41 | fmt : string, optional 42 | The format to which the spec will be saved. If not specified, it 43 | is inferred from `fp`. 44 | fp : string or file-like object, optional 45 | Only referenced if fmt is None. The name is used to infer the format 46 | if possible. 47 | 48 | Returns 49 | ------- 50 | Saver : Saver class 51 | The Saver subclass that implements the desired operation. 52 | """ 53 | if isinstance(method, type) and issubclass(method, Saver): 54 | return method 55 | elif isinstance(method, str): 56 | if method in _SAVER_METHODS: 57 | return _SAVER_METHODS[method] 58 | else: 59 | raise ValueError(f"Unrecognized method: {method!r}") 60 | elif method is None: 61 | if fmt is None: 62 | if fp is None: 63 | raise ValueError("Either fmt or fp must be specified") 64 | fmt = extract_format(fp) 65 | for s in _SAVER_METHODS.values(): 66 | if s.enabled() and fmt in s.valid_formats[mode]: 67 | return s 68 | raise ValueError(f"No enabled saver found that supports format={fmt!r}") 69 | else: 70 | raise ValueError(f"Unrecognized method: {method}") 71 | 72 | 73 | def save( 74 | chart: Union[alt.TopLevelMixin, JSONDict], 75 | fp: Optional[Union[IO, str]] = None, 76 | fmt: Optional[str] = None, 77 | mode: Optional[str] = None, 78 | embed_options: Optional[JSONDict] = None, 79 | method: Optional[Union[str, Type[Saver]]] = None, 80 | **kwargs: Any, 81 | ) -> Optional[Union[str, bytes]]: 82 | """Save an Altair, Vega, or Vega-Lite chart 83 | 84 | Parameters 85 | ---------- 86 | chart : alt.Chart or dict 87 | The chart or Vega/Vega-Lite chart specification to be saved 88 | fp : file or filename 89 | location to save the result. For fmt in ["png", "pdf"], file must be binary. 90 | For fmt in ["svg", "vega", "vega-lite"], file must be text. If not specified, 91 | the serialized chart will be returned. 92 | fmt : string (optional) 93 | The format in which to save the chart. If not specified and fp is a string, 94 | fmt will be determined from the file extension. Options are 95 | ["html", "pdf", "png", "svg", "vega", "vega-lite"]. 96 | mode : string (optional) 97 | The mode of the input spec. Either "vega-lite" or "vega". If not specified, 98 | it will be inferred from the spec. 99 | embed_options : dict (optional) 100 | A dictionary of options to pass to vega-embed. If not specified, the default 101 | will be drawn from alt.renderers.options. 102 | method : string or type 103 | The save method to use: one of {"node", "selenium", "html", "basic"}, 104 | or a subclass of Saver. 105 | 106 | Additional Parameters 107 | --------------------- 108 | vega_version : string (optional) 109 | For method in {"selenium", "html"}, the version of the vega javascript 110 | package to use. Default is alt.VEGA_VERSION. 111 | vegalite_version : string (optional) 112 | For method in {"selenium", "html"}, the version of the vega-lite javascript 113 | package to use. Default is alt.VEGALITE_VERSION. 114 | vegaembed_version : string (optional) 115 | For method in {"selenium", "html"}, the version of the vega-embed javascript 116 | package to use. Default is alt.VEGAEMBED_VERSION. 117 | inline : boolean (optional) 118 | For method="html", specify whether javascript sources should be included 119 | inline rather than loaded from an external CDN. Default: False. 120 | standalone : boolean (optional) 121 | For method="html", specify whether to create a standalone HTML file. 122 | Default is True for save(). 123 | webdriver : string or Object (optional) 124 | For method="selenium", the type of webdriver to use: one of "chrome", "firefox", 125 | or a selenium.WebDriver object. Defaults to what is available on your system. 126 | offline : bool (optional) 127 | For method="selenium", whether to save charts in offline mode (default=True). If 128 | false, saving charts will require a web connection to load Javascript from CDN. 129 | scale_factor : integer (optional) 130 | For method="selenium", scale saved image by this factor (default=1). This parameter 131 | value is overridden by embed_options["scaleFactor"] when both are specified. 132 | **kwargs : 133 | Additional keyword arguments are passed to Saver initialization. 134 | 135 | Returns 136 | ------- 137 | chart : string, bytes, or None 138 | If fp is None, the serialized chart is returned. 139 | If fp is specified, the return value is None. 140 | """ 141 | spec: JSONDict = {} 142 | if isinstance(chart, dict): 143 | spec = chart 144 | else: 145 | spec = chart.to_dict() 146 | 147 | if mode is None: 148 | mode = infer_mode_from_spec(spec) 149 | 150 | if embed_options is None: 151 | embed_options = alt.renderers.options.get("embed_options", None) 152 | 153 | Saver = _select_saver(method, mode=mode, fmt=fmt, fp=fp) 154 | saver = Saver(spec, mode=mode, embed_options=embed_options, **kwargs) 155 | 156 | return saver.save(fp=fp, fmt=fmt) 157 | 158 | 159 | def render( 160 | chart: Union[alt.TopLevelMixin, JSONDict], 161 | fmts: Union[str, Iterable[str]], 162 | mode: Optional[str] = None, 163 | embed_options: Optional[JSONDict] = None, 164 | method: Optional[Union[str, Type[Saver]]] = None, 165 | **kwargs: Any, 166 | ) -> Mimebundle: 167 | """Render a chart, returning a mimebundle. 168 | 169 | This implements an Altair renderer entry-point, enabled via:: 170 | 171 | alt.renderers.enable("altair_saver") 172 | 173 | Parameters 174 | ---------- 175 | chart : alt.Chart or dict 176 | The chart or Vega/Vega-Lite chart specification 177 | fmts : string or list of strings 178 | The format(s) to include in the mimebundle. Options are 179 | ["html", "pdf", "png", "svg", "vega", "vega-lite"]. 180 | mode : string (optional) 181 | The mode of the input spec. Either "vega-lite" or "vega". If not specified, 182 | it will be inferred from the spec. 183 | embed_options : dict (optional) 184 | A dictionary of options to pass to vega-embed. If not specified, the default 185 | will be drawn from alt.renderers.options. 186 | method : string or type 187 | The save method to use: one of {"node", "selenium", "html", "basic"}, 188 | or a subclass of Saver. 189 | 190 | Additional Parameters 191 | --------------------- 192 | vega_version : string (optional) 193 | For method in {"selenium", "html"}, the version of the vega javascript 194 | package to use. Default is alt.VEGA_VERSION. 195 | vegalite_version : string (optional) 196 | For method in {"selenium", "html"}, the version of the vega-lite javascript 197 | package to use. Default is alt.VEGALITE_VERSION. 198 | vegaembed_version : string (optional) 199 | For method in {"selenium", "html"}, the version of the vega-embed javascript 200 | package to use. Default is alt.VEGAEMBED_VERSION. 201 | inline : boolean (optional) 202 | For method="html", specify whether javascript sources should be included 203 | inline rather than loaded from an external CDN. Default: False. 204 | standalone : boolean (optional) 205 | For method="html", specify whether to create a standalone HTML file. 206 | Default is False for render(). 207 | webdriver : string or Object (optional) 208 | For method="selenium", the type of webdriver to use: one of "chrome", "firefox", 209 | or a selenium.WebDriver object. Defaults to what is available on your system. 210 | offline : bool (optional) 211 | For method="selenium", whether to save charts in offline mode (default=True). If 212 | false, saving charts will require a web connection to load Javascript from CDN. 213 | **kwargs : 214 | Additional keyword arguments are passed to Saver initialization. 215 | """ 216 | if isinstance(fmts, str): 217 | fmts = [fmts] 218 | mimebundle: Mimebundle = {} 219 | 220 | spec: JSONDict = {} 221 | if isinstance(chart, dict): 222 | spec = chart 223 | else: 224 | spec = chart.to_dict() 225 | 226 | if mode is None: 227 | mode = infer_mode_from_spec(spec) 228 | 229 | if embed_options is None: 230 | embed_options = alt.renderers.options.get("embed_options", None) 231 | 232 | for fmt in fmts: 233 | Saver = _select_saver(method, mode=mode, fmt=fmt) 234 | saver = Saver(spec, mode=mode, embed_options=embed_options, **kwargs) 235 | mimebundle.update(saver.mimebundle(fmt)) 236 | 237 | return mimebundle 238 | 239 | 240 | def available_formats(mode: str = "vega-lite") -> Set[str]: 241 | """Return the set of available formats. 242 | 243 | Parameters 244 | ---------- 245 | mode : str 246 | The kind of input; one of "vega", "vega-lite" 247 | 248 | Returns 249 | ------- 250 | formats : set of strings 251 | Formats available in the current session. 252 | """ 253 | valid_modes = {"vega", "vega-lite"} 254 | if mode not in valid_modes: 255 | raise ValueError(f"Invalid mode: {mode!r}. Must be one of {valid_modes!r}") 256 | return set.union( 257 | *(set(s.valid_formats[mode]) for s in _SAVER_METHODS.values() if s.enabled()) 258 | ) 259 | -------------------------------------------------------------------------------- /altair_saver/savers/tests/testcases/scatter.svg: -------------------------------------------------------------------------------- 1 | 05101520253035404550x-1.0-0.50.00.51.0y --------------------------------------------------------------------------------