From 4f9cdd6472f9b5690db5a29b27982268b402cf75 Mon Sep 17 00:00:00 2001 From: Martin Xu <15661672+martinxu9@users.noreply.github.com> Date: Wed, 28 Feb 2024 15:25:26 -0800 Subject: [PATCH] Add support for custom components starter (#2314) --- .coveragerc | 2 +- poetry.lock | 181 ++---- pyproject.toml | 3 + .../jinja/custom_components/README.md.jinja2 | 9 + .../custom_components/__init__.py.jinja2 | 1 + .../custom_components/demo_app.py.jinja2 | 36 ++ .../custom_components/pyproject.toml.jinja2 | 35 ++ .../jinja/custom_components/src.py.jinja2 | 57 ++ reflex/compiler/templates.py | 17 + reflex/constants/__init__.py | 4 + reflex/constants/custom_components.py | 30 + reflex/custom_components/__init__.py | 1 + reflex/custom_components/custom_components.py | 565 ++++++++++++++++++ reflex/reflex.py | 6 + 14 files changed, 828 insertions(+), 119 deletions(-) create mode 100644 reflex/.templates/jinja/custom_components/README.md.jinja2 create mode 100644 reflex/.templates/jinja/custom_components/__init__.py.jinja2 create mode 100644 reflex/.templates/jinja/custom_components/demo_app.py.jinja2 create mode 100644 reflex/.templates/jinja/custom_components/pyproject.toml.jinja2 create mode 100644 reflex/.templates/jinja/custom_components/src.py.jinja2 create mode 100644 reflex/constants/custom_components.py create mode 100644 reflex/custom_components/__init__.py create mode 100644 reflex/custom_components/custom_components.py diff --git a/.coveragerc b/.coveragerc index 8c3df0fa4..51d2ab615 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,7 +5,7 @@ branch = true [report] show_missing = true # TODO bump back to 79 -fail_under = 69 +fail_under = 68 precision = 2 # Regexes for lines to exclude from consideration diff --git a/poetry.lock b/poetry.lock index 15d70b3ef..eb1eeaa98 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "alembic" version = "1.13.1" description = "A database migration tool for SQLAlchemy." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -26,7 +25,6 @@ tz = ["backports.zoneinfo"] name = "anyio" version = "4.3.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -49,7 +47,6 @@ trio = ["trio (>=0.23)"] name = "async-timeout" version = "4.0.3" description = "Timeout context manager for asyncio programs" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -61,7 +58,6 @@ files = [ name = "asynctest" version = "0.13.0" description = "Enhance the standard unittest package with features for testing asyncio libraries" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -73,7 +69,6 @@ files = [ name = "attrs" version = "23.2.0" description = "Classes Without Boilerplate" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -93,7 +88,6 @@ tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "p name = "bidict" version = "0.23.1" description = "The bidirectional mapping library for Python." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -105,7 +99,6 @@ files = [ name = "black" version = "22.12.0" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -137,11 +130,34 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "build" +version = "1.0.3" +description = "A simple, correct Python build frontend" +optional = false +python-versions = ">= 3.7" +files = [ + {file = "build-1.0.3-py3-none-any.whl", hash = "sha256:589bf99a67df7c9cf07ec0ac0e5e2ea5d4b37ac63301c4986d1acb126aa83f8f"}, + {file = "build-1.0.3.tar.gz", hash = "sha256:538aab1b64f9828977f84bc63ae570b060a8ed1be419e7870b8b4fc5e6ea553b"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} +packaging = ">=19.0" +pyproject_hooks = "*" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2023.08.17)", "sphinx (>=7.0,<8.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)", "sphinx-issues (>=3.0.0)"] +test = ["filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "setuptools (>=42.0.0)", "setuptools (>=56.0.0)", "setuptools (>=56.0.0)", "setuptools (>=67.8.0)", "wheel (>=0.36.0)"] +typing = ["importlib-metadata (>=5.1)", "mypy (>=1.5.0,<1.6.0)", "tomli", "typing-extensions (>=3.7.4.3)"] +virtualenv = ["virtualenv (>=20.0.35)"] + [[package]] name = "certifi" version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -153,7 +169,6 @@ files = [ name = "cffi" version = "1.16.0" description = "Foreign Function Interface for Python calling C code." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -218,7 +233,6 @@ pycparser = "*" name = "cfgv" version = "3.4.0" description = "Validate configuration and produce human readable error messages." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -230,7 +244,6 @@ files = [ name = "charset-normalizer" version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -330,7 +343,6 @@ files = [ name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -345,7 +357,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "cloudpickle" version = "2.2.1" description = "Extended pickling support for Python objects" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -357,7 +368,6 @@ files = [ name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -369,7 +379,6 @@ files = [ name = "coverage" version = "7.4.1" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -437,7 +446,6 @@ toml = ["tomli"] name = "darglint" version = "1.8.1" description = "A utility for ensuring Google-style docstrings stay up to date with the source code." -category = "dev" optional = false python-versions = ">=3.6,<4.0" files = [ @@ -449,7 +457,6 @@ files = [ name = "distlib" version = "0.3.8" description = "Distribution utilities" -category = "dev" optional = false python-versions = "*" files = [ @@ -461,7 +468,6 @@ files = [ name = "distro" version = "1.9.0" description = "Distro - an OS platform information API" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -473,7 +479,6 @@ files = [ name = "docopt" version = "0.6.2" description = "Pythonic argument parser, that will make you smile" -category = "main" optional = false python-versions = "*" files = [ @@ -484,7 +489,6 @@ files = [ name = "exceptiongroup" version = "1.2.0" description = "Backport of PEP 654 (exception groups)" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -499,7 +503,6 @@ test = ["pytest (>=6)"] name = "fastapi" version = "0.96.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -521,7 +524,6 @@ test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6 name = "filelock" version = "3.13.1" description = "A platform independent file lock." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -538,7 +540,6 @@ typing = ["typing-extensions (>=4.8)"] name = "greenlet" version = "3.0.3" description = "Lightweight in-process concurrent programming" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -610,7 +611,6 @@ test = ["objgraph", "psutil"] name = "gunicorn" version = "20.1.0" description = "WSGI HTTP Server for UNIX" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -631,7 +631,6 @@ tornado = ["tornado (>=0.2)"] name = "h11" version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -643,7 +642,6 @@ files = [ name = "httpcore" version = "1.0.3" description = "A minimal low-level HTTP client." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -658,14 +656,13 @@ h11 = ">=0.13,<0.15" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] trio = ["trio (>=0.22.0,<0.24.0)"] [[package]] name = "httpx" version = "0.25.2" description = "The next generation HTTP client." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -676,21 +673,20 @@ files = [ [package.dependencies] anyio = "*" certifi = "*" -httpcore = ">=1.0.0,<2.0.0" +httpcore = "==1.*" idna = "*" sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "identify" version = "2.5.35" description = "File identification library for Python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -705,7 +701,6 @@ license = ["ukkonen"] name = "idna" version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -717,7 +712,6 @@ files = [ name = "importlib-metadata" version = "7.0.1" description = "Read metadata from Python packages" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -737,7 +731,6 @@ testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs name = "importlib-resources" version = "6.1.1" description = "Read resources from Python packages" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -756,7 +749,6 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -768,7 +760,6 @@ files = [ name = "jinja2" version = "3.1.3" description = "A very fast and expressive template engine." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -786,7 +777,6 @@ i18n = ["Babel (>=2.7)"] name = "mako" version = "1.3.2" description = "A super-fast templating language that borrows the best ideas from the existing templating languages." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -806,7 +796,6 @@ testing = ["pytest"] name = "markdown-it-py" version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -831,7 +820,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "markupsafe" version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -901,7 +889,6 @@ files = [ name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -913,7 +900,6 @@ files = [ name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -925,7 +911,6 @@ files = [ name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" -category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -940,7 +925,6 @@ setuptools = "*" name = "numpy" version = "1.24.4" description = "Fundamental package for array computing in Python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -978,7 +962,6 @@ files = [ name = "numpy" version = "1.26.4" description = "Fundamental package for array computing in Python" -category = "dev" optional = false python-versions = ">=3.9" files = [ @@ -1024,7 +1007,6 @@ files = [ name = "outcome" version = "1.3.0.post0" description = "Capture the outcome of Python function calls." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1039,7 +1021,6 @@ attrs = ">=19.2.0" name = "packaging" version = "23.2" description = "Core utilities for Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1051,7 +1032,6 @@ files = [ name = "pandas" version = "1.5.3" description = "Powerful data structures for data analysis, time series, and statistics" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1096,7 +1076,6 @@ test = ["hypothesis (>=5.5.3)", "pytest (>=6.0)", "pytest-xdist (>=1.31)"] name = "pandas" version = "2.2.0" description = "Powerful data structures for data analysis, time series, and statistics" -category = "dev" optional = false python-versions = ">=3.9" files = [ @@ -1169,7 +1148,6 @@ xml = ["lxml (>=4.9.2)"] name = "pathspec" version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1181,7 +1159,6 @@ files = [ name = "pillow" version = "10.2.0" description = "Python Imaging Library (Fork)" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1267,7 +1244,6 @@ xmp = ["defusedxml"] name = "pipdeptree" version = "2.14.0" description = "Command line utility to show dependency tree of packages." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1283,7 +1259,6 @@ test = ["covdefaults (>=2.3)", "diff-cover (>=8.0.1)", "pip (>=23.3.1)", "pytest name = "pipreqs" version = "0.4.13" description = "Pip requirements.txt generator based on imports in project" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1299,7 +1274,6 @@ yarg = "*" name = "platformdirs" version = "3.11.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1315,7 +1289,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co name = "plotly" version = "5.19.0" description = "An open-source, interactive data visualization library for Python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1331,7 +1304,6 @@ tenacity = ">=6.2.0" name = "pluggy" version = "1.4.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1347,7 +1319,6 @@ testing = ["pytest", "pytest-benchmark"] name = "pre-commit" version = "3.5.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1366,7 +1337,6 @@ virtualenv = ">=20.10.0" name = "psutil" version = "5.9.8" description = "Cross-platform lib for process and system monitoring in Python." -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -1395,7 +1365,6 @@ test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] name = "py-cpuinfo" version = "9.0.0" description = "Get CPU info with pure Python" -category = "dev" optional = false python-versions = "*" files = [ @@ -1407,7 +1376,6 @@ files = [ name = "pycparser" version = "2.21" description = "C parser in Python" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1419,7 +1387,6 @@ files = [ name = "pydantic" version = "1.10.14" description = "Data validation and settings management using python type hints" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1472,7 +1439,6 @@ email = ["email-validator (>=1.0.3)"] name = "pygments" version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1484,11 +1450,24 @@ files = [ plugins = ["importlib-metadata"] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyproject-hooks" +version = "1.0.0" +description = "Wrappers to call pyproject.toml-based build backend hooks." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyproject_hooks-1.0.0-py3-none-any.whl", hash = "sha256:283c11acd6b928d2f6a7c73fa0d01cb2bdc5f07c57a2eeb6e83d5e56b97976f8"}, + {file = "pyproject_hooks-1.0.0.tar.gz", hash = "sha256:f271b298b97f5955d53fb12b72c1fb1948c22c1a6b70b315c54cedaca0264ef5"}, +] + +[package.dependencies] +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + [[package]] name = "pyright" version = "1.1.334" description = "Command line wrapper for pyright" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1507,7 +1486,6 @@ dev = ["twine (>=3.4.1)"] name = "pysocks" version = "1.7.1" description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1520,7 +1498,6 @@ files = [ name = "pytest" version = "7.4.4" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1543,7 +1520,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-asyncio" version = "0.20.3" description = "Pytest support for asyncio" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1562,7 +1538,6 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy name = "pytest-benchmark" version = "4.0.0" description = "A ``pytest`` fixture for benchmarking code. It will group the tests into rounds that are calibrated to the chosen timer." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1583,7 +1558,6 @@ histogram = ["pygal", "pygaljs"] name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1602,7 +1576,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-mock" version = "3.12.0" description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1620,7 +1593,6 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -1635,7 +1607,6 @@ six = ">=1.5" name = "python-engineio" version = "4.9.0" description = "Engine.IO server and client for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1655,7 +1626,6 @@ docs = ["sphinx"] name = "python-multipart" version = "0.0.5" description = "A streaming multipart parser for Python" -category = "main" optional = false python-versions = "*" files = [ @@ -1669,7 +1639,6 @@ six = ">=1.4.0" name = "python-socketio" version = "5.11.1" description = "Socket.IO server and client for Python" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1690,7 +1659,6 @@ docs = ["sphinx"] name = "pytz" version = "2024.1" description = "World timezone definitions, modern and historical" -category = "dev" optional = false python-versions = "*" files = [ @@ -1702,7 +1670,6 @@ files = [ name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1752,7 +1719,6 @@ files = [ name = "redis" version = "4.6.0" description = "Python client for Redis database and key-value store" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1771,7 +1737,6 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" name = "reflex-hosting-cli" version = "0.1.8" description = "Reflex Hosting CLI" -category = "main" optional = false python-versions = ">=3.8,<4.0" files = [ @@ -1796,7 +1761,6 @@ websockets = ">=10.4" name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1818,7 +1782,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "rich" version = "13.7.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -1838,7 +1801,6 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] name = "ruff" version = "0.0.244" description = "An extremely fast Python linter, written in Rust." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1864,7 +1826,6 @@ files = [ name = "selenium" version = "4.18.0" description = "" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1881,26 +1842,24 @@ urllib3 = {version = ">=1.26,<3", extras = ["socks"]} [[package]] name = "setuptools" -version = "69.1.0" +version = "69.1.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-69.1.0-py3-none-any.whl", hash = "sha256:c054629b81b946d63a9c6e732bc8b2513a7c3ea645f11d0139a2191d735c60c6"}, - {file = "setuptools-69.1.0.tar.gz", hash = "sha256:850894c4195f09c4ed30dba56213bf7c3f21d86ed6bdaafb5df5972593bfc401"}, + {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, + {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, ] [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] name = "simple-websocket" version = "1.0.0" description = "Simple WebSocket server and client for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1918,7 +1877,6 @@ docs = ["sphinx"] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1930,7 +1888,6 @@ files = [ name = "sniffio" version = "1.3.0" description = "Sniff out which async library your code is running under" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1942,7 +1899,6 @@ files = [ name = "sortedcontainers" version = "2.4.0" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" -category = "dev" optional = false python-versions = "*" files = [ @@ -1954,7 +1910,6 @@ files = [ name = "sqlalchemy" version = "2.0.27" description = "Database Abstraction Library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2042,7 +1997,6 @@ sqlcipher = ["sqlcipher3_binary"] name = "sqlmodel" version = "0.0.14" description = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness." -category = "main" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -2058,7 +2012,6 @@ SQLAlchemy = ">=2.0.0,<2.1.0" name = "starlette" version = "0.27.0" description = "The little ASGI library that shines." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2077,7 +2030,6 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyam name = "starlette-admin" version = "0.9.0" description = "Fast, beautiful and extensible administrative interface framework for Starlette/FastApi applications" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2100,7 +2052,6 @@ test = ["aiomysql (>=0.1.1,<0.2.0)", "aiosqlite (>=0.17.0,<0.20.0)", "arrow (>=1 name = "tabulate" version = "0.9.0" description = "Pretty-print tabular data" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2115,7 +2066,6 @@ widechars = ["wcwidth"] name = "tenacity" version = "8.2.3" description = "Retry code until it succeeds" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2130,7 +2080,6 @@ doc = ["reno", "sphinx", "tornado (>=4.5)"] name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -2142,7 +2091,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2154,7 +2102,6 @@ files = [ name = "trio" version = "0.24.0" description = "A friendly Python library for async concurrency and I/O" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -2175,7 +2122,6 @@ sortedcontainers = "*" name = "trio-websocket" version = "0.11.1" description = "WebSocket library for Trio" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2192,7 +2138,6 @@ wsproto = ">=0.14" name = "typer" version = "0.9.0" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2214,7 +2159,6 @@ test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6. name = "types-tabulate" version = "0.9.0.20240106" description = "Typing stubs for tabulate" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -2226,7 +2170,6 @@ files = [ name = "typing-extensions" version = "4.9.0" description = "Backported and Experimental Type Hints for Python 3.8+" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2238,7 +2181,6 @@ files = [ name = "tzdata" version = "2024.1" description = "Provider of IANA time zone data" -category = "dev" optional = false python-versions = ">=2" files = [ @@ -2250,7 +2192,6 @@ files = [ name = "urllib3" version = "2.2.1" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2271,7 +2212,6 @@ zstd = ["zstandard (>=0.18.0)"] name = "uvicorn" version = "0.20.0" description = "The lightning-fast ASGI server." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2290,7 +2230,6 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", name = "uvicorn" version = "0.24.0.post1" description = "The lightning-fast ASGI server." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2309,7 +2248,6 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", name = "virtualenv" version = "20.25.0" description = "Virtual Python Environment builder" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2330,7 +2268,6 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess name = "watchdog" version = "2.3.1" description = "Filesystem events monitoring" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2371,7 +2308,6 @@ watchmedo = ["PyYAML (>=3.10)"] name = "watchfiles" version = "0.19.0" description = "Simple, modern and high performance file watching and code reload in python." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2406,7 +2342,6 @@ anyio = ">=3.0.0" name = "websockets" version = "12.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2484,11 +2419,24 @@ files = [ {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, ] +[[package]] +name = "wheel" +version = "0.42.0" +description = "A built-package format for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "wheel-0.42.0-py3-none-any.whl", hash = "sha256:177f9c9b0d45c47873b619f5b650346d632cdc35fb5e4d25058e09c9e581433d"}, + {file = "wheel-0.42.0.tar.gz", hash = "sha256:c45be39f7882c9d34243236f2d63cbd58039e360f85d0913425fbd7ceea617a8"}, +] + +[package.extras] +test = ["pytest (>=6.0.0)", "setuptools (>=65)"] + [[package]] name = "wrapt" version = "1.16.0" description = "Module for decorators, wrappers and monkey patching." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2568,7 +2516,6 @@ files = [ name = "wsproto" version = "1.2.0" description = "WebSockets state-machine based protocol implementation" -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -2583,7 +2530,6 @@ h11 = ">=0.9.0,<1" name = "yarg" version = "0.1.9" description = "A semi hard Cornish cheese, also queries PyPI (PyPI client)" -category = "main" optional = false python-versions = "*" files = [ @@ -2598,7 +2544,6 @@ requests = "*" name = "zipp" version = "3.17.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2613,4 +2558,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "c22317cf6beac82268e73619e984788895d258bb7b7983eacdfbf6093c419dc3" +content-hash = "840901e82824445e708eb44ae7f237b286946db2dd3332740c6d1e5e891fb84d" diff --git a/pyproject.toml b/pyproject.toml index d2874290c..49ef1ca90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ packaging = "^23.1" pipdeptree = "^2.13.0" reflex-hosting-cli = ">=0.1.2" charset-normalizer = "^3.3.2" +wheel = "^0.42.0" +build = "^1.0.3" +setuptools = "^69.1.1" [tool.poetry.group.dev.dependencies] pytest = "^7.1.2" diff --git a/reflex/.templates/jinja/custom_components/README.md.jinja2 b/reflex/.templates/jinja/custom_components/README.md.jinja2 new file mode 100644 index 000000000..b7aec4d9d --- /dev/null +++ b/reflex/.templates/jinja/custom_components/README.md.jinja2 @@ -0,0 +1,9 @@ +# {{ module_name }} + +A Reflex custom component {{ module_name }}. + +## Installation + +```bash +pip install {{ package_name }} +``` diff --git a/reflex/.templates/jinja/custom_components/__init__.py.jinja2 b/reflex/.templates/jinja/custom_components/__init__.py.jinja2 new file mode 100644 index 000000000..96c74063e --- /dev/null +++ b/reflex/.templates/jinja/custom_components/__init__.py.jinja2 @@ -0,0 +1 @@ +from .{{ module_name }} import * \ No newline at end of file diff --git a/reflex/.templates/jinja/custom_components/demo_app.py.jinja2 b/reflex/.templates/jinja/custom_components/demo_app.py.jinja2 new file mode 100644 index 000000000..3eccd690e --- /dev/null +++ b/reflex/.templates/jinja/custom_components/demo_app.py.jinja2 @@ -0,0 +1,36 @@ +"""Welcome to Reflex! This file showcases the custom component in a basic app.""" + +from rxconfig import config + +import reflex as rx + +from {{ custom_component_module_dir }} import {{ module_name }} + +filename = f"{config.app_name}/{config.app_name}.py" + + +class State(rx.State): + """The app state.""" + + pass + + +def index() -> rx.Component: + return rx.center( + rx.theme_panel(), + rx.vstack( + rx.heading("Welcome to Reflex!", size="9"), + rx.text("Test your custom component by editing ", rx.code(filename)), + {{ module_name }}(), + align="center", + spacing="7", + font_size="2em", + ), + height="100vh", + ) + + +# Add state and page to the app. +app = rx.App() +app.add_page(index) + diff --git a/reflex/.templates/jinja/custom_components/pyproject.toml.jinja2 b/reflex/.templates/jinja/custom_components/pyproject.toml.jinja2 new file mode 100644 index 000000000..ec657ab57 --- /dev/null +++ b/reflex/.templates/jinja/custom_components/pyproject.toml.jinja2 @@ -0,0 +1,35 @@ +[build-system] +requires = [ + "setuptools", + "wheel", +] +build-backend = "setuptools.build_meta" + +[project] +name = "{{ package_name }}" +version = "0.0.1" +description = "Reflex custom component {{ module_name }}" +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.8" +authors = [{ name = "Your Name", email = "YOUREMAIL@domain.com" }] +keywords = [ + "reflex", + "reflex-custom-components"] + +dependencies = [ + "reflex>=0.4.2" +] + +classifiers = [ + "Development Status :: 4 - Beta", +] + +[project.urls] +Homepage = "https://github.com" + +[project.optional-dependencies] +dev = ["build", "twine"] + +[tool.setuptools.packages.find] +where = ["custom_components"] diff --git a/reflex/.templates/jinja/custom_components/src.py.jinja2 b/reflex/.templates/jinja/custom_components/src.py.jinja2 new file mode 100644 index 000000000..904371655 --- /dev/null +++ b/reflex/.templates/jinja/custom_components/src.py.jinja2 @@ -0,0 +1,57 @@ +"""Reflex custom component {{ component_class_name }}.""" + +# For wrapping react guide, visit https://reflex.dev/docs/wrapping-react/overview/ + +import reflex as rx + +# Some libraries you may want to wrap may require dynamic imports. +# This is because they they may not be compatible with Server-Side Rendering (SSR). +# To handle this in Reflex all you need to do is subclass NoSSRComponent instead. +# For example: +# from reflex.components.component import NoSSRComponent +# class {{ component_class_name }}(NoSSRComponent): +# pass + + +class {{ component_class_name }}(rx.Component): + """{{ component_class_name }} component.""" + + # The React library to wrap. + library = "Fill-Me" + + # The React component tag. + tag = "Fill-Me" + + # If the tag is the default export from the module, you must set is_default = True. + # This is normally used when components don't have curly braces around them when importing. + # is_default = True + + # If you are wrapping another components with the same tag as a component in your project + # you can use aliases to differentiate between them and avoid naming conflicts. + # alias = "Other{{ component_class_name }}" + + # The props of the React component. + # Note: when Reflex compiles the component to Javascript, + # `snake_case` property names are automatically formatted as `camelCase`. + # The prop names may be defined in `camelCase` as well. + # some_prop: rx.Var[str] = "some default value" + # some_other_prop: rx.Var[int] = 1 + + # By default Reflex will install the library you have specified in the library property. + # However, sometimes you may need to install other libraries to use a component. + # In this case you can use the lib_dependencies property to specify other libraries to install. + # lib_dependencies: list[str] = [] + + # Event triggers, I did not understand the wording of the doc. + # def get_event_triggers(self) -> dict[str, Any]: + # return { + # **super().get_event_triggers(), + # "on_change": lambda e0: [e0], + # } + + # To add custom code to your component + # def _get_custom_code(self) -> str: + # return "const customCode = 'customCode';" + + +{{ module_name }} = {{ component_class_name }}.create diff --git a/reflex/compiler/templates.py b/reflex/compiler/templates.py index 2b71230c8..694ca0cde 100644 --- a/reflex/compiler/templates.py +++ b/reflex/compiler/templates.py @@ -98,3 +98,20 @@ STYLE = get_template("web/styles/styles.css.jinja2") # Code that generate the package json file PACKAGE_JSON = get_template("web/package.json.jinja2") + +# Code that generate the pyproject.toml file for custom components +CUSTOM_COMPONENTS_PYPROJECT_TOML = get_template( + "custom_components/pyproject.toml.jinja2" +) + +# Code that generates the README file for custom components +CUSTOM_COMPONENTS_README = get_template("custom_components/README.md.jinja2") + +# Code that generates the source file for custom components +CUSTOM_COMPONENTS_SOURCE = get_template("custom_components/src.py.jinja2") + +# Code that generates the init file for custom components +CUSTOM_COMPONENTS_INIT_FILE = get_template("custom_components/__init__.py.jinja2") + +# Code that generates the demo app main py file for testing custom components +CUSTOM_COMPONENTS_DEMO_APP = get_template("custom_components/demo_app.py.jinja2") diff --git a/reflex/constants/__init__.py b/reflex/constants/__init__.py index 7663a0c76..74baf3043 100644 --- a/reflex/constants/__init__.py +++ b/reflex/constants/__init__.py @@ -40,6 +40,9 @@ from .config import ( GitIgnore, RequirementsTxt, ) +from .custom_components import ( + CustomComponents, +) from .event import Endpoint, EventTriggers, SocketEvent from .installer import ( Bun, @@ -67,6 +70,7 @@ __ALL__ = [ Config, COOKIES, ComponentName, + CustomComponents, DefaultPage, Dirs, Endpoint, diff --git a/reflex/constants/custom_components.py b/reflex/constants/custom_components.py new file mode 100644 index 000000000..3c4ebdb8f --- /dev/null +++ b/reflex/constants/custom_components.py @@ -0,0 +1,30 @@ +"""Constants for the custom components.""" + +from __future__ import annotations + +from types import SimpleNamespace + + +class CustomComponents(SimpleNamespace): + """Constants for the custom components.""" + + # The name of the custom components source directory. + SRC_DIR = "custom_components" + # The name of the custom components pyproject.toml file. + PYPROJECT_TOML = "pyproject.toml" + # The name of the custom components package README file. + PACKAGE_README = "README.md" + # The name of the custom components package .gitignore file. + PACKAGE_GITIGNORE = ".gitignore" + # The name of the distribution directory as result of a build. + DIST_DIR = "dist" + # The name of the init file. + INIT_FILE = "__init__.py" + # Suffixes for the distribution files. + DISTRIBUTION_FILE_SUFFIXES = [".tar.gz", ".whl"] + # The name to the URL of python package repositories. + REPO_URLS = { + # Note: the trailing slash is required for below URLs. + "pypi": "https://upload.pypi.org/legacy/", + "testpypi": "https://test.pypi.org/legacy/", + } diff --git a/reflex/custom_components/__init__.py b/reflex/custom_components/__init__.py new file mode 100644 index 000000000..5b2ab6a0c --- /dev/null +++ b/reflex/custom_components/__init__.py @@ -0,0 +1 @@ +"""The Reflex custom components.""" diff --git a/reflex/custom_components/custom_components.py b/reflex/custom_components/custom_components.py new file mode 100644 index 000000000..46dd060b1 --- /dev/null +++ b/reflex/custom_components/custom_components.py @@ -0,0 +1,565 @@ +"""CLI for creating custom components.""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from collections import namedtuple +from contextlib import contextmanager +from pathlib import Path +from typing import Optional + +import typer + +from reflex import constants +from reflex.config import get_config +from reflex.constants import CustomComponents +from reflex.utils import console + +config = get_config() +custom_components_cli = typer.Typer() + + +@contextmanager +def set_directory(working_directory: str): + """Context manager that sets the working directory. + + Args: + working_directory: The working directory to change to. + + Yields: + Yield to the caller to perform operations in the working directory. + """ + current_directory = os.getcwd() + try: + os.chdir(working_directory) + yield + finally: + os.chdir(current_directory) + + +def _create_package_config(module_name: str, package_name: str): + """Create a package config pyproject.toml file. + + Args: + module_name: The name of the module. + package_name: The name of the package typically constructed with `reflex-` prefix and a meaningful library name. + """ + from reflex.compiler import templates + + with open(CustomComponents.PYPROJECT_TOML, "w") as f: + f.write( + templates.CUSTOM_COMPONENTS_PYPROJECT_TOML.render( + module_name=module_name, package_name=package_name + ) + ) + + +def _create_readme(module_name: str, package_name: str): + """Create a package README file. + + Args: + module_name: The name of the module. + package_name: The name of the python package to be published. + """ + from reflex.compiler import templates + + with open(CustomComponents.PACKAGE_README, "w") as f: + f.write( + templates.CUSTOM_COMPONENTS_README.render( + module_name=module_name, + package_name=package_name, + ) + ) + + +def _write_source_and_init_py( + custom_component_src_dir: str, + component_class_name: str, + module_name: str, +): + """Write the source code and init file from templates for the custom component. + + Args: + custom_component_src_dir: The name of the custom component source directory. + component_class_name: The name of the component class. + module_name: The name of the module. + """ + from reflex.compiler import templates + + with open( + os.path.join( + custom_component_src_dir, + f"{module_name}.py", + ), + "w", + ) as f: + f.write( + templates.CUSTOM_COMPONENTS_SOURCE.render( + component_class_name=component_class_name, module_name=module_name + ) + ) + + with open( + os.path.join( + custom_component_src_dir, + CustomComponents.INIT_FILE, + ), + "w", + ) as f: + f.write(templates.CUSTOM_COMPONENTS_INIT_FILE.render(module_name=module_name)) + + +def _populate_demo_app(name_variants: NameVariants): + """Populate the demo app that imports the custom components. + + Args: + name_variants: the tuple including various names such as package name, class name needed for the project. + """ + from reflex import constants + from reflex.compiler import templates + from reflex.reflex import _init + + demo_app_dir = name_variants.demo_app_dir + demo_app_name = name_variants.demo_app_name + + console.info(f"Creating app for testing: {demo_app_dir}") + + os.makedirs(demo_app_dir) + + with set_directory(demo_app_dir): + # We start with the blank template as basis. + _init(name=demo_app_name, template=constants.Templates.Kind.BLANK) + # Then overwrite the app source file with the one we want for testing custom components. + # This source file is rendered using jinja template file. + with open(f"{demo_app_name}/{demo_app_name}.py", "w") as f: + f.write( + templates.CUSTOM_COMPONENTS_DEMO_APP.render( + custom_component_module_dir=name_variants.custom_component_module_dir, + module_name=name_variants.module_name, + ) + ) + + +def _get_default_library_name_parts() -> list[str]: + """Get the default library name. Based on the current directory name, remove any non-alphanumeric characters. + + Raises: + ValueError: If the current directory name is not suitable for python projects, and we cannot find a valid library name based off it. + + Returns: + The parts of default library name. + """ + current_dir_name = os.getcwd().split(os.path.sep)[-1] + + cleaned_dir_name = re.sub("[^0-9a-zA-Z-_]+", "", current_dir_name) + parts = re.split("-|_", cleaned_dir_name) + if not parts: + # The folder likely has a name not suitable for python paths. + raise ValueError( + f"Could not find a valid library name based on the current directory: got {current_dir_name}." + ) + return parts + + +NameVariants = namedtuple( + "NameVariants", + [ + "library_name", + "component_class_name", + "package_name", + "module_name", + "custom_component_module_dir", + "demo_app_dir", + "demo_app_name", + ], +) + + +def _validate_library_name(library_name: str | None) -> NameVariants: + """Validate the library name. + + Args: + library_name: The name of the library if picked otherwise None. + + Raises: + Exit: If the library name is not suitable for python projects. + + Returns: + A tuple containing the various names such as package name, class name, etc., needed for the project. + """ + if library_name is not None and not re.match( + r"^[a-zA-Z-]+[a-zA-Z0-9-]*$", library_name + ): + console.error( + f"Please use only alphanumeric characters or dashes: got {library_name}" + ) + raise typer.Exit(code=1) + + # If not specified, use the current directory name to form the module name. + name_parts = ( + [part.lower() for part in library_name.split("-")] + if library_name + else _get_default_library_name_parts() + ) + if not library_name: + library_name = "-".join(name_parts) + + # Component class name is the camel case. + component_class_name = "".join([part.capitalize() for part in name_parts]) + console.info(f"Component class name: {component_class_name}") + + # Package name is commonly kebab case. + package_name = f"reflex-{library_name}" + console.info(f"Package name: {package_name}") + + # Module name is the snake case. + module_name = "_".join(name_parts) + + custom_component_module_dir = f"reflex_{module_name}" + console.info(f"Custom component source directory: {custom_component_module_dir}") + + # Use the same name for the directory and the app. + demo_app_dir = demo_app_name = f"{module_name}_demo" + console.info(f"Demo app directory: {demo_app_dir}") + + return NameVariants( + library_name=library_name, + component_class_name=component_class_name, + package_name=package_name, + module_name=module_name, + custom_component_module_dir=custom_component_module_dir, + demo_app_dir=demo_app_dir, + demo_app_name=demo_app_name, + ) + + +def _populate_custom_component_project(name_variants: NameVariants): + """Populate the custom component source directory. This includes the pyproject.toml, README.md, and the code template for the custom component. + + Args: + name_variants: the tuple including various names such as package name, class name needed for the project. + """ + console.info( + f"Populating pyproject.toml with package name: {name_variants.package_name}" + ) + # write pyproject.toml, README.md, etc. + _create_package_config( + module_name=name_variants.library_name, package_name=name_variants.package_name + ) + _create_readme( + module_name=name_variants.library_name, package_name=name_variants.package_name + ) + + console.info( + f"Initializing the component directory: {CustomComponents.SRC_DIR}/{name_variants.custom_component_module_dir}" + ) + os.makedirs(CustomComponents.SRC_DIR) + with set_directory(CustomComponents.SRC_DIR): + os.makedirs(name_variants.custom_component_module_dir) + _write_source_and_init_py( + custom_component_src_dir=name_variants.custom_component_module_dir, + component_class_name=name_variants.component_class_name, + module_name=name_variants.module_name, + ) + + +@custom_components_cli.command(name="init") +def init( + library_name: Optional[str] = typer.Option( + None, + help="The name of your library. On PyPI, package will be published as `reflex-{library-name}`.", + ), + install: bool = typer.Option( + True, + help="Whether to install package from this local custom component in editable mode.", + ), + loglevel: constants.LogLevel = typer.Option( + config.loglevel, help="The log level to use." + ), +): + """Initialize a custom component. + + Args: + library_name: The name of the library. + install: Whether to install package from this local custom component in editable mode. + loglevel: The log level to use. + + Raises: + Exit: If the pyproject.toml already exists. + """ + from reflex.utils import exec, prerequisites + + console.set_log_level(loglevel) + + if os.path.exists(CustomComponents.PYPROJECT_TOML): + console.error(f"A {CustomComponents.PYPROJECT_TOML} already exists. Aborting.") + typer.Exit(code=1) + + # Show system info. + exec.output_system_info() + + # Check the name follows the convention if picked. + name_variants = _validate_library_name(library_name) + + _populate_custom_component_project(name_variants) + + _populate_demo_app(name_variants) + + # Initialize the .gitignore. + prerequisites.initialize_gitignore() + + if install: + package_name = name_variants.package_name + console.info(f"Installing {package_name} in editable mode.") + if _pip_install_on_demand(package_name=".", install_args=["-e"]): + console.info(f"Package {package_name} installed!") + else: + raise typer.Exit(code=1) + + console.print("Custom component initialized successfully!") + console.print("Here's the summary:") + console.print( + f"{CustomComponents.PYPROJECT_TOML} and {CustomComponents.PACKAGE_README} created. [bold]Please fill in details such as your name, email, homepage URL.[/bold]" + ) + console.print( + f"Source code template is in {CustomComponents.SRC_DIR}. [bold]Start by editing it with your component implementation.[/bold]" + ) + console.print( + f"Demo app created in {name_variants.demo_app_dir}. [bold]Use this app to test your custom component.[/bold]" + ) + + +def _pip_install_on_demand( + package_name: str, + install_args: list[str] | None = None, +) -> bool: + """Install a package on demand. + + Args: + package_name: The name of the package. + install_args: The additional arguments for the pip install command. + + Returns: + True if the package is installed successfully, False otherwise. + """ + install_args = install_args or [] + + install_cmds = [ + sys.executable, + "-m", + "pip", + "install", + *install_args, + package_name, + ] + console.debug(f"Install package: {' '.join(install_cmds)}") + return _run_commands_in_subprocess(install_cmds) + + +def _run_commands_in_subprocess(cmds: list[str]) -> bool: + """Run commands in a subprocess. + + Args: + cmds: The commands to run. + + Returns: + True if the command runs successfully, False otherwise. + """ + console.debug(f"Running command: {' '.join(cmds)}") + try: + result = subprocess.run(cmds, capture_output=True, text=True, check=True) + console.debug(result.stdout) + return True + except subprocess.CalledProcessError as cpe: + console.error(cpe.stdout) + console.error(cpe.stderr) + return False + + +@custom_components_cli.command(name="build") +def build( + loglevel: constants.LogLevel = typer.Option( + config.loglevel, help="The log level to use." + ), +): + """Build a custom component. Must be run from the project root directory where the pyproject.toml is. + + Args: + loglevel: The log level to use. + + Raises: + Exit: If the build fails. + """ + console.set_log_level(loglevel) + console.print("Building custom component...") + + cmds = [sys.executable, "-m", "build", "."] + if _run_commands_in_subprocess(cmds): + console.info("Custom component built successfully!") + else: + raise typer.Exit(code=1) + + +def _validate_repository_name(repository: str | None) -> str: + """Validate the repository name. + + Args: + repository: The name of the repository. + + Returns: + The name of the repository. + + Raises: + Exit: If the repository name is not supported. + """ + if repository is None: + return "pypi" + elif repository not in CustomComponents.REPO_URLS: + console.error( + f"Unsupported repository name. Allow {CustomComponents.REPO_URLS.keys()}, got {repository}" + ) + raise typer.Exit(code=1) + return repository + + +def _validate_credentials( + username: str | None, password: str | None, token: str | None +) -> tuple[str, str]: + """Validate the credentials. + + Args: + username: The username to use for authentication on python package repository. + password: The password to use for authentication on python package repository. + token: The token to use for authentication on python package repository. + + Raises: + Exit: If the appropriate combination of credentials is not provided. + + Returns: + The username and password. + """ + if token is not None: + if username is not None or password is not None: + console.error("Cannot use token and username/password at the same time.") + raise typer.Exit(code=1) + username = "__token__" + password = token + elif username is None or password is None: + console.error( + "Must provide both username and password for authentication if not using a token." + ) + raise typer.Exit(code=1) + + return username, password + + +def _ensure_dist_dir(): + """Ensure the distribution directory and the expected files exist. + + Raises: + Exit: If the distribution directory does not exist or the expected files are not found. + """ + dist_dir = Path(CustomComponents.DIST_DIR) + + # Check if the distribution directory exists. + if not dist_dir.exists(): + console.error(f"Directory {dist_dir.name} does not exist. Please build first.") + raise typer.Exit(code=1) + + # Check if the distribution directory is indeed a directory. + if not dist_dir.is_dir(): + console.error( + f"{dist_dir.name} is not a directory. If this is a file you added, move it and rebuild." + ) + raise typer.Exit(code=1) + + # Check if the distribution files exist. + for suffix in CustomComponents.DISTRIBUTION_FILE_SUFFIXES: + if not list(dist_dir.glob(f"*{suffix}")): + console.error( + f"Expected distribution file with suffix {suffix} in directory {dist_dir.name}" + ) + raise typer.Exit(code=1) + + +@custom_components_cli.command(name="publish") +def publish( + repository: Optional[str] = typer.Option( + None, + "-r", + "--repository", + help="The name of the repository. Defaults to pypi. Only supports pypi and testpypi (Test PyPI) for now.", + ), + token: Optional[str] = typer.Option( + None, + "-t", + "--token", + help="The API token to use for authentication on python package repository. If token is provided, no username/password should be provided at the same time", + ), + username: Optional[str] = typer.Option( + None, + "-u", + "--username", + help="The username to use for authentication on python package repository. Username and password must both be provided.", + ), + password: Optional[str] = typer.Option( + None, + "-p", + "--password", + help="The password to use for authentication on python package repository. Username and password must both be provided.", + ), + loglevel: constants.LogLevel = typer.Option( + config.loglevel, help="The log level to use." + ), +): + """Publish a custom component. Must be run from the project root directory where the pyproject.toml is. + + Args: + repository: The name of the Python package repository, such pypi, testpypi. + token: The token to use for authentication on python package repository. If token is provided, no username/password should be provided at the same time. + username: The username to use for authentication on python package repository. + password: The password to use for authentication on python package repository. + loglevel: The log level to use. + + Raises: + Exit: If arguments provided are not correct or the publish fails. + """ + console.set_log_level(loglevel) + + # Validate the repository name. + repository = _validate_repository_name(repository) + console.print(f"Publishing custom component to {repository}...") + + # Validate the credentials. + username, password = _validate_credentials(username, password, token) + + # Validate the distribution directory. + _ensure_dist_dir() + + # We install twine on the fly if required so it is not a stable dependency of reflex. + try: + import twine # noqa: F401 # type: ignore + except (ImportError, ModuleNotFoundError) as ex: + if not _pip_install_on_demand("twine"): + raise typer.Exit(code=1) from ex + publish_cmds = [ + sys.executable, + "-m", + "twine", + "upload", + "--repository-url", + CustomComponents.REPO_URLS[repository], + "--username", + username, + "--password", + password, + "--non-interactive", + f"{CustomComponents.DIST_DIR}/*", + ] + if _run_commands_in_subprocess(publish_cmds): + console.info("Custom component published successfully!") + else: + raise typer.Exit(1) diff --git a/reflex/reflex.py b/reflex/reflex.py index ca2e923f3..f45bd869e 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -15,6 +15,7 @@ from reflex_cli.utils import dependency from reflex import constants from reflex.config import get_config +from reflex.custom_components.custom_components import custom_components_cli from reflex.utils import console, telemetry # Disable typer+rich integration for help panels @@ -580,6 +581,11 @@ cli.add_typer( name="deployments", help="Subcommands for managing the Deployments.", ) +cli.add_typer( + custom_components_cli, + name="component", + help="Subcommands for creating and publishing Custom Components.", +) if __name__ == "__main__": cli()