├── .bumpversion.cfg ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── publish.yml ├── .gitignore ├── .readthedocs.yml ├── .travis.yml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── README.rst ├── docs ├── authors.rst ├── conf.py ├── contributing.rst ├── environment.yml ├── history.rst ├── images │ ├── pydantic-panel-simple.png │ └── simple_model_example.png ├── index.rst ├── installation.rst ├── readme.rst └── usage.rst ├── images ├── pydantic-panel-simple.png └── simple_model_example.png ├── poetry.lock ├── pydantic_panel ├── __init__.py ├── dispatchers.py ├── numpy.py ├── pandas.py ├── pane.py └── widgets.py ├── pyproject.toml ├── tasks.py ├── tests ├── __init__.py └── test_pydantic_panel.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.2.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | search = version = "{current_version}" 8 | replace = version = "{new_version}" 9 | 10 | [bumpversion:file:pydantic_panel/__init__.py] 11 | search = __version__ = "{current_version}" 12 | replace = __version__ = "{new_version}" 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * pydantic-panel version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 📦 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.x" 16 | - name: Install pypa/build 17 | run: >- 18 | python3 -m 19 | pip install 20 | build 21 | --user 22 | - name: Build a binary wheel and a source tarball 23 | run: python3 -m build 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v3 26 | with: 27 | name: python-package-distributions 28 | path: dist/ 29 | 30 | publish-to-pypi: 31 | name: >- 32 | Publish Python 🐍 distribution 📦 to PyPI 33 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 34 | needs: 35 | - build 36 | runs-on: ubuntu-latest 37 | environment: 38 | name: pypi 39 | url: https://pypi.org/p/pydantic-panel 40 | permissions: 41 | id-token: write # IMPORTANT: mandatory for trusted publishing 42 | 43 | steps: 44 | - name: Download all the dists 45 | uses: actions/download-artifact@v3 46 | with: 47 | name: python-package-distributions 48 | path: dist/ 49 | - name: Publish distribution 📦 to PyPI 50 | uses: pypa/gh-action-pypi-publish@release/v1 51 | 52 | github-release: 53 | name: >- 54 | Sign the Python 🐍 distribution 📦 with Sigstore 55 | and upload them to GitHub Release 56 | needs: 57 | - publish-to-pypi 58 | runs-on: ubuntu-latest 59 | 60 | permissions: 61 | contents: write # IMPORTANT: mandatory for making GitHub Releases 62 | id-token: write # IMPORTANT: mandatory for sigstore 63 | 64 | steps: 65 | - name: Download all the dists 66 | uses: actions/download-artifact@v3 67 | with: 68 | name: python-package-distributions 69 | path: dist/ 70 | - name: Sign the dists with Sigstore 71 | uses: sigstore/gh-action-sigstore-python@v1.2.3 72 | with: 73 | inputs: >- 74 | ./dist/*.tar.gz 75 | ./dist/*.whl 76 | - name: Create GitHub Release 77 | env: 78 | GITHUB_TOKEN: ${{ github.token }} 79 | run: >- 80 | gh release create 81 | '${{ github.ref_name }}' 82 | --repo '${{ github.repository }}' 83 | --notes "" 84 | - name: Upload artifact signatures to GitHub Release 85 | env: 86 | GITHUB_TOKEN: ${{ github.token }} 87 | # Upload to GitHub Release using the `gh` CLI. 88 | # `dist/` contains the built packages, and the 89 | # sigstore-produced signatures and certificates. 90 | run: >- 91 | gh release upload 92 | '${{ github.ref_name }}' dist/** 93 | --repo '${{ github.repository }}' 94 | -------------------------------------------------------------------------------- /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | # IDE settings 105 | .vscode/ 106 | script.py 107 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Build documentation in the docs/ directory with Sphinx 8 | sphinx: 9 | builder: html 10 | configuration: docs/conf.py 11 | 12 | build: 13 | os: ubuntu-20.04 14 | tools: 15 | python: mambaforge-4.10 16 | 17 | conda: 18 | environment: docs/environment.yml 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.com 2 | 3 | language: python 4 | python: 5 | - 3.10 6 | - 3.9 7 | - 3.8 8 | - 3.7 9 | 10 | 11 | # Command to install dependencies 12 | install: pip install -U tox-travis 13 | 14 | # Command to run tests 15 | script: tox 16 | 17 | 18 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Yossi Mosbacher 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Marc Skov Madsen -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/jmosbacher/pydantic_panel/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | pydantic-panel could always use more documentation, whether as part of the 42 | official pydantic-panel docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/jmosbacher/pydantic_panel/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `pydantic_panel` for local development. 61 | 62 | #. Fork the `pydantic_panel` repo on GitHub. 63 | #. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/pydantic_panel.git 66 | 67 | #. Ensure `poetry is installed`_. 68 | #. Install dependencies and start your virtualenv:: 69 | 70 | $ poetry install 71 | 72 | #. Create a branch for local development:: 73 | 74 | $ git checkout -b name-of-your-bugfix-or-feature 75 | 76 | Now you can make your changes locally. 77 | 78 | #. When you're done making changes, check that your changes pass the 79 | tests, including testing other Python versions, with tox:: 80 | 81 | $ tox 82 | 83 | #. Commit your changes and push your branch to GitHub:: 84 | 85 | $ git add . 86 | $ git commit -m "Your detailed description of your changes." 87 | $ git push origin name-of-your-bugfix-or-feature 88 | 89 | #. Submit a pull request through the GitHub website. 90 | 91 | .. _poetry is installed: https://python-poetry.org/docs/ 92 | 93 | Pull Request Guidelines 94 | ----------------------- 95 | 96 | Before you submit a pull request, check that it meets these guidelines: 97 | 98 | 1. The pull request should include tests. 99 | 2. If the pull request adds functionality, the docs should be updated. Put 100 | your new functionality into a function with a docstring, and add the 101 | feature to the list in README.rst. 102 | 3. The pull request should work for Python 3.8, 3.9, 3.10 and for PyPy. Check 103 | https://travis-ci.com/jmosbacher/pydantic_panel/pull_requests 104 | and make sure that the tests pass for all supported Python versions. 105 | 106 | Tips 107 | ---- 108 | 109 | To run a subset of tests:: 110 | 111 | $ pytest tests.test_pydantic_panel 112 | 113 | 114 | Deploying 115 | --------- 116 | 117 | A reminder for the maintainers on how to deploy. 118 | Make sure all your changes are committed (including an entry in HISTORY.rst). 119 | Then run:: 120 | 121 | $ bump2version patch # possible: major / minor / patch 122 | $ git push 123 | $ git push --tags 124 | 125 | Travis will then deploy to PyPI if tests pass. 126 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.1.0 (2022-07-12) 6 | ------------------ 7 | 8 | * First release on PyPI. 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022, Yossi Mosbacher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | pydantic-panel 3 | ============== 4 | 5 | **pydantic-panel makes it easy to auto-generate UI elements from 6 | Pydantic models.** 7 | 8 | .. image:: https://img.shields.io/pypi/v/pydantic_panel.svg 9 | :target: https://pypi.python.org/pypi/pydantic_panel 10 | :alt: Pypi package version 11 | 12 | .. image:: https://img.shields.io/badge/Python-3.7%2B-blue&style=flat 13 | :target: https://pypi.org/project/streamlit-pydantic/ 14 | :alt: Python version 15 | 16 | .. image:: https://img.shields.io/travis/jmosbacher/pydantic_panel.svg 17 | :target: https://travis-ci.com/jmosbacher/pydantic_panel 18 | :alt: Build Status 19 | 20 | .. image:: https://readthedocs.org/projects/pydantic-panel/badge/?version=latest 21 | :target: https://pydantic-panel.readthedocs.io/en/latest/?badge=latest 22 | :alt: Documentation Status 23 | 24 | .. image:: https://img.shields.io/badge/License-MIT-green.svg 25 | :target: https://github.com/jmosbacher/pydantic-panel/blob/master/LICENSE 26 | :alt: MIT License 27 | 28 | `Getting Started`_ | `Documentation`_ | `Support`_ 29 | 30 | pydantic-panel makes it easy to **auto-generate UI elements** from 31 | `Pydantic`_ models and any other Python object. The UI elements 32 | can be used in your **Jupyter Notebook** and in your `Panel`_ **data app**. 33 | 34 | .. image:: images/pydantic-panel-simple.png 35 | :width: 700 36 | :align: center 37 | 38 | This project is at an early stage and potentially contains bugs. You might also 39 | see api changes, USE AT YOUR OWN RISK. 40 | 41 | I will continue to add support for more types as I need them. Feel free to 42 | open issues with feature requests or better yet PRs with implementations. 43 | 44 | Getting Started 45 | --------------- 46 | 47 | Step 1 - Install 48 | 49 | *Requirements: Python 3.7+.* 50 | 51 | .. code-block:: 52 | 53 | pip install pydantic-panel 54 | 55 | 56 | Step 2 - Import panel and add your models to layouts! 57 | 58 | .. code-block:: python 59 | 60 | import pydantic 61 | import panel as pn 62 | 63 | pn.extension() 64 | 65 | class SomeModel(pydantic.BaseModel): 66 | name: str 67 | value: float 68 | 69 | model = SomeModel(name="meaning", value=42) 70 | 71 | widget = pn.panel(model) 72 | 73 | layout = pn.Column(widget, widget.json) 74 | layout.servable() 75 | 76 | Now you can edit your model: 77 | 78 | .. image:: images/simple_model_example.png 79 | :width: 400 80 | 81 | How it works 82 | ------------ 83 | 84 | If you install `pydantic_panel`, it will register the widget automatically using the `panel.BasePane.applies` interface. 85 | After importing, calling `panel.panel(model)` will return a `panel.CompositeWidget` whos value is the model. 86 | 87 | When you change one of the sub-widget values, the new value is validated/coerced using the corresponding pydantic 88 | field and if it passes validation/coercion the new value is set on the model itself. 89 | By default this is a one-way sync, if the model field values are changed via code, it does not sync the widgets. 90 | 91 | If you want biderectional sync, you can pass `bidirectional = True` to the widget constructor, this will patch the model 92 | to sync changes to the widgets but this may break without warning if pydantic change the internals of 93 | their `__setattr__` method. 94 | 95 | 96 | Customizing Behavior 97 | -------------------- 98 | 99 | You can add or change the widgets used for a given type by hooking into the dispatch 100 | mechanism (we use plum-dispatch). This can be used to override the widget used for a supported 101 | type or to add support for a new type. 102 | 103 | 104 | .. code-block:: python 105 | 106 | from pydantic_panel import infer_widget 107 | from pydantic.fields import FieldInfo 108 | from typing import Optional 109 | 110 | # precedence > 0 will ensure this function will be called 111 | # instead of the default which has precedence = 0 112 | @infer_widget.dispatch(precedence=1) 113 | def infer_widget(value: MY_TYPE, field: Optional[FieldInfo] = None, **kwargs): 114 | # extract relavent info from the pydantic field info here. 115 | 116 | # return your favorite widget 117 | return MY_FAVORITE_WIDGET(value=value, **kwargs) 118 | 119 | 120 | Supported types 121 | --------------- 122 | 123 | * int 124 | * float 125 | * str 126 | * list 127 | * tuple 128 | * dict 129 | * datetime.datetime 130 | * BaseModel 131 | * List[BaseModel] 132 | * pandas.Interval 133 | * numpy.ndarray 134 | 135 | FAQ 136 | --- 137 | 138 | Q: Why did you decide to use CompositWidget instead of Pane like Param uses? 139 | 140 | A: Nested models. This is a recursive problem, so I was looking for a recursive solution. By using a Widget to 141 | display models, all fields are treated equally. A field of type BaseModel is edited with a widget that has a `.value` 142 | attribute just like any other field and therefore requires no special treatment. When the parent collects the values of its children 143 | it just reads the `widget.value` attribute and does not need to check whether the value is nested or not. At every level 144 | of the recursion the widget only has to care about the fields on its model class and watch only the `.value` attribute of 145 | its children widgets for changes. 146 | 147 | 148 | Features 149 | -------- 150 | 151 | * TODO 152 | 153 | 154 | Support & Feedback 155 | ------------------ 156 | 157 | 158 | +---------------------+------------------------------------------------+ 159 | | Type | Channel | 160 | +=====================+================================================+ 161 | | 🐛 Bugs + |BugImage| | 162 | +---------------------+------------------------------------------------+ 163 | | 🎁 Features + |FeatureImage| | 164 | +---------------------+------------------------------------------------+ 165 | | ❓ Questions + |QuestionImage| | 166 | +---------------------+------------------------------------------------+ 167 | 168 | 169 | Credits 170 | ------- 171 | 172 | This package was created with Cookiecutter_ and the `briggySmalls/cookiecutter-pypackage`_ project template. 173 | 174 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 175 | .. _`briggySmalls/cookiecutter-pypackage`: https://github.com/briggySmalls/cookiecutter-pypackage 176 | .. _Pydantic: https://github.com/samuelcolvin/pydantic/ 177 | .. _Panel: https://github.com/holoviz/panel 178 | .. _Getting Started: #getting-started 179 | .. _Documentation: https://pydantic-panel.readthedocs.io 180 | .. _Support: #support--feedback 181 | .. |BugImage| image:: https://img.shields.io/github/issues/jmosbacher/pydantic-panel/bug.svg?label=bug 182 | :target: https://github.com/jmosbacher/pydantic-panel/issues?utf8=%E2%9C%93&q=is%3Aopen+is%3Aissue+label%3Abug+sort%3Areactions-%2B1-desc+ 183 | .. |FeatureImage| image:: https://img.shields.io/github/issues/jmosbacher/pydantic-panel/feature.svg?label=feature%20request 184 | :target: https://github.com/jmosbacher/pydantic-panel/issues?q=is%3Aopen+is%3Aissue+label%3Afeature+sort%3Areactions-%2B1-desc 185 | .. |QuestionImage| image:: https://img.shields.io/github/issues/jmosbacher/pydantic-panel/support.svg?label=support%20request 186 | :target: https://github.com/jmosbacher/pydantic-panel/issues?q=is%3Aopen+is%3Aissue+label%3Asupport+sort%3Areactions-%2B1-desc 187 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # pydantic_panel documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another 16 | # directory, add these directories to sys.path here. If the directory is 17 | # relative to the documentation root, use os.path.abspath to make it 18 | # absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | 23 | sys.path.insert(0, os.path.abspath("..")) 24 | 25 | import pydantic_panel 26 | import sphinx_material 27 | 28 | # -- General configuration --------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 36 | extensions = [ 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.viewcode", 39 | "sphinx.ext.napoleon", 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ["_templates"] 44 | 45 | # The suffix(es) of source filenames. 46 | # You can specify multiple suffix as a list of string: 47 | # 48 | # source_suffix = ['.rst', '.md'] 49 | source_suffix = ".rst" 50 | 51 | # The master toctree document. 52 | master_doc = "index" 53 | 54 | # General information about the project. 55 | project = "pydantic-panel" 56 | copyright = "2022, Yossi Mosbacher" 57 | author = "Yossi Mosbacher" 58 | 59 | # The version info for the project you're documenting, acts as replacement 60 | # for |version| and |release|, also used in various other places throughout 61 | # the built documents. 62 | # 63 | # The short X.Y version. 64 | version = pydantic_panel.__version__ 65 | # The full version, including alpha/beta/rc tags. 66 | release = pydantic_panel.__version__ 67 | 68 | # The language for content autogenerated by Sphinx. Refer to documentation 69 | # for a list of supported languages. 70 | # 71 | # This is also used if you do content translation via gettext catalogs. 72 | # Usually you set "language" from the command line for these cases. 73 | language = None 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | # This patterns also effect to html_static_path and html_extra_path 78 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 79 | 80 | # The name of the Pygments (syntax highlighting) style to use. 81 | pygments_style = "sphinx" 82 | 83 | # If true, `todo` and `todoList` produce output, else they produce nothing. 84 | todo_include_todos = False 85 | 86 | 87 | # -- Options for HTML output ------------------------------------------- 88 | 89 | # The theme to use for HTML and HTML Help pages. See the documentation for 90 | # a list of builtin themes. 91 | # 92 | extensions.append("sphinx_material") 93 | html_theme_path = sphinx_material.html_theme_path() 94 | html_context = sphinx_material.get_html_context() 95 | html_theme = "sphinx_material" 96 | # Material theme options (see theme.conf for more information) 97 | html_theme_options = { 98 | # Set the name of the project to appear in the navigation. 99 | "nav_title": "pydantic-panel", 100 | # Set you GA account ID to enable tracking 101 | # 'google_analytics_account': 'UA-XXXXX', 102 | # Specify a base_url used to generate sitemap.xml. If not 103 | # specified, then no sitemap will be built. 104 | "base_url": "https://project.github.io/jmosbacher", 105 | # Set the color and the accent color 106 | "color_primary": "green", 107 | "color_accent": "light-green", 108 | # Set the repo location to get a badge with stats 109 | "repo_url": "https://github.com/jmosbacher/pydantic_panel/", 110 | "repo_name": "pydantic-panel", 111 | # Visible levels of the global TOC; -1 means unlimited 112 | "globaltoc_depth": 3, 113 | # If False, expand all TOC entries 114 | "globaltoc_collapse": False, 115 | # If True, show hidden TOC entries 116 | "globaltoc_includehidden": False, 117 | } 118 | 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ["_static"] 124 | 125 | 126 | # -- Options for HTMLHelp output --------------------------------------- 127 | 128 | # Output file base name for HTML help builder. 129 | htmlhelp_basename = "pydantic_paneldoc" 130 | 131 | 132 | # -- Options for LaTeX output ------------------------------------------ 133 | 134 | latex_elements = { 135 | # The paper size ('letterpaper' or 'a4paper'). 136 | # 137 | # 'papersize': 'letterpaper', 138 | # The font size ('10pt', '11pt' or '12pt'). 139 | # 140 | # 'pointsize': '10pt', 141 | # Additional stuff for the LaTeX preamble. 142 | # 143 | # 'preamble': '', 144 | # Latex figure (float) alignment 145 | # 146 | # 'figure_align': 'htbp', 147 | } 148 | 149 | # Grouping the document tree into LaTeX files. List of tuples 150 | # (source start file, target name, title, author, documentclass 151 | # [howto, manual, or own class]). 152 | latex_documents = [ 153 | ( 154 | master_doc, 155 | "pydantic_panel.tex", 156 | "pydantic-panel Documentation", 157 | "Yossi Mosbacher", 158 | "manual", 159 | ), 160 | ] 161 | 162 | 163 | # -- Options for manual page output ------------------------------------ 164 | 165 | # One entry per manual page. List of tuples 166 | # (source start file, name, description, authors, manual section). 167 | man_pages = [ 168 | (master_doc, "pydantic_panel", "pydantic-panel Documentation", [author], 1) 169 | ] 170 | 171 | 172 | # -- Options for Texinfo output ---------------------------------------- 173 | 174 | # Grouping the document tree into Texinfo files. List of tuples 175 | # (source start file, target name, title, author, 176 | # dir menu entry, description, category) 177 | texinfo_documents = [ 178 | ( 179 | master_doc, 180 | "pydantic_panel", 181 | "pydantic-panel Documentation", 182 | author, 183 | "pydantic_panel", 184 | "One line description of project.", 185 | "Miscellaneous", 186 | ), 187 | ] 188 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | name: xedocs-docs 2 | # this is a conda/mamba environment for building the xedocs documentation 3 | 4 | channels: 5 | - conda-forge 6 | - nodefaults 7 | 8 | dependencies: 9 | # the chunk below gets copied to ../.binder/environment.yml 10 | ### DOCS ENV ### 11 | # runtimes 12 | - nodejs >=14,<15 13 | - python >=3.9,<3.10 14 | # build 15 | - doit >=0.33,<0.34 16 | - flit >=3.7.1,!=3.5.0 17 | - jupyter-server-mathjax >=0.2.3 18 | - jsonschema >=3 19 | - pip 20 | - pkginfo 21 | - wheel 22 | - yarn <2 23 | # cli 24 | - wheel 25 | # docs 26 | - myst-nb 27 | - pydata-sphinx-theme 28 | - sphinx 29 | - sphinx-autodoc-typehints 30 | - sphinx-jsonschema 31 | - sphinxext-rediraffe 32 | # check 33 | - pytest-check-links 34 | # test 35 | - ansi2html 36 | - pytest-console-scripts 37 | - pytest-cov 38 | - pytest-html 39 | - pytest-xdist 40 | # language packs and contents 41 | - jupyter_server >=1.11,<2 42 | - jupyterlab-language-pack-fr-FR 43 | - jupyterlab-language-pack-zh-CN 44 | - jupyterlab_server >=2.8.1,<3 45 | ### DOCS ENV ### 46 | - pip: 47 | - pydantic-panel 48 | - plum-dispatch 49 | - pandas 50 | - numpydoc 51 | - sphinx 52 | - sphinx-material 53 | - nbsphinx 54 | - autodoc-pydantic 55 | - jupyterlite-sphinx 56 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/images/pydantic-panel-simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmosbacher/pydantic-panel/8b93ddd876dad797043e4a792bfad775c962fa3b/docs/images/pydantic-panel-simple.png -------------------------------------------------------------------------------- /docs/images/simple_model_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmosbacher/pydantic-panel/8b93ddd876dad797043e4a792bfad775c962fa3b/docs/images/simple_model_example.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to pydantic-panel's documentation! 2 | ====================================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | modules 12 | contributing 13 | authors 14 | history 15 | 16 | Indices and tables 17 | ================== 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install pydantic-panel, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install pydantic_panel 16 | 17 | This is the preferred method to install pydantic-panel, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From source 27 | ----------- 28 | 29 | The source for pydantic-panel can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/jmosbacher/pydantic_panel 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OJL https://github.com/jmosbacher/pydantic_panel/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ pip install . 48 | 49 | .. _Github repo: https://github.com/jmosbacher/pydantic_panel 50 | .. _tarball: https://github.com/jmosbacher/pydantic_panel/tarball/master 51 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use pydantic-panel in a project:: 6 | 7 | import pydantic_panel 8 | -------------------------------------------------------------------------------- /images/pydantic-panel-simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmosbacher/pydantic-panel/8b93ddd876dad797043e4a792bfad775c962fa3b/images/pydantic-panel-simple.png -------------------------------------------------------------------------------- /images/simple_model_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jmosbacher/pydantic-panel/8b93ddd876dad797043e4a792bfad775c962fa3b/images/simple_model_example.png -------------------------------------------------------------------------------- /pydantic_panel/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pydantic-panel makes it easy to combine Pydantic and Panel 3 | ========================================================== 4 | 5 | With pydantic-panel you can 6 | 7 | - edit Pydantic models in notebooks and data apps. 8 | - build data apps from Pydantic Models using the dataviz tools you already know 9 | and love ❤️. 10 | 11 | To learn more check out https://pydantic-panel.readthedocs.io/en/latest/. To 12 | report issues or contribute go to https://github.com/jmosbacher/pydantic-panel. 13 | 14 | How to use pydantic-panel in 3 simple steps 15 | ------------------------------------------- 16 | 17 | 1. Define your Pydantic model 18 | 19 | >>> import pydantic 20 | >>> class SomeModel(pydantic.BaseModel): 21 | ... name: str 22 | ... value: float 23 | 24 | 2. Import panel and pydantic_panel 25 | 26 | >>> import panel as pn 27 | >>> import pydantic_panel 28 | >>> pn.extension() 29 | 30 | 3. Wrap your model with `pn.panel` and work with Panel as you normally would 31 | 32 | >>> widget = pn.panel(SomeModel) 33 | >>> layout = pn.Column(widget, widget.json) 34 | >>> layout.servable() 35 | 36 | In a notebook this will give you a component with widgets for editing your 37 | model. 38 | 39 | You can also serve this as a data app if you run `panel serve 40 | name_of_file.ipynb --show` (or `panel serve name_of_file.py --show`). Add 41 | the `--autoreload` flag for hot reloading during development. 42 | """ 43 | 44 | __author__ = """Yossi Mosbacher""" 45 | __email__ = "joe.mosbacher@gmail.com" 46 | __version__ = "0.2.0" 47 | 48 | from .dispatchers import infer_widget 49 | 50 | from .widgets import ( 51 | PydanticModelEditor, 52 | PydanticModelEditorCard, 53 | ItemListEditor, 54 | ItemDictEditor, 55 | ) 56 | 57 | from .pane import Pydantic 58 | 59 | # Needed for VS Code/ pyright to discover the available items 60 | __all__ = [ 61 | "infer_widget", 62 | "ItemDictEditor", 63 | "ItemListEditor", 64 | "Pydantic", 65 | "PydanticModelEditor", 66 | "PydanticModelEditorCard", 67 | ] 68 | 69 | try: 70 | from .pandas import ( 71 | PandasTimeIntervalEditor, 72 | PandasIntervalEditor, 73 | PandasIntergerIntervalEditor, 74 | ) 75 | 76 | __all__.extend( 77 | [ 78 | "PandasTimeIntervalEditor", 79 | "PandasIntervalEditor", 80 | "PandasIntergerIntervalEditor", 81 | ] 82 | ) 83 | 84 | except ImportError: 85 | pass 86 | 87 | try: 88 | from .numpy import NPArray 89 | __all__.append('NPArray') 90 | 91 | except ImportError: 92 | pass 93 | -------------------------------------------------------------------------------- /pydantic_panel/dispatchers.py: -------------------------------------------------------------------------------- 1 | import param 2 | import datetime 3 | import annotated_types 4 | 5 | from typing import Any, Optional 6 | from pydantic.fields import FieldInfo 7 | 8 | try: 9 | from typing import _LiteralGenericAlias 10 | except ImportError: 11 | _LiteralGenericAlias = None 12 | 13 | from plum import dispatch 14 | from numbers import Integral, Number 15 | from panel import Param, Column 16 | 17 | 18 | from panel.widgets import ( 19 | Widget, 20 | LiteralInput, 21 | IntInput, 22 | NumberInput, 23 | DatetimePicker, 24 | Checkbox, 25 | TextInput, 26 | TextAreaInput, 27 | Select, 28 | MultiChoice, 29 | ) 30 | 31 | 32 | ListInput = type("ListInput", (LiteralInput,), {"type": list}) 33 | DictInput = type("DictInput", (LiteralInput,), {"type": dict}) 34 | TupleInput = type("TupleInput", (LiteralInput,), {"type": tuple}) 35 | 36 | 37 | def clean_kwargs(obj: param.Parameterized, 38 | kwargs: dict[str,Any]) -> dict[str,Any]: 39 | '''Remove any kwargs that are not explicit parameters of obj. 40 | ''' 41 | return {k: v for k, v in kwargs.items() if k in obj.param.values()} 42 | 43 | 44 | @dispatch 45 | def infer_widget(value: Any, field: Optional[FieldInfo] = None, **kwargs) -> Widget: 46 | """Fallback function when a more specific 47 | function was not registered. 48 | """ 49 | 50 | if field is not None and type(field.annotation) == _LiteralGenericAlias: 51 | options = list(field.annotation.__args__) 52 | if value not in options: 53 | value = options[0] 54 | options = kwargs.pop("options", options) 55 | kwargs = clean_kwargs(Select, kwargs) 56 | 57 | return Select(value=value, options=options, **kwargs) 58 | 59 | kwargs = clean_kwargs(LiteralInput, kwargs) 60 | return LiteralInput(value=value, **kwargs) 61 | 62 | 63 | @dispatch 64 | def infer_widget(value: Integral, field: Optional[FieldInfo] = None, **kwargs) -> Widget: 65 | start = None 66 | end = None 67 | if field is not None: 68 | if type(field.annotation) == _LiteralGenericAlias: 69 | options = list(field.annotation.__args__) 70 | if value not in options: 71 | value = options[0] 72 | options = kwargs.pop("options", options) 73 | kwargs = clean_kwargs(Select, kwargs) 74 | return Select(value=value, options=options, **kwargs) 75 | 76 | for m in field.metadata: 77 | if isinstance(m, annotated_types.Gt): 78 | start = m.gt + 1 79 | if isinstance(m, annotated_types.Ge): 80 | start = m.ge 81 | if isinstance(m, annotated_types.Lt): 82 | end = m.lt - 1 83 | if isinstance(m, annotated_types.Le): 84 | end = m.le 85 | 86 | kwargs = clean_kwargs(IntInput, kwargs) 87 | return IntInput(value=value, start=start, end=end, **kwargs) 88 | 89 | 90 | @dispatch 91 | def infer_widget(value: Number, field: Optional[FieldInfo] = None, **kwargs) -> Widget: 92 | start = None 93 | end = None 94 | if field is not None: 95 | if type(field.annotation) == _LiteralGenericAlias: 96 | options = list(field.annotation.__args__) 97 | if value not in options: 98 | value = options[0] 99 | options = kwargs.pop("options", options) 100 | kwargs = clean_kwargs(Select, kwargs) 101 | return Select(value=value, options=options, **kwargs) 102 | 103 | for m in field.metadata: 104 | if isinstance(m, annotated_types.Gt): 105 | start = m.gt + 1 106 | if isinstance(m, annotated_types.Ge): 107 | start = m.ge 108 | if isinstance(m, annotated_types.Lt): 109 | end = m.lt - 1 110 | if isinstance(m, annotated_types.Le): 111 | end = m.le 112 | 113 | kwargs = clean_kwargs(NumberInput, kwargs) 114 | return NumberInput(value=value, start=start, end=end, **kwargs) 115 | 116 | 117 | @dispatch 118 | def infer_widget(value: bool, field: Optional[FieldInfo] = None, **kwargs) -> Widget: 119 | if value is None: 120 | value = False 121 | kwargs = clean_kwargs(Checkbox, kwargs) 122 | return Checkbox(value=value, **kwargs) 123 | 124 | 125 | @dispatch 126 | def infer_widget(value: str, field: Optional[FieldInfo] = None, **kwargs) -> Widget: 127 | min_length = kwargs.pop("min_length", None) 128 | max_length = kwargs.pop("max_length", 100) 129 | 130 | if field is not None: 131 | if type(field.annotation) == _LiteralGenericAlias: 132 | options = list(field.annotation.__args__) 133 | if value not in options: 134 | value = options[0] 135 | options = kwargs.pop("options", options) 136 | kwargs = clean_kwargs(Select, kwargs) 137 | return Select(value=value, options=options, **kwargs) 138 | for m in field.metadata: 139 | if isinstance(m, annotated_types.MinLen): 140 | min_length = m.min_length 141 | if isinstance(m, annotated_types.MaxLen): 142 | max_length = m.max_length 143 | 144 | kwargs["min_length"] = min_length 145 | 146 | if max_length is not None and max_length < 100: 147 | kwargs = clean_kwargs(TextInput, kwargs) 148 | return TextInput(value=value, max_length=max_length, **kwargs) 149 | 150 | kwargs = clean_kwargs(TextAreaInput, kwargs) 151 | return TextAreaInput(value=value, max_length=max_length, **kwargs) 152 | 153 | 154 | @dispatch 155 | def infer_widget(value: list, field: Optional[FieldInfo] = None, **kwargs) -> Widget: 156 | if field is not None and type(field.annotation) == _LiteralGenericAlias: 157 | options = list(field.annotation.__args__) 158 | if value not in options: 159 | value = [] 160 | kwargs = clean_kwargs(ListInput, kwargs) 161 | return MultiChoice(name=field.alias, 162 | value=value, options=options) 163 | 164 | kwargs = clean_kwargs(ListInput, kwargs) 165 | return ListInput(value=value, **kwargs) 166 | 167 | 168 | @dispatch 169 | def infer_widget(value: dict, field: Optional[FieldInfo] = None, **kwargs) -> Widget: 170 | kwargs = clean_kwargs(DictInput, kwargs) 171 | return DictInput(value=value, **kwargs) 172 | 173 | 174 | @dispatch 175 | def infer_widget(value: tuple, field: Optional[FieldInfo] = None, **kwargs) -> Widget: 176 | kwargs = clean_kwargs(TupleInput, kwargs) 177 | return TupleInput(value=value, **kwargs) 178 | 179 | 180 | @dispatch 181 | def infer_widget( 182 | value: datetime.datetime, field: Optional[FieldInfo] = None, **kwargs 183 | ): 184 | kwargs = clean_kwargs(DatetimePicker, kwargs) 185 | return DatetimePicker(value=value, **kwargs) 186 | 187 | 188 | @dispatch 189 | def infer_widget( 190 | value: param.Parameterized, field: Optional[FieldInfo] = None, **kwargs 191 | ): 192 | kwargs = clean_kwargs(Param, kwargs) 193 | return Param(value, **kwargs) 194 | 195 | 196 | @dispatch 197 | def infer_widget( 198 | value: list[param.Parameterized], field: Optional[FieldInfo] = None, **kwargs 199 | ): 200 | kwargs = clean_kwargs(Param, kwargs) 201 | return Column(*[Param(val, **kwargs) for val in value]) 202 | -------------------------------------------------------------------------------- /pydantic_panel/numpy.py: -------------------------------------------------------------------------------- 1 | 2 | import numpy as np 3 | 4 | from typing import Optional 5 | from plum import dispatch, parametric, type_of 6 | from pydantic.fields import FieldInfo 7 | from panel.widgets import Widget, ArrayInput 8 | 9 | from .dispatchers import clean_kwargs 10 | 11 | 12 | @dispatch 13 | def infer_widget( 14 | value: np.ndarray, field: Optional[FieldInfo] = None, **kwargs 15 | ) -> Widget: 16 | kwargs = clean_kwargs(ArrayInput, kwargs) 17 | return ArrayInput(value=value, **kwargs) 18 | 19 | 20 | # Taken from plum examples 21 | # This is mostly for users to be able to define custom widgets based 22 | # on the shape of the array. For now we dispatch all numpy arrays to the 23 | # same widget constructor. 24 | @parametric(runtime_type_of=True) 25 | class NPArray(np.ndarray): 26 | """A type for NumPy arrays where the type parameter specifies the number of 27 | dimensions. 28 | """ 29 | 30 | 31 | @type_of.dispatch 32 | def type_of(x: np.ndarray): 33 | '''Hook into Plum's type inference system to produce 34 | an appropriate instance of `NPArray` for NumPy arrays. 35 | ''' 36 | return NPArray[x.ndim] 37 | 38 | -------------------------------------------------------------------------------- /pydantic_panel/pandas.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from plum import dispatch 3 | 4 | import param 5 | import pandas as pd 6 | 7 | from pydantic.fields import FieldInfo 8 | from panel.widgets import DatetimeRangePicker, EditableRangeSlider 9 | 10 | from .dispatchers import clean_kwargs 11 | 12 | 13 | class PandasTimeIntervalEditor(DatetimeRangePicker): 14 | value = param.ClassSelector(class_=pd.Interval, default=None) 15 | 16 | def _serialize_value(self, value): 17 | value = super()._serialize_value(value) 18 | if any([v is None for v in value]): 19 | return None 20 | left = pd.Timestamp(value[0]) 21 | right = pd.Timestamp(value[1]) 22 | return pd.Interval(left, right) 23 | 24 | def _deserialize_value(self, value): 25 | if isinstance(value, pd.Interval): 26 | value = (value.left.to_pydatetime(), value.right.to_pydatetime()) 27 | 28 | value = super()._deserialize_value(value) 29 | return value 30 | 31 | @param.depends("start", "end", watch=True) 32 | def _update_value_bounds(self): 33 | pass 34 | 35 | 36 | class PandasIntervalEditor(EditableRangeSlider): 37 | 38 | value = param.ClassSelector(class_=pd.Interval, default=None) 39 | 40 | value_throttled = param.ClassSelector(class_=pd.Interval, default=None) 41 | 42 | @param.depends("value", watch=True) 43 | def _update_value(self): 44 | if self.value is None: 45 | return 46 | 47 | self._slider.value = (self.value.left, self.value.right) 48 | self._start_edit.value = self.value.left 49 | self._end_edit.value = self.value.right 50 | 51 | def _sync_value(self, event): 52 | with param.edit_constant(self): 53 | new_value = pd.Interval(left=event.new[0], right=event.new[1]) 54 | self.param.update(**{event.name: new_value}) 55 | 56 | def _sync_start_value(self, event): 57 | if event.name == "value": 58 | end = self.value.right if self.value else self.end 59 | else: 60 | end = self.value_throttled.right if self.value_throttled else self.end 61 | 62 | new_value = pd.Interval(left=event.new, right=end) 63 | 64 | with param.edit_constant(self): 65 | self.param.update(**{event.name: new_value}) 66 | 67 | def _sync_end_value(self, event): 68 | if event.name == "value": 69 | start = self.value.left if self.value else self.start 70 | else: 71 | start = self.value_throttled.left if self.value_throttled else self.start 72 | 73 | new_value = pd.Interval(left=start, right=event.new) 74 | with param.edit_constant(self): 75 | self.param.update(**{event.name: new_value}) 76 | 77 | 78 | class PandasIntegerIntervalEditor(PandasIntervalEditor): 79 | 80 | step = param.Integer(default=1, constant=True) 81 | 82 | format = param.String(default="0", constant=True) 83 | 84 | 85 | @dispatch 86 | def infer_widget(value: pd.Interval, field: Optional[FieldInfo] = None, **kwargs): 87 | if isinstance(value.left, pd.Timestamp) or isinstance(value.right, pd.Timestamp): 88 | kwargs = clean_kwargs(PandasTimeIntervalEditor, kwargs) 89 | return PandasTimeIntervalEditor(value=value, **kwargs) 90 | 91 | start = None 92 | end = None 93 | step = None 94 | if field is not None: 95 | start = field.field_info.ge or field.field_info.gt 96 | end = field.field_info.le or field.field_info.lt 97 | step = field.field_info.multiple_of 98 | 99 | if start is None: 100 | start = kwargs.get("start", 0) 101 | 102 | if end is None: 103 | end = kwargs.get("end", 1) 104 | 105 | if isinstance(value.left, int) or isinstance(value.right, int): 106 | 107 | if step is None: 108 | step = kwargs.get("step", 1) 109 | 110 | kwargs = clean_kwargs(PandasIntegerIntervalEditor, kwargs) 111 | return PandasIntegerIntervalEditor( 112 | value=value, step=step, start=start, end=end, **kwargs 113 | ) 114 | 115 | if step is None: 116 | step = kwargs.get("step", 0.001) 117 | 118 | kwargs = clean_kwargs(PandasIntervalEditor, kwargs) 119 | return PandasIntervalEditor(value=value, step=step, start=start, end=end, **kwargs) 120 | -------------------------------------------------------------------------------- /pydantic_panel/pane.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import param 4 | import pydantic 5 | from bokeh.document import Document 6 | from bokeh.model import Model 7 | from panel.io import init_doc, state 8 | from panel.layout import Panel, WidgetBox 9 | from panel.pane import PaneBase 10 | from pyviz_comms import Comm 11 | 12 | 13 | from typing import ( 14 | Any, 15 | ClassVar, 16 | Optional, 17 | ) 18 | 19 | from .dispatchers import infer_widget 20 | 21 | pyobject = object 22 | 23 | class Pydantic(PaneBase): 24 | """The Pydantic pane wraps your Pydantic model into a Panel component. 25 | 26 | You can use the component to edit your model or power your data app. 27 | 28 | Example: 29 | 30 | >>> from pydantic_panel import Pydantic 31 | >>> widget = Pydantic(SomePydanticModel) 32 | 33 | or alternatively 34 | 35 | >>> import pydantic_panel 36 | >>> widget = pn.panel(SomePydanticModel) 37 | 38 | Args: 39 | object (BaseModel): A Pydantic model 40 | default_layout (Panel): A WidgetBox, Row, Column or other Panel to 41 | layout the widgets. 42 | 43 | In addition you can use all the usual styling related arguments like 44 | height, width, sizing_mode etc. 45 | """ 46 | 47 | priority: ClassVar = None 48 | 49 | default_layout: Panel = param.ClassSelector( 50 | default=WidgetBox, class_=Panel, is_instance=False 51 | ) 52 | 53 | object = param.Parameter(default=None) 54 | 55 | def __init__(self, object=None, default_layout: Panel | None = None, **params): 56 | if default_layout: 57 | params["default_layout"] = default_layout 58 | 59 | pane_params = { 60 | name: params[name] for name in Pydantic.param.values() if name in params 61 | } 62 | 63 | super().__init__(object, **pane_params) 64 | 65 | if isinstance(object, type): 66 | 67 | if issubclass(object, pydantic.BaseModel): 68 | params["class_"] = object 69 | 70 | self.widget = infer_widget.invoke(object)(None, **params) 71 | self.widget.link(self, value="object") 72 | 73 | elif isinstance(object, pyobject): 74 | self.widget = infer_widget(object, **params) 75 | self.object = object 76 | 77 | else: 78 | raise 79 | 80 | self.layout = self.default_layout(self.widget) 81 | 82 | def _get_model( 83 | self, 84 | doc: Document, 85 | root: Optional[Model] = None, 86 | parent: Optional[Model] = None, 87 | comm: Optional[Comm] = None, 88 | ) -> Model: 89 | model = self.layout._get_model(doc, root, parent, comm) 90 | self._models[root.ref["id"]] = (model, parent) 91 | return model 92 | 93 | def _cleanup(self, root: Optional[Model] = None) -> None: 94 | self.layout._cleanup(root) 95 | super()._cleanup(root) 96 | 97 | @classmethod 98 | def applies(cls, obj: Any, **params) -> Optional[bool]: 99 | if isinstance(obj, param.Parameterized): 100 | return False 101 | 102 | if isinstance(obj, object): 103 | if isinstance(obj, pydantic.BaseModel): 104 | return 1 105 | elif isinstance(obj, list) and all( 106 | isinstance(o, pydantic.BaseModel) for o in obj 107 | ): 108 | return 1 109 | elif isinstance(obj, dict) and all( 110 | isinstance(o, pydantic.BaseModel) for o in obj.values() 111 | ): 112 | return 1 113 | try: 114 | infer_widget(obj) 115 | return 0.01 116 | except: 117 | pass 118 | 119 | if isinstance(obj, type): 120 | if issubclass(obj, pydantic.BaseModel): 121 | return 1 122 | try: 123 | infer_widget.invoke(obj) 124 | return 0.01 125 | except: 126 | pass 127 | 128 | return False 129 | 130 | def select(self, selector=None): 131 | """ 132 | Iterates over the Viewable and any potential children in the 133 | applying the Selector. 134 | 135 | Arguments 136 | --------- 137 | selector: type or callable or None 138 | The selector allows selecting a subset of Viewables by 139 | declaring a type or callable function to filter by. 140 | 141 | Returns 142 | ------- 143 | viewables: list(Viewable) 144 | """ 145 | return super().select(selector) + self.layout.select(selector) 146 | 147 | def get_root( 148 | self, 149 | doc: Optional[Document] = None, 150 | comm: Optional[Comm] = None, 151 | preprocess: bool = True, 152 | ) -> Model: 153 | """ 154 | Returns the root model and applies pre-processing hooks 155 | 156 | Arguments 157 | --------- 158 | doc: bokeh.Document 159 | Bokeh document the bokeh model will be attached to. 160 | comm: pyviz_comms.Comm 161 | Optional pyviz_comms when working in notebook 162 | preprocess: boolean (default=True) 163 | Whether to run preprocessing hooks 164 | 165 | Returns 166 | ------- 167 | Returns the bokeh model corresponding to this panel object 168 | """ 169 | doc = init_doc(doc) 170 | root = self.layout.get_root(doc, comm, preprocess) 171 | ref = root.ref["id"] 172 | self._models[ref] = (root, None) 173 | state._views[ref] = (self, root, doc, comm) 174 | return root 175 | -------------------------------------------------------------------------------- /pydantic_panel/widgets.py: -------------------------------------------------------------------------------- 1 | from ast import Import 2 | import param 3 | import pydantic 4 | 5 | from typing import Dict, List, Any, Optional, Type, ClassVar 6 | 7 | from pydantic import ValidationError, BaseModel 8 | from pydantic.fields import FieldInfo 9 | 10 | from plum import dispatch, NotFoundLookupError 11 | 12 | import param 13 | 14 | import panel as pn 15 | 16 | from panel.layout import Column, Divider, ListPanel, Card 17 | 18 | from panel.widgets import CompositeWidget, Button 19 | 20 | from .dispatchers import infer_widget, clean_kwargs 21 | 22 | from pydantic_panel import infer_widget 23 | from typing import ClassVar, Type, List, Dict, Tuple, Any 24 | 25 | # See https://github.com/holoviz/panel/issues/3736 26 | JSON_HACK_MARGIN = (10, 10) 27 | 28 | 29 | def get_theme(): 30 | return pn.state.session_args.get("theme", [b"default"])[0].decode() 31 | 32 | 33 | def get_json_theme(): 34 | if get_theme() == "dark": 35 | return "dark" 36 | return "light" 37 | 38 | 39 | class Config: 40 | """Pydantic Config overrides for monkey patching 41 | synchronization into a model. 42 | """ 43 | 44 | validate_assignment = True 45 | 46 | 47 | class pydantic_widgets(param.ParameterizedFunction): 48 | """Returns a dictionary of widgets to edit the fields 49 | of a pydantic model. 50 | """ 51 | 52 | model = param.ClassSelector(class_=pydantic.BaseModel, is_instance=False) 53 | 54 | aliases = param.Dict({}) 55 | 56 | widget_kwargs = param.Dict({}) 57 | defaults = param.Dict({}) 58 | use_model_aliases = param.Boolean(False) 59 | callback = param.Callable() 60 | 61 | def __call__(self, **params): 62 | 63 | p = param.ParamOverrides(self, params) 64 | 65 | if isinstance(p.model, BaseModel): 66 | self.defaults = {f: getattr(p.model, f, None) for f in p.model.model_fields} 67 | 68 | if p.use_model_aliases: 69 | default_aliases = { 70 | field.name: field.alias.capitalize() 71 | for field in p.model.model_fields.values() 72 | } 73 | else: 74 | default_aliases = { 75 | name: name.replace("_", " ").capitalize() for name in p.model.model_fields 76 | } 77 | 78 | aliases = params.get("aliases", default_aliases) 79 | 80 | widgets = {} 81 | for field_name, alias in aliases.items(): 82 | field = p.model.model_fields[field_name] 83 | 84 | value = p.defaults.get(field_name, None) 85 | 86 | if value is None: 87 | value = field.default 88 | 89 | try: 90 | widget_builder = infer_widget.invoke(field.annotation, field.__class__) 91 | widget = widget_builder( 92 | value, field, name=field_name, **p.widget_kwargs 93 | ) 94 | 95 | except (NotFoundLookupError, NotImplementedError): 96 | widget = infer_widget(value, field, name=field_name, **p.widget_kwargs) 97 | 98 | if p.callback is not None: 99 | widget.param.watch(p.callback, "value") 100 | 101 | widgets[field_name] = widget 102 | return widgets 103 | 104 | 105 | class PydanticModelEditor(CompositeWidget): 106 | """A composet widget whos value is a pydantic model and whos 107 | children widgets are synced with the model attributes 108 | 109 | """ 110 | 111 | _composite_type: ClassVar[Type[ListPanel]] = Column 112 | _trigger_recreate: ClassVar[List] = ["class_"] 113 | 114 | _widgets = param.Dict(default={}, constant=True) 115 | 116 | _updating = param.Boolean(False) 117 | _updating_field = param.Boolean(False) 118 | 119 | class_ = param.ClassSelector(class_=BaseModel, default=None, is_instance=False) 120 | 121 | fields = param.List([]) 122 | 123 | by_alias = param.Boolean(False) 124 | 125 | bidirectional = param.Boolean(False) 126 | 127 | value = param.ClassSelector(class_=(BaseModel, dict)) 128 | 129 | def __init__(self, **params): 130 | 131 | super().__init__(**params) 132 | self._recreate_widgets() 133 | self.param.watch(self._recreate_widgets, self._trigger_recreate) 134 | 135 | self.param.watch(self._update_value, "value") 136 | 137 | if self.value is not None: 138 | self.param.trigger("value") 139 | 140 | for w in self.widgets: 141 | w.param.trigger("value") 142 | 143 | @property 144 | def widgets(self): 145 | fields = self.fields if self.fields else list(self._widgets) 146 | return [self._widgets[field] for field in fields if field in self._widgets] 147 | 148 | def _recreate_widgets(self, *events): 149 | if self.class_ is None: 150 | self.value = None 151 | return 152 | 153 | widgets = pydantic_widgets( 154 | model=self.class_, 155 | defaults=dict(self.items()), 156 | callback=self._validate_field, 157 | use_model_aliases=self.by_alias, 158 | widget_kwargs=dict(bidirectional=self.bidirectional), 159 | ) 160 | 161 | with param.edit_constant(self): 162 | self._widgets = widgets 163 | 164 | self._composite[:] = self.widgets 165 | 166 | def _update_value(self, event: param.Event): 167 | 168 | if self._updating_field: 169 | return 170 | 171 | if self.value is None: 172 | for widget in self.widgets: 173 | try: 174 | widget.value = None 175 | except: 176 | pass 177 | return 178 | 179 | if self.class_ is None and isinstance(self.value, BaseModel): 180 | self.class_ = type(self.value) 181 | 182 | if isinstance(self.value, self.class_): 183 | for k, v in self.items(): 184 | if k in self._widgets: 185 | self._widgets[k].value = v 186 | else: 187 | self._recreate_widgets() 188 | self.param.trigger("value") 189 | return 190 | 191 | elif isinstance(self.value, dict) and not set(self.value).symmetric_difference( 192 | self._widgets 193 | ): 194 | self.value = self.class_(**self.value) 195 | return 196 | else: 197 | raise ValueError( 198 | f"value must be an instance of {self._class}" 199 | " or a dict matching its fields." 200 | ) 201 | 202 | # HACK for biderectional sync 203 | if self.value is not None and self.bidirectional: 204 | 205 | # We need to ensure the model validates on assignment 206 | if not self.value.model_config.get("validate_assignment", False): 207 | config = self.value.model_config.copy() 208 | config.update(validate_assignment=True) 209 | 210 | # Add a callback to the root validators 211 | # sync widgets to the changes made directly 212 | # to the model attributes 213 | add_setattr_callback(self.value, self._update_widget) 214 | 215 | 216 | # If the previous value was a model 217 | # instance we unlink it 218 | if id(self.value) != id(event.old) and isinstance(event.old, BaseModel): 219 | remove_setattr_callback(event.old, self._update_widget) 220 | 221 | def __del__(self): 222 | if self.value is not None and self.bidirectional: 223 | remove_setattr_callback(self.value, self._update_widget) 224 | 225 | def items(self): 226 | if self.value is None: 227 | return [] 228 | return [(name, getattr(self.value, name)) 229 | for name in self.value.model_fields] 230 | 231 | def _validate_field(self, event: param.Event): 232 | if not event or self._updating: 233 | return 234 | 235 | if self.value is None: 236 | if self.class_ is not None: 237 | try: 238 | data = {k: w.value for k, w in self._widgets.items()} 239 | self.value = self.class_(**data) 240 | except: 241 | pass 242 | return 243 | 244 | if self.value is None: 245 | return 246 | 247 | for name, widget in self._widgets.items(): 248 | if event.obj == widget: 249 | break 250 | else: 251 | return 252 | 253 | try: 254 | self.class_.__pydantic_validator__.validate_assignment(self.value, 255 | name, 256 | event.new) 257 | except ValidationError as e: 258 | self._updating = True 259 | try: 260 | event.obj.value = event.old 261 | self._updating_field = True 262 | self.param.trigger("value") 263 | self._updating_field = False 264 | finally: 265 | self._updating = False 266 | raise e 267 | 268 | def _update_widget(self, name, value): 269 | if self._updating: 270 | return 271 | 272 | if name in self._widgets: 273 | self._updating = True 274 | try: 275 | self._widgets[name].value = value 276 | finally: 277 | self._updating = False 278 | 279 | def _update_widgets(self, cls, values): 280 | if self.value is None: 281 | return 282 | 283 | if self._updating: 284 | return values 285 | 286 | self._updating = True 287 | try: 288 | for k, w in self._widgets.items(): 289 | if k not in values: 290 | continue 291 | val = values[k] 292 | if w.value != val: 293 | w.value = val 294 | finally: 295 | self._updating = False 296 | 297 | return values 298 | 299 | @pn.depends("value") 300 | def json(self): 301 | if self.value is None: 302 | return pn.pane.JSON( 303 | width=self.width, 304 | sizing_mode="stretch_both", 305 | theme=get_json_theme(), 306 | margin=JSON_HACK_MARGIN, 307 | ) 308 | 309 | return pn.pane.JSON( 310 | object=self.value.json(), 311 | width=self.width, 312 | sizing_mode="stretch_both", 313 | theme=get_json_theme(), 314 | margin=JSON_HACK_MARGIN, 315 | ) 316 | 317 | 318 | def add_setattr_callback(model_instance: BaseModel, callback: callable): 319 | """Syncs the fields of a pydantic model with a dictionary of widgets 320 | 321 | Args: 322 | model_instance (BaseModel): The model instance to sync 323 | callback (callable): The callback function to sync the fields 324 | 325 | Returns: 326 | callback: A callback function that can be used to unsync the fields 327 | """ 328 | 329 | class_ = model_instance.__class__ 330 | if hasattr(class_, "__panel_callbacks__"): 331 | class_.__panel_callbacks__ += (callback,) 332 | else: 333 | class ModifiedModel(class_): 334 | __panel_callbacks__ = (callback,) 335 | 336 | def __setattr__(self, name, value): 337 | super().__setattr__(name, value) 338 | if not hasattr(self.__class__, "__panel_callbacks__"): 339 | return 340 | for cb in self.__class__.__panel_callbacks__: 341 | cb(name, value) 342 | 343 | model_instance.__class__ = ModifiedModel 344 | 345 | return callback 346 | 347 | def remove_setattr_callback(model_instance: BaseModel, callback: callable): 348 | """Unsyncs the fields of a pydantic model with a dictionary of widgets 349 | 350 | Args: 351 | model_instance (BaseModel): The model instance to unsync 352 | 353 | Returns: 354 | None 355 | """ 356 | class_ = model_instance.__class__ 357 | 358 | if hasattr(class_, "__panel_callbacks__"): 359 | class_.__panel_callbacks__ = tuple( 360 | cb for cb in class_.__panel_callbacks__ if cb is not callback 361 | ) 362 | else: 363 | return 364 | 365 | if class_.__panel_callbacks__: 366 | return 367 | 368 | for class_ in model_instance.__class__.mro(): 369 | if hasattr(class_, "__panel_callbacks__"): 370 | continue 371 | model_instance.__class__ = class_ 372 | 373 | 374 | class PydanticModelEditorCard(PydanticModelEditor): 375 | """Same as PydanticModelEditor but uses a Card container 376 | to hold the widgets and synces the header with the widget `name` 377 | """ 378 | 379 | _composite_type: ClassVar[Type[ListPanel]] = Card 380 | collapsed = param.Boolean(False) 381 | 382 | def __init__(self, **params): 383 | super().__init__(**params) 384 | self._composite.header = self.name 385 | self.link(self._composite, name="header") 386 | self.link(self._composite, collapsed="collapsed") 387 | 388 | 389 | class BaseCollectionEditor(CompositeWidget): 390 | """Composite widget for editing a collections of items""" 391 | 392 | _composite_type: ClassVar[Type[ListPanel]] = Column 393 | 394 | _new_editor = param.Parameter() 395 | 396 | _widgets = param.Dict({}) 397 | 398 | allow_add = param.Boolean(True) 399 | allow_remove = param.Boolean(True) 400 | 401 | item_added = param.Event() 402 | item_removed = param.Event() 403 | 404 | expand = param.Boolean(True) 405 | 406 | class_ = param.ClassSelector(class_=object, is_instance=False) 407 | 408 | item_field = param.ClassSelector(class_=FieldInfo, default=None, allow_None=True) 409 | 410 | default_item = param.Parameter(default=None) 411 | 412 | value = param.Parameter(default=None) 413 | 414 | __abstract = True 415 | 416 | def __init__(self, **params): 417 | super().__init__(**params) 418 | self.param.watch(self._value_changed, "value") 419 | self.param.trigger("value") 420 | 421 | def _panel_for(self, name, widget): 422 | if isinstance(widget, CompositeWidget): 423 | panel = Card(widget, header=str(name), collapsed=not self.expand) 424 | else: 425 | widget.width = 200 426 | panel = pn.Row(widget) 427 | 428 | if self.allow_remove: 429 | remove_button = Button(name="❌", width=50, width_policy="auto", align="end") 430 | 431 | def cb(event): 432 | self.remove_item(name) 433 | 434 | remove_button.on_click(cb) 435 | panel.append(remove_button) 436 | return panel 437 | 438 | def _create_widgets(self, *events, reset=True): 439 | if reset: 440 | self._widgets = {} 441 | for name, item in self.items(): 442 | widget = self._widget_for(name, item) 443 | 444 | def cb(event): 445 | self.sync_item(name) 446 | 447 | widget.param.watch(cb, "value") 448 | self._widgets[name] = widget 449 | 450 | def _update_panels(self, *events): 451 | panels = [ 452 | self._panel_for(name, widget) for name, widget in self._widgets.items() 453 | ] 454 | if self.name: 455 | panels.insert(0, pn.panel(f"### {self.name.capitalize()}")) 456 | panels.append(pn.panel(self._controls)) 457 | panels.append(Divider()) 458 | 459 | self._composite[:] = panels 460 | 461 | def _sync_widgets(self, *events): 462 | for name, item in self.items(): 463 | widget = self._widgets.get(name, None) 464 | if widget is None: 465 | continue 466 | with param.parameterized.discard_events(widget): 467 | widget.value = item 468 | 469 | def _value_changed(self, *event): 470 | if not self.value: 471 | self._widgets = {} 472 | self._update_panels() 473 | return 474 | if set(self._widgets).symmetric_difference(self.keys()): 475 | self._create_widgets() 476 | self._update_panels() 477 | else: 478 | self._sync_widgets() 479 | 480 | def _controls(self): 481 | return pn.Column() 482 | 483 | def keys(self): 484 | raise NotImplementedError 485 | 486 | def values(self): 487 | raise NotImplementedError 488 | 489 | def items(self) -> list[Tuple[str, Any]]: 490 | raise NotImplementedError 491 | 492 | def add_item(self, item, name=None): 493 | raise NotImplementedError 494 | 495 | def remove_item(self, name): 496 | raise NotImplementedError 497 | 498 | def sync_item(self, name): 499 | raise NotImplementedError 500 | 501 | def _widget_for(self, name, item): 502 | raise NotImplementedError 503 | 504 | def _sync_values(self, *events): 505 | raise NotImplementedError 506 | 507 | 508 | class ItemListEditor(BaseCollectionEditor): 509 | 510 | value = param.List(default=[]) 511 | 512 | def keys(self): 513 | return list(range(len(self.value))) 514 | 515 | def values(self): 516 | return list(self.value) 517 | 518 | def items(self) -> list[Tuple[str, Any]]: 519 | return list(enumerate(self.value)) 520 | 521 | def add_item(self, item, name=None): 522 | if name is None: 523 | name = len(self.value) 524 | idx = int(name) 525 | self.value.insert(idx, item) 526 | self.param.trigger("value") 527 | self.item_added = True 528 | 529 | def remove_item(self, name): 530 | self.value.pop(int(name)) 531 | self.param.trigger("value") 532 | self.item_removed = True 533 | 534 | def sync_item(self, name): 535 | idx = int(name) 536 | self.value[idx] = self._widgets[idx].value 537 | self.param.trigger("value") 538 | 539 | def _add_new_cb(self, event): 540 | self.add_item(self.default_value) 541 | 542 | @param.depends("class_", "allow_add") 543 | def _controls(self): 544 | if self.allow_add and self.class_ is not None: 545 | editor = self._widget_for(len(self.value), self.default_item) 546 | 547 | def cb(event): 548 | if editor.value is not None: 549 | self.add_item(editor.value) 550 | 551 | if isinstance(editor, CompositeWidget): 552 | add_button = Button(name="✅ Insert") 553 | add_button.on_click(cb) 554 | return Card( 555 | editor, 556 | add_button, 557 | header="➕ Add", 558 | collapsed=True, 559 | width_policy="min", 560 | ) 561 | else: 562 | add_button = Button( 563 | name="➕", width=50, width_policy="auto", align="end" 564 | ) 565 | add_button.on_click(cb) 566 | editor.width = 200 567 | return pn.Row(editor, add_button) 568 | return pn.Column() 569 | 570 | def _widget_for(self, name, item): 571 | if item is None: 572 | return infer_widget.invoke(self.class_, None)( 573 | self.default_item, self.item_field, class_=self.class_, name=str(name) 574 | ) 575 | return infer_widget(item, self.item_field, name=str(name)) 576 | 577 | def _sync_values(self, *events): 578 | with param.parameterized.discard_events(self): 579 | self.value = [self._widgets[name].value for name in self.keys()] 580 | 581 | 582 | class ItemDictEditor(BaseCollectionEditor): 583 | value = param.Dict( 584 | default={}, 585 | ) 586 | 587 | key_type = param.ClassSelector(class_=object, default=str, is_instance=False) 588 | 589 | default_key = param.Parameter(default="") 590 | 591 | def keys(self): 592 | return list(self.value) 593 | 594 | def values(self): 595 | return list(self.value.values()) 596 | 597 | def items(self) -> list[tuple[str, Any]]: 598 | return list(self.value.items()) 599 | 600 | def add_item(self, item, name=None): 601 | if name is None: 602 | name = self.default_key 603 | self.value[name] = item 604 | self.param.trigger("value") 605 | self.item_added = True 606 | 607 | def remove_item(self, name): 608 | self.value.pop(name, None) 609 | self.param.trigger("value") 610 | self.item_removed = True 611 | 612 | def sync_item(self, name): 613 | self.value[name] = self._widgets[name].value 614 | self.param.trigger("value") 615 | 616 | def _widget_for(self, name, item): 617 | if item is None: 618 | return infer_widget.invoke(self.class_, self.item_field)( 619 | item, self.item_field, class_=self.class_, name=str(name) 620 | ) 621 | 622 | return infer_widget(item, self.item_field, name=str(name)) 623 | 624 | def _sync_values(self, *events): 625 | with param.parameterized.discard_events(self): 626 | self.value = {name: self._widgets[name].value for name in self.keys()} 627 | 628 | @param.depends("class_", "allow_add") 629 | def _controls(self): 630 | if self.allow_add and self.class_ is not None: 631 | key_editor = infer_widget(self.default_key, None, name="Key", max_length=50) 632 | editor = self._widget_for(self.default_key, self.default_item) 633 | editor.name = "Value" 634 | 635 | def cb(event): 636 | if editor.value is None: 637 | self.add_item(editor.value, key_editor.value) 638 | 639 | add_button = Button(name="✅ Insert") 640 | add_button.on_click(cb) 641 | 642 | return Card( 643 | key_editor, 644 | editor, 645 | add_button, 646 | header="➕ Add", 647 | collapsed=True, 648 | width_policy="min", 649 | ) 650 | return pn.Column() 651 | 652 | 653 | @dispatch 654 | def infer_widget(value: BaseModel, field: Optional[FieldInfo] = None, **kwargs): 655 | if field is None: 656 | class_ = kwargs.pop("class_", type(value)) 657 | return PydanticModelEditor(value=value, class_=class_, **kwargs) 658 | 659 | class_ = kwargs.pop("class_", field.annotation) 660 | kwargs = clean_kwargs(PydanticModelEditorCard, kwargs) 661 | return PydanticModelEditorCard(value=value, class_=class_, **kwargs) 662 | 663 | 664 | @dispatch 665 | def infer_widget(value: list[BaseModel], field: Optional[FieldInfo] = None, **kwargs): 666 | 667 | if field is not None: 668 | kwargs["class_"] = kwargs.pop("class_", field.annotation) 669 | if value is None: 670 | value = field.default 671 | 672 | if value is None: 673 | value = [] 674 | kwargs = clean_kwargs(ItemListEditor, kwargs) 675 | return ItemListEditor(value=value, **kwargs) 676 | 677 | 678 | @dispatch 679 | def infer_widget( 680 | value: dict[str, BaseModel], field: Optional[FieldInfo] = None, **kwargs 681 | ): 682 | 683 | if field is not None: 684 | kwargs["class_"] = kwargs.pop("class_", field.annotation) 685 | if value is None: 686 | value = field.default 687 | 688 | if value is None: 689 | value = {} 690 | 691 | kwargs["key_type"] = kwargs.pop("key_type", str) 692 | kwargs = clean_kwargs(ItemDictEditor, kwargs) 693 | return ItemDictEditor(value=value, **kwargs) 694 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool] 2 | [tool.poetry] 3 | name = "pydantic_panel" 4 | version = "0.2.0" 5 | homepage = "https://github.com/jmosbacher/pydantic_panel" 6 | description = "Top-level package for pydantic-panel." 7 | authors = ["Yossi Mosbacher "] 8 | readme = "README.rst" 9 | license = "MIT" 10 | classifiers=[ 11 | 'Development Status :: 2 - Pre-Alpha', 12 | 'Intended Audience :: Developers', 13 | 'License :: OSI Approved :: MIT License', 14 | 'Natural Language :: English', 15 | 'Programming Language :: Python :: 3.8', 16 | 'Programming Language :: Python :: 3.9', 17 | 'Programming Language :: Python :: 3.10', 18 | 19 | ] 20 | packages = [ 21 | { include = "pydantic_panel" }, 22 | { include = "tests", format = "sdist" }, 23 | ] 24 | 25 | [tool.poetry.dependencies] 26 | python = ">=3.8,<4.0" 27 | panel = ">=0.13" 28 | pydantic = ">=2.0" 29 | plum-dispatch = "*" 30 | 31 | 32 | [tool.poetry.dev-dependencies] 33 | bumpversion = "*" 34 | coverage = "*" 35 | flake8 = "*" 36 | isort = "*" 37 | pylint = "*" 38 | pytest = "*" 39 | sphinx = "^5.0.2" 40 | tox = "*" 41 | yapf = "*" 42 | sphinx-material = "*" 43 | nbsphinx = "*" 44 | invoke = "*" 45 | twine = "^4.0.1" 46 | black = "^22.6.0" 47 | pytest-cov = "^3.0.0" 48 | 49 | [tool.poetry.plugins."panel.extension"] 50 | pydantic = 'pydantic_panel' 51 | 52 | [build-system] 53 | requires = ["poetry-core>=1.0.8", "setuptools"] 54 | build-backend = "poetry.core.masonry.api" 55 | 56 | [tool.dephell.main] 57 | versioning = "semver" 58 | from = {format = "poetry", path = "pyproject.toml"} 59 | to = {format = "setuppy", path = "setup.py"} 60 | 61 | [tool.poe.tasks] 62 | test = "pytest --cov=pydantic_panel" 63 | format = "black ." 64 | clean = """ 65 | rm -rf .coverage 66 | .mypy_cache 67 | .pytest_cache 68 | dist 69 | ./**/__pycache__ 70 | """ 71 | lint = "pylint pydantic_panel" 72 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tasks for maintaining the project. 3 | 4 | Execute 'invoke --list' for guidance on using Invoke 5 | """ 6 | import shutil 7 | import platform 8 | 9 | from invoke import task 10 | from pathlib import Path 11 | import webbrowser 12 | 13 | 14 | ROOT_DIR = Path(__file__).parent 15 | SETUP_FILE = ROOT_DIR.joinpath("setup.py") 16 | TEST_DIR = ROOT_DIR.joinpath("tests") 17 | SOURCE_DIR = ROOT_DIR.joinpath("pydantic_panel") 18 | TOX_DIR = ROOT_DIR.joinpath(".tox") 19 | COVERAGE_FILE = ROOT_DIR.joinpath(".coverage") 20 | COVERAGE_DIR = ROOT_DIR.joinpath("htmlcov") 21 | COVERAGE_REPORT = COVERAGE_DIR.joinpath("index.html") 22 | DOCS_DIR = ROOT_DIR.joinpath("docs") 23 | DOCS_BUILD_DIR = DOCS_DIR.joinpath("_build") 24 | DOCS_INDEX = DOCS_BUILD_DIR.joinpath("index.html") 25 | PYTHON_DIRS = [str(d) for d in [SOURCE_DIR, TEST_DIR]] 26 | 27 | 28 | def _delete_file(file): 29 | try: 30 | file.unlink(missing_ok=True) 31 | except TypeError: 32 | # missing_ok argument added in 3.8 33 | try: 34 | file.unlink() 35 | except FileNotFoundError: 36 | pass 37 | 38 | 39 | def _run(c, command): 40 | return c.run(command, pty=platform.system() != "Windows") 41 | 42 | 43 | @task(help={"check": "Checks if source is formatted without applying changes"}) 44 | def format(c, check=False): 45 | """ 46 | Format code 47 | """ 48 | python_dirs_string = " ".join(PYTHON_DIRS) 49 | # Run yapf 50 | yapf_options = "--recursive {}".format("--diff" if check else "--in-place") 51 | _run(c, "yapf {} {}".format(yapf_options, python_dirs_string)) 52 | # Run isort 53 | isort_options = "--recursive {}".format("--check-only --diff" if check else "") 54 | _run(c, "isort {} {}".format(isort_options, python_dirs_string)) 55 | 56 | 57 | @task 58 | def lint_flake8(c): 59 | """ 60 | Lint code with flake8 61 | """ 62 | _run(c, "flake8 {}".format(" ".join(PYTHON_DIRS))) 63 | 64 | 65 | @task 66 | def lint_pylint(c): 67 | """ 68 | Lint code with pylint 69 | """ 70 | _run(c, "pylint {}".format(" ".join(PYTHON_DIRS))) 71 | 72 | 73 | @task(lint_flake8, lint_pylint) 74 | def lint(c): 75 | """ 76 | Run all linting 77 | """ 78 | 79 | 80 | @task 81 | def test(c): 82 | """ 83 | Run tests 84 | """ 85 | _run(c, "pytest") 86 | 87 | 88 | @task(help={"publish": "Publish the result via coveralls"}) 89 | def coverage(c, publish=False): 90 | """ 91 | Create coverage report 92 | """ 93 | _run(c, "coverage run --source {} -m pytest".format(SOURCE_DIR)) 94 | _run(c, "coverage report") 95 | if publish: 96 | # Publish the results via coveralls 97 | _run(c, "coveralls") 98 | else: 99 | # Build a local report 100 | _run(c, "coverage html") 101 | webbrowser.open(COVERAGE_REPORT.as_uri()) 102 | 103 | 104 | @task(help={"launch": "Launch documentation in the web browser"}) 105 | def docs(c, launch=True): 106 | """ 107 | Generate documentation 108 | """ 109 | _run(c, "sphinx-build -b html {} {}".format(DOCS_DIR, DOCS_BUILD_DIR)) 110 | if launch: 111 | webbrowser.open(DOCS_INDEX.as_uri()) 112 | 113 | 114 | @task 115 | def clean_docs(c): 116 | """ 117 | Clean up files from documentation builds 118 | """ 119 | _run(c, "rm -fr {}".format(DOCS_BUILD_DIR)) 120 | 121 | 122 | @task 123 | def clean_build(c): 124 | """ 125 | Clean up files from package building 126 | """ 127 | _run(c, "rm -fr build/") 128 | _run(c, "rm -fr dist/") 129 | _run(c, "rm -fr .eggs/") 130 | _run(c, "find . -name '*.egg-info' -exec rm -fr {} +") 131 | _run(c, "find . -name '*.egg' -exec rm -f {} +") 132 | 133 | 134 | @task 135 | def clean_python(c): 136 | """ 137 | Clean up python file artifacts 138 | """ 139 | _run(c, "find . -name '*.pyc' -exec rm -f {} +") 140 | _run(c, "find . -name '*.pyo' -exec rm -f {} +") 141 | _run(c, "find . -name '*~' -exec rm -f {} +") 142 | _run(c, "find . -name '__pycache__' -exec rm -fr {} +") 143 | 144 | 145 | @task 146 | def clean_tests(c): 147 | """ 148 | Clean up files from testing 149 | """ 150 | _delete_file(COVERAGE_FILE) 151 | shutil.rmtree(TOX_DIR, ignore_errors=True) 152 | shutil.rmtree(COVERAGE_DIR, ignore_errors=True) 153 | 154 | 155 | @task(pre=[clean_build, clean_python, clean_tests, clean_docs]) 156 | def clean(c): 157 | """ 158 | Runs all clean sub-tasks 159 | """ 160 | pass 161 | 162 | 163 | @task(clean) 164 | def dist(c): 165 | """ 166 | Build source and wheel packages 167 | """ 168 | _run(c, "poetry build") 169 | 170 | 171 | @task(pre=[clean, dist]) 172 | def release(c): 173 | """ 174 | Make a release of the python package to pypi 175 | """ 176 | _run(c, "poetry publish") 177 | 178 | 179 | @task(help={"version": "major/minor/path or explicit version"}) 180 | def bump(c, version="patch"): 181 | """ 182 | Bump version 183 | """ 184 | _run(c, "bump2version {}".format(version)) 185 | _run(c, "git push") 186 | _run(c, "git push --tags") 187 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for pydantic_panel.""" 2 | -------------------------------------------------------------------------------- /tests/test_pydantic_panel.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Tests for `pydantic_panel` package.""" 3 | # pylint: disable=redefined-outer-name 4 | 5 | import pydantic_panel 6 | import pytest 7 | import panel as pn 8 | from pydantic import BaseModel 9 | 10 | 11 | class SomeModel(BaseModel): 12 | regular_string: str = "string" 13 | regular_int: int = 42 14 | regular_float: float = 0.999 15 | 16 | 17 | alt_data = dict( 18 | regular_string="string2", 19 | regular_int=666, 20 | regular_float=0.111, 21 | ) 22 | 23 | 24 | def test_panel_model_class(): 25 | w = pn.panel(SomeModel) 26 | assert isinstance(w, pydantic_panel.PydanticModelEditor) 27 | assert w.value == SomeModel() 28 | 29 | 30 | def test_panel_model_instance(): 31 | w = pn.panel(SomeModel()) 32 | assert isinstance(w, pydantic_panel.PydanticModelEditor) 33 | assert w.value == SomeModel() 34 | 35 | 36 | def test_set_data(): 37 | m = SomeModel() 38 | w = pn.panel(m) 39 | for k, v in alt_data.items(): 40 | w._widgets[k].value = v 41 | assert getattr(w.value, k) == v 42 | assert w.value == m 43 | 44 | 45 | def test_bidirectional(): 46 | m = SomeModel() 47 | w = pn.panel(m, bidirectional=True) 48 | for k, v in alt_data.items(): 49 | setattr(m, k, v) 50 | assert w._widgets[k].value == v 51 | assert w.value == m 52 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = true 3 | envlist = py38, py39, py310, lint, format 4 | 5 | [travis] 6 | python = 7 | 3.8: py38 8 | 3.9: py39 9 | 3.10: py310 10 | 11 | 12 | [testenv:lint] 13 | basepython = python 14 | commands = poetry run invoke lint 15 | 16 | [testenv:format] 17 | basepython = python 18 | commands = poetry run invoke format --check 19 | 20 | [testenv] 21 | ; If you want to make tox run the tests with the same versions, commit 22 | ; the poetry.lock to source control 23 | commands_pre = poetry install 24 | commands = poetry run invoke test 25 | 26 | 27 | --------------------------------------------------------------------------------