commit e8e8eaa010f08ed4b0c7a3b773cd8cbf68018f97 Author: Nikhil Rao Date: Fri Nov 18 04:47:00 2022 -0800 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..fc50258b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +**/*.pyc +**/.DS_Store +**/*.swp +**/.web +**/*.db +**/node_modules/** +bun.lockb +poetry.lock +dist/* +pynetree/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..90dd43f1e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +opensource@pynecone.io. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..7a28583ff --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,18 @@ +# Welcome to Pynecone contributing guide! 🥳 + +## Getting started + +To navigate our codebase with confidence, see [Pynecone Docs](https://pynecone.io/docs/getting-started/introduction) :confetti_ball:. + +### Issues + +#### Create a new issue + +If you spot a problem with anything in Pynecone feel free to create an issue. Even if you are not sure if its a problem with the framework or your own code, create an issue and we will do our best to answer or resolve it. + +#### Solve an issue + +Scan through our [existing issues](https://github.com/pynecone-io/pynecone/issues) to find one that interests you. You can narrow down the search using `labels` as filters. As a general rule, we don’t assign issues to anyone. If you find an issue to work on, you are welcome to open a PR with a fix. Any large issue changing the compiler of Pynecone should brought to the Pynecone maintainers for approval + +Thank you for supporting Pynecone!🎊 + diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 000000000..22273b491 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +
+ +drawing + +**The easiest way to build and deploy web apps.** + +[![PyPI version](https://badge.fury.io/py/pynecone-io.svg)](https://badge.fury.io/py/pynecone-io) +![versions](https://img.shields.io/pypi/pyversions/pynecone-io.svg) +[![License](https://img.shields.io/badge/License-Apache_2.0-yellowgreen.svg)](https://opensource.org/licenses/Apache-2.0) + + +
+ +## Coming Soon + +Pynecone is a full-stack python framework that makes it easy to build and deploy web apps in minutes. + + diff --git a/docs/images/Counter.gif b/docs/images/Counter.gif new file mode 100644 index 000000000..f4037d882 Binary files /dev/null and b/docs/images/Counter.gif differ diff --git a/docs/images/logo.png b/docs/images/logo.png new file mode 100644 index 000000000..adbc2a7cc Binary files /dev/null and b/docs/images/logo.png differ diff --git a/pynecone/.templates/app/__init__.py b/pynecone/.templates/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pynecone/.templates/app/tutorial.py b/pynecone/.templates/app/tutorial.py new file mode 100644 index 000000000..98feabdc7 --- /dev/null +++ b/pynecone/.templates/app/tutorial.py @@ -0,0 +1,83 @@ +"""Welcome to Pynecone! This file outlines the steps to create a basic app.""" + +# Import pynecone. +import pcconfig + +import pynecone as pc + +docs_url = "https://pynecone.io/docs/getting-started/introduction" +title = "Welcome to Pynecone!" +filename = f"{pcconfig.APP_NAME}/{pcconfig.APP_NAME}.py" + + +class State(pc.State): + """The app state.""" + + # The colors to cycle through. + colors = ["black", "red", "orange", "yellow", "green", "blue", "purple"] + + # The index of the current color. + index = 0 + + def next_color(self): + """Cycle to the next color.""" + self.index = (self.index + 1) % len(self.colors) + + @pc.var + def color(self): + return self.colors[self.index] + + +# Define views. +def welcome_text(): + return pc.heading( + title, + font_size="2.5em", + on_click=State.next_color, + color=State.color, + _hover={"cursor": "pointer"}, + ) + + +def instructions(): + return pc.box( + "Get started by editing ", + pc.code( + filename, + font_size="0.8em", + ), + ) + + +def doclink(): + return pc.link( + "Check out our docs!", + href=docs_url, + border="0.1em solid", + padding="0.5em", + _hover={ + "border_color": State.color, + "color": State.color, + }, + ) + + +def index(): + return pc.container( + pc.vstack( + welcome_text(), + instructions(), + doclink(), + spacing="2em", + ), + padding_y="5em", + font_size="2em", + text_align="center", + height="100vh", + ) + + +# Add state and page to the app. +app = pc.App(state=State) +app.add_page(index, title=title) +app.compile() diff --git a/pynecone/.templates/assets/favicon.ico b/pynecone/.templates/assets/favicon.ico new file mode 100644 index 000000000..8a93bfa72 Binary files /dev/null and b/pynecone/.templates/assets/favicon.ico differ diff --git a/pynecone/.templates/web/.gitignore b/pynecone/.templates/web/.gitignore new file mode 100644 index 000000000..534bc86a6 --- /dev/null +++ b/pynecone/.templates/web/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +/_static + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# DS_Store +.DS_Store \ No newline at end of file diff --git a/pynecone/.templates/web/next.config.js b/pynecone/.templates/web/next.config.js new file mode 100644 index 000000000..90ceeefe4 --- /dev/null +++ b/pynecone/.templates/web/next.config.js @@ -0,0 +1,3 @@ +module.exports = { + reactStrictMode: true +}; diff --git a/pynecone/.templates/web/package.json b/pynecone/.templates/web/package.json new file mode 100644 index 000000000..7e23c9d32 --- /dev/null +++ b/pynecone/.templates/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "pynecone", + "scripts": { + "dev": "next dev", + "export": "next build && next export -o _static", + "prod": "next start" + }, + "dependencies": { + "@chakra-ui/icons": "^2.0.10", + "@chakra-ui/react": "1.8.8", + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "axios": "^0.27.2", + "focus-visible": "^5.2.0", + "framer-motion": "^6.3.3", + "gridjs": "^4.0.0", + "gridjs-react": "^4.0.0", + "next": "^12.1.0", + "plotly.js": "2.6.4", + "prettier": "^2.7.1", + "react": "^17.0.2", + "react-confetti": "^6.1.0", + "react-copy-to-clipboard": "^5.1.0", + "react-dom": "^17.0.2", + "react-markdown": "^8.0.3", + "react-plotly.js": "^2.6.0", + "react-syntax-highlighter": "^15.5.0", + "rehype-katex": "^6.0.2", + "remark-gfm": "^3.0.1", + "remark-math": "^5.1.1" + } +} \ No newline at end of file diff --git a/pynecone/.templates/web/pages/404.js b/pynecone/.templates/web/pages/404.js new file mode 100644 index 000000000..dc03e1e89 --- /dev/null +++ b/pynecone/.templates/web/pages/404.js @@ -0,0 +1,19 @@ +import Router from "next/router"; +import { useEffect, useState } from "react"; + +export default function Custom404() { + const [isNotFound, setIsNotFound] = useState(false); + + useEffect(() => { + const pathNameArray = window.location.pathname.split("/"); + if (pathNameArray.length == 2 && pathNameArray[1] == "404") { + setIsNotFound(true); + } else { + Router.replace(window.location.pathname); + } + }, []); + + if (isNotFound) return

404 - Page Not Found

; + + return null; +} diff --git a/pynecone/.templates/web/pages/_app.js b/pynecone/.templates/web/pages/_app.js new file mode 100644 index 000000000..d0b7c53ef --- /dev/null +++ b/pynecone/.templates/web/pages/_app.js @@ -0,0 +1,12 @@ +import { ChakraProvider, extendTheme } from "@chakra-ui/react"; +import theme from "/utils/theme"; + +function MyApp({ Component, pageProps }) { + return ( + + + + ); +} + +export default MyApp; diff --git a/pynecone/.templates/web/utils/state.js b/pynecone/.templates/web/utils/state.js new file mode 100644 index 000000000..eca26bfdc --- /dev/null +++ b/pynecone/.templates/web/utils/state.js @@ -0,0 +1,93 @@ +import axios from "axios"; + +let token; +const TOKEN_KEY = "token"; + +const generateUUID = () => { + let d = new Date().getTime(), + d2 = (performance && performance.now && performance.now() * 1000) || 0; + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + let r = Math.random() * 16; + if (d > 0) { + r = (d + r) % 16 | 0; + d = Math.floor(d / 16); + } else { + r = (d2 + r) % 16 | 0; + d2 = Math.floor(d2 / 16); + } + return (c == "x" ? r : (r & 0x7) | 0x8).toString(16); + }); +}; + +export const getToken = () => { + if (token) { + return token; + } + if (window) { + if (!window.sessionStorage.getItem(TOKEN_KEY)) { + window.sessionStorage.setItem(TOKEN_KEY, generateUUID()); + } + token = window.sessionStorage.getItem(TOKEN_KEY); + } + return token; +}; + +export const applyDelta = (state, delta) => { + for (const substate in delta) { + let s = state; + const path = substate.split(".").slice(1); + while (path.length > 0) { + s = s[path.shift()]; + } + for (const key in delta[substate]) { + s[key] = delta[substate][key]; + } + } +}; + +export const applyEvent = async (state, event, endpoint, router) => { + // Handle special events + if (event.name == "_redirect") { + router.push(event.payload.path); + return []; + } + + if (event.name == "_console") { + console.log(event.payload.message); + return []; + } + + if (event.name == "_alert") { + alert(event.payload.message); + return []; + } + + event.token = getToken(); + const update = (await axios.post(endpoint, event)).data; + applyDelta(state, update.delta); + return update.events; +}; + +export const updateState = async ( + state, + result, + setResult, + endpoint, + router +) => { + if (result.processing || state.events.length == 0) { + return; + } + setResult({ ...result, processing: true }); + const events = await applyEvent( + state, + state.events.shift(), + endpoint, + router + ); + setResult({ + state: state, + events: events, + processing: true, + }); +}; diff --git a/pynecone/__init__.py b/pynecone/__init__.py new file mode 100644 index 000000000..b4b3c0dbc --- /dev/null +++ b/pynecone/__init__.py @@ -0,0 +1,12 @@ +"""Import all classes and functions the end user will need to make an app. + +Anything imported here will be available in the default Pynecone import as `pc.*`. +""" + +from .app import App +from .base import Base +from .components import * +from .event import console_log, redirect, window_alert +from .model import Model, session +from .state import ComputedVar as var +from .state import State diff --git a/pynecone/app.py b/pynecone/app.py new file mode 100644 index 000000000..7fcbbd378 --- /dev/null +++ b/pynecone/app.py @@ -0,0 +1,344 @@ +"""The main Pynecone app.""" + +import re +from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple, Type, Union + +import fastapi +from fastapi.middleware import cors + +from pynecone import constants, utils +from pynecone.base import Base +from pynecone.compiler import compiler +from pynecone.compiler import utils as compiler_utils +from pynecone.components.component import Component, ComponentStyle +from pynecone.event import Event +from pynecone.middleware import HydrateMiddleware, LoggingMiddleware, Middleware +from pynecone.model import Model +from pynecone.state import DefaultState, Delta, State, StateManager, StateUpdate + +# Define custom types. +ComponentCallable = Callable[[], Component] +Reducer = Callable[[Event], Coroutine[Any, Any, StateUpdate]] + + +class App(Base): + """A Pynecone application.""" + + # A map from a page route to the component to render. + pages: Dict[str, Component] = {} + + # A list of URLs to stylesheets to include in the app. + stylesheets: List[str] = [] + + # The backend API object. + api: fastapi.FastAPI = None # type: ignore + + # The state class to use for the app. + state: Type[State] = DefaultState + + # Class to manage many client states. + state_manager: StateManager = StateManager() + + # The styling to apply to each component. + style: ComponentStyle = {} + + # Middleware to add to the app. + middleware: List[Middleware] = [] + + def __init__(self, *args, **kwargs): + """Initialize the app. + + Args: + *args: Args to initialize the app with. + **kwargs: Kwargs to initialize the app with. + """ + super().__init__(*args, **kwargs) + + # Add middleware. + self.middleware.append(HydrateMiddleware()) + self.middleware.append(LoggingMiddleware()) + + # Set up the state manager. + self.state_manager.set(state=self.state) + + # Set up the API. + self.api = fastapi.FastAPI() + self.add_cors() + self.add_default_endpoints() + + def __repr__(self) -> str: + """Get the string representation of the app. + + Returns: + The string representation of the app. + """ + return f"" + + def add_default_endpoints(self): + """Add the default endpoints.""" + # To test the server. + self.get(str(constants.Endpoint.PING))(_ping) + + # To make state changes. + self.post(str(constants.Endpoint.EVENT))(_event(app=self)) + + def add_cors(self): + """Add CORS middleware to the app.""" + self.api.add_middleware( + cors.CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + def get(self, path: str, *args, **kwargs) -> Callable: + """Register a get request. + + Args: + path: The endpoint path to link to the request. + *args: Args to pass to the request. + **kwargs: Kwargs to pass to the request. + + Returns: + A decorator to handle the request. + """ + return self.api.get(path, *args, **kwargs) + + def post(self, path: str, *args, **kwargs) -> Callable: + """Register a post request. + + Args: + path: The endpoint path to link to the request. + *args: Args to pass to the request. + **kwargs: Kwargs to pass to the request. + + Returns: + A decorator to handle the request. + """ + return self.api.post(path, *args, **kwargs) + + def preprocess(self, state: State, event: Event) -> Optional[Delta]: + """Preprocess the event. + + This is where middleware can modify the event before it is processed. + Each middleware is called in the order it was added to the app. + + If a middleware returns a delta, the event is not processed and the + delta is returned. + + Args: + state: The state to preprocess. + event: The event to preprocess. + + Returns: + An optional state to return. + """ + for middleware in self.middleware: + out = middleware.preprocess(app=self, state=state, event=event) + if out is not None: + return out + + def postprocess(self, state: State, event: Event, delta: Delta) -> Optional[Delta]: + """Postprocess the event. + + This is where middleware can modify the delta after it is processed. + Each middleware is called in the order it was added to the app. + + If a middleware returns a delta, the delta is not processed and the + delta is returned. + + Args: + state: The state to postprocess. + event: The event to postprocess. + delta: The delta to postprocess. + + Returns: + An optional state to return. + """ + for middleware in self.middleware: + out = middleware.postprocess( + app=self, state=state, event=event, delta=delta + ) + if out is not None: + return out + + def add_page( + self, + component: Union[Component, ComponentCallable], + path: Optional[str] = None, + title: str = constants.DEFAULT_TITLE, + ): + """Add a page to the app. + + If the component is a callable, by default the route is the name of the + function. Otherwise, a route must be provided. + + Args: + component: The component to display at the page. + path: The path to display the component at. + title: The title of the page. + """ + # If the path is not set, get it from the callable. + if path is None: + assert isinstance( + component, Callable + ), "Path must be set if component is not a callable." + path = component.__name__ + + from pynecone.var import BaseVar + + parts = path.split("/") + check = re.compile(r"^\[(.+)\]$") + args = [] + for part in parts: + match = check.match(part) + if match: + v = BaseVar( + name=match.groups()[0], + type_=str, + state="router.query", + ) + args.append(v) + + # Generate the component if it is a callable. + component = component if isinstance(component, Component) else component(*args) + + # Add the title to the component. + compiler_utils.add_title(component, title) + + # Format the route. + route = utils.format_route(path) + + # Add the page. + self.pages[route] = component + + def compile(self, ignore_env: bool = False): + """Compile the app and output it to the pages folder. + + If the pcconfig environment is set to production, the app will + not be compiled. + + Args: + ignore_env: Whether to ignore the pcconfig environment. + """ + # Get the env mode. + config = utils.get_config() + if not ignore_env and config.ENV != constants.Env.DEV.value: + print("Skipping compilation in non-dev mode.") + return + + # Create the database models. + Model.create_all() + + # Create the root document with base styles and fonts. + self.pages[constants.DOCUMENT_ROOT] = compiler_utils.create_document_root( + self.stylesheets + ) + self.pages[constants.THEME] = compiler_utils.create_theme(self.style) # type: ignore + + # Compile the pages. + for path, component in self.pages.items(): + path, code = self.compile_page(path, component) + + def compile_page( + self, path: str, component: Component, write: bool = True + ) -> Tuple[str, str]: + """Compile a single page. + + Args: + path: The path to compile the page to. + component: The component to compile. + write: Whether to write the page to the pages folder. + + Returns: + The path and code of the compiled page. + """ + # Get the path for the output file. + output_path = utils.get_page_path(path) + + # Compile the document root. + if path == constants.DOCUMENT_ROOT: + code = compiler.compile_document_root(component) + + # Compile the theme. + elif path == constants.THEME: + output_path = utils.get_theme_path() + code = compiler.compile_theme(component) # type: ignore + + # Compile all other pages. + else: + # Add the style to the component. + component.add_style(self.style) + code = compiler.compile_component( + component=component, + state=self.state, + ) + + # Write the page to the pages folder. + if write: + utils.write_page(output_path, code) + + return output_path, code + + def get_state(self, token: str) -> State: + """Get the state for a token. + + Args: + token: The token to get the state for. + + Returns: + The state for the token. + """ + return self.state_manager.get_state(token) + + def set_state(self, token: str, state: State): + """Set the state for a token. + + Args: + token: The token to set the state for. + state: The state to set. + """ + self.state_manager.set_state(token, state) + + +async def _ping() -> str: + """Test API endpoint. + + Returns: + The response. + """ + return "pong" + + +def _event(app: App) -> Reducer: + """Create an event reducer to modify the state. + + Args: + app: The app to modify the state of. + + Returns: + A handler that takes in an event and modifies the state. + """ + + async def process(event: Event) -> StateUpdate: + # Get the state for the session. + state = app.get_state(event.token) + + # Preprocess the event. + pre = app.preprocess(state, event) + if pre is not None: + return StateUpdate(delta=pre) + + # Apply the event to the state. + update = await state.process(event) + app.set_state(event.token, state) + + # Postprocess the event. + post = app.postprocess(state, event, update.delta) + if post is not None: + return StateUpdate(delta=post) + + # Return the delta. + return update + + return process diff --git a/pynecone/base.py b/pynecone/base.py new file mode 100644 index 000000000..d91c17807 --- /dev/null +++ b/pynecone/base.py @@ -0,0 +1,75 @@ +"""Define the base Pynecone class.""" +from __future__ import annotations + +from typing import Any, Dict, TypeVar + +import pydantic + +# Typevar to represent any class subclassing Base. +PcType = TypeVar("PcType") + + +class Base(pydantic.BaseModel): + """The base class subclassed by all Pynecone classes. + + This class wraps Pydantic and provides common methods such as + serialization and setting fields. + + Any data structure that needs to be transferred between the + frontend and backend should subclass this class. + """ + + class Config: + """Pydantic config.""" + + arbitrary_types_allowed = True + + def json(self) -> str: + """Convert the object to a json string. + + Returns: + The object as a json string. + """ + return self.__config__.json_dumps(self.dict()) + + def set(self: PcType, **kwargs) -> PcType: + """Set multiple fields and return the object. + + Args: + **kwargs: The fields and values to set. + + Returns: + The object with the fields set. + """ + for key, value in kwargs.items(): + setattr(self, key, value) + return self + + @classmethod + def get_fields(cls) -> Dict[str, Any]: + """Get the fields of the object. + + Returns: + The fields of the object. + """ + return cls.__fields__ + + def get_value(self, key: str) -> Any: + """Get the value of a field. + + Args: + key: The key of the field. + + Returns: + The value of the field. + """ + return self._get_value( + key, + to_dict=True, + by_alias=False, + include=None, + exclude=None, + exclude_unset=False, + exclude_defaults=False, + exclude_none=False, + ) diff --git a/pynecone/compiler/__init__.py b/pynecone/compiler/__init__.py new file mode 100644 index 000000000..4912c1e62 --- /dev/null +++ b/pynecone/compiler/__init__.py @@ -0,0 +1 @@ +"""The Pynecone compiler.""" diff --git a/pynecone/compiler/compiler.py b/pynecone/compiler/compiler.py new file mode 100644 index 000000000..5d50fb749 --- /dev/null +++ b/pynecone/compiler/compiler.py @@ -0,0 +1,68 @@ +"""Compiler for the pynecone apps.""" + +import json +from typing import Type + +from pynecone import constants +from pynecone.compiler import templates, utils +from pynecone.components.component import Component, ImportDict +from pynecone.state import State + +# Imports to be included in every Pynecone app. +DEFAULT_IMPORTS: ImportDict = { + "react": {"useEffect", "useState"}, + "next/router": {"useRouter"}, + f"/{constants.STATE_PATH}": {"updateState"}, +} + + +def compile_document_root(root: Component) -> str: + """Compile the document root. + + Args: + root: The document root to compile. + + Returns: + The compiled document root. + """ + return templates.DOCUMENT_ROOT( + imports=utils.compile_imports(root.get_imports()), + document=root.render(), + ) + + +def compile_theme(theme: dict) -> str: + """Compile the theme. + + Args: + theme: The theme to compile. + + Returns: + The compiled theme. + """ + return templates.THEME(theme=json.dumps(theme)) + + +def compile_component(component: Component, state: Type[State]) -> str: + """Compile the component given the app state. + + Args: + component: The component to compile. + state: The app state. + + Returns: + The compiled component. + """ + # Merge the default imports with the app-specific imports. + imports = utils.merge_imports(DEFAULT_IMPORTS, component.get_imports()) + + # Compile the code to render the component. + return templates.COMPONENT( + imports=utils.compile_imports(imports), + custom_code=component.get_custom_code(), + constants=utils.compile_constants(), + state=utils.compile_state(state), + events=utils.compile_events(state), + effects=utils.compile_effects(state), + render=component.render(), + ) diff --git a/pynecone/compiler/templates.py b/pynecone/compiler/templates.py new file mode 100644 index 000000000..c16b42e07 --- /dev/null +++ b/pynecone/compiler/templates.py @@ -0,0 +1,181 @@ +"""Templates to use in the pynecone compiler.""" + +from typing import Callable, Optional, Set + +from pynecone import constants, utils +from pynecone.utils import join + +# Template for the Pynecone config file. +PCCONFIG = f"""# The Pynecone configuration file. + +APP_NAME = "{{app_name}}" +API_HOST = "http://localhost:8000" +BUN_PATH = "$HOME/.bun/bin/bun" +ENV = "{constants.Env.DEV.value}" +DB_URI = "sqlite:///{constants.DB_NAME}" +""" + +# Javascript formatting. +CONST = "const {name} = {value}".format +PROP = "{object}.{property}".format +IMPORT_LIB = 'import "{lib}"'.format +IMPORT_FIELDS = 'import {default}{others} from "{lib}"'.format + + +def format_import(lib: str, default: str = "", rest: Optional[Set[str]] = None) -> str: + """Format an import statement. + + Args: + lib: The library to import from. + default: The default field to import. + rest: The set of fields to import from the library. + + Returns: + The compiled import statement. + """ + # Handle the case of direct imports with no libraries. + if lib == "": + assert default == "", "No default field allowed for empty library." + assert rest is not None and len(rest) > 0, "No fields to import." + return join([IMPORT_LIB(lib=lib) for lib in sorted(rest)]) + + # Handle importing from a library. + rest = rest or set() + if len(default) == 0 and len(rest) == 0: + # Handle the case of importing a library with no fields. + return IMPORT_LIB(lib=lib) + else: + # Handle importing specific fields from a library. + others = f'{{{", ".join(sorted(rest))}}}' if len(rest) > 0 else "" + if len(default) > 0 and len(rest) > 0: + default += ", " + return IMPORT_FIELDS(default=default, others=others, lib=lib) + + +# Code to render a NextJS Document root. +DOCUMENT_ROOT = join( + [ + "{imports}", + "", + "export default function Document() {{", + "", + "return (", + "{document}", + ")", + "}}", + ] +).format + +# Template for the theme file. +THEME = "export default {theme}".format + +# Code to render a single NextJS component. +COMPONENT = join( + [ + "{imports}", + "{custom_code}", + "", + "{constants}", + "", + "export default function Component() {{", + "", + "{state}", + "", + "{events}", + "", + "{effects}", + "", + "return (", + "{render}", + ")", + "}}", + ] +).format + +# React state declarations. +USE_STATE = CONST( + name="[{state}, {set_state}]", value="useState({initial_state})" +).format + + +def format_state_setter(state: str) -> str: + """Format a state setter. + + Args: + state: The name of the state variable. + + Returns: + The compiled state setter. + """ + return f"set{state[0].upper() + state[1:]}" + + +def format_state( + state: str, + initial_state: str, +) -> str: + """Format a state declaration. + + Args: + state: The name of the state variable. + initial_state: The initial state of the state variable. + + Returns: + The compiled state declaration. + """ + set_state = format_state_setter(state) + return USE_STATE(state=state, set_state=set_state, initial_state=initial_state) + + +# Events. +EVENT_ENDPOINT = constants.Endpoint.EVENT.name +EVENT_FN = join( + [ + "const E = (name, payload) => {{ return {{name, payload}} }}", + "const Event = events => {set_state}({{", + " ...{state},", + " events: [...{state}.events, ...events],", + "}})", + ] +).format + + +def format_event_declaration(fn: Callable) -> str: + """Format an event declaration. + + Args: + fn: The function to declare. + + Returns: + The compiled event declaration. + """ + name = utils.format_event_fn(fn=fn) + event = utils.to_snake_case(fn.__qualname__) + return f"const {name} = Event('{event}')" + + +# Effects. +USE_EFFECT = join( + [ + "useEffect(() => {{", + " const update = async () => {{", + " if (result.state != null) {{", + " setState({{", + " ...result.state,", + " events: [...state.events, ...result.events],", + " }})", + " setResult({{", + " ...result,", + " state: null,", + " processing: false,", + " }})", + " }}", + f" await updateState({{state}}, {{result}}, {{set_result}}, {EVENT_ENDPOINT}, {constants.ROUTER})", + " }}", + " update()", + "}})", + ] +).format + +# Routing +ROUTER = f"const {constants.ROUTER} = useRouter()" diff --git a/pynecone/compiler/utils.py b/pynecone/compiler/utils.py new file mode 100644 index 000000000..ff267b204 --- /dev/null +++ b/pynecone/compiler/utils.py @@ -0,0 +1,219 @@ +"""Common utility functions used in the compiler.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Dict, Set, Type + +from pynecone import constants, utils +from pynecone.compiler import templates +from pynecone.components.base import ( + Body, + DocumentHead, + Head, + Html, + Link, + Main, + Script, + Title, +) +from pynecone.components.component import ImportDict +from pynecone.state import State +from pynecone.style import Style + +if TYPE_CHECKING: + from pynecone.components.component import Component + + +# To re-export this function. +merge_imports = utils.merge_imports + + +def compile_import_statement(lib: str, fields: Set[str]) -> str: + """Compile an import statement. + + Args: + lib: The library to import from. + fields: The set of fields to import from the library. + + Returns: + The compiled import statement. + """ + # Check for default imports. + defaults = { + field + for field in fields + if field.lower() == lib.lower().replace("-", "").replace("/", "") + } + assert len(defaults) < 2 + + # Get the default import, and the specific imports. + default = next(iter(defaults), "") + rest = fields - defaults + return templates.format_import(lib=lib, default=default, rest=rest) + + +def compile_imports(imports: ImportDict) -> str: + """Compile an import dict. + + Args: + imports: The import dict to compile. + + Returns: + The compiled import dict. + """ + return templates.join( + [compile_import_statement(lib, fields) for lib, fields in imports.items()] + ) + + +def compile_constant_declaration(name: str, value: str) -> str: + """Compile a constant declaration. + + Args: + name: The name of the constant. + value: The value of the constant. + + Returns: + The compiled constant declaration. + """ + return templates.CONST(name=name, value=json.dumps(value)) + + +def compile_constants() -> str: + """Compile all the necessary constants. + + Returns: + A string of all the compiled constants. + """ + endpoint = constants.Endpoint.EVENT + return templates.join( + [compile_constant_declaration(name=endpoint.name, value=endpoint.get_url())] + ) + + +import plotly.graph_objects as go + + +def compile_state(state: Type[State]) -> str: + """Compile the state of the app. + + Args: + state: The app state object. + + Returns: + A string of the compiled state. + """ + initial_state = state().dict() + initial_state.update( + { + "events": [{"name": utils.get_hydrate_event(state)}], + } + ) + initial_state = utils.format_state(initial_state) + synced_state = templates.format_state( + state=state.get_name(), initial_state=json.dumps(initial_state) + ) + initial_result = { + "state": None, + "events": [], + "processing": False, + } + result = templates.format_state( + state="result", + initial_state=json.dumps(initial_result), + ) + router = templates.ROUTER + return templates.join([synced_state, result, router]) + + +def compile_events(state: Type[State]) -> str: + """Compile all the events for a given component. + + Args: + state: The state class for the component. + + Returns: + A string of the compiled events for the component. + """ + state_name = state.get_name() + state_setter = templates.format_state_setter(state_name) + return templates.EVENT_FN(state=state_name, set_state=state_setter) + + +def compile_effects(state: Type[State]) -> str: + """Compile all the effects for a given component. + + Args: + state: The state class for the component. + + Returns: + A string of the compiled effects for the component. + """ + state_name = state.get_name() + result_name = "result" + set_result = templates.format_state_setter(result_name) + return templates.USE_EFFECT( + state=state_name, result=result_name, set_result=set_result + ) + + +def compile_render(component: Component) -> str: + """Compile the component's render method. + + Args: + component: The component to compile the render method for. + + Returns: + A string of the compiled render method. + """ + return component.render() + + +def create_document_root(stylesheets) -> Component: + """Create the document root. + + Args: + stylesheets: The stylesheets to include in the document root. + + Returns: + The document root. + """ + sheets = [Link.create(rel="stylesheet", href=href) for href in stylesheets] + return Html.create( + DocumentHead.create(*sheets), + Body.create( + Main.create(), + Script.create(), + ), + ) + + +def create_theme(style: Style) -> Dict: + """Create the base style for the app. + + Args: + style: The style dict for the app. + + Returns: + The base style for the app. + """ + return { + "styles": { + "global": Style({k: v for k, v in style.items() if not isinstance(k, type)}) + }, + } + + +def add_title(page: Component, title: str) -> Component: + """Add a title to a page. + + Args: + page: The component for the page. + title: The title to add. + + Returns: + The component with the title added. + """ + page.children.append(Head.create(Title.create(title))) + return page diff --git a/pynecone/components/__init__.py b/pynecone/components/__init__.py new file mode 100644 index 000000000..6f88ec843 --- /dev/null +++ b/pynecone/components/__init__.py @@ -0,0 +1,91 @@ +"""Import all the components.""" + +from pynecone import utils +from pynecone.event import EventSpec +from pynecone.var import Var + +from .component import Component +from .datadisplay import * +from .disclosure import * +from .feedback import * +from .forms import * +from .graphing import * +from .layout import * +from .media import * +from .navigation import * +from .overlay import * +from .typography import * + +# Add the convenience methods for all the components. +locals().update( + { + utils.to_snake_case(name): value.create + for name, value in locals().items() + if isinstance(value, type) and issubclass(value, Component) + } +) + +# Add responsive styles shortcuts. +def mobile_only(*children, **props): + """Create a component that is only visible on mobile. + + Args: + *children: The children to pass to the component. + **props: The props to pass to the component. + + Returns: + The component. + """ + return Box.create(*children, **props, display=["block", "none", "none", "none"]) + + +def tablet_only(*children, **props): + """Create a component that is only visible on tablet. + + Args: + *children: The children to pass to the component. + **props: The props to pass to the component. + + Returns: + The component. + """ + return Box.create(*children, **props, display=["none", "block", "block", "none"]) + + +def desktop_only(*children, **props): + """Create a component that is only visible on desktop. + + Args: + *children: The children to pass to the component. + **props: The props to pass to the component. + + Returns: + The component. + """ + return Box.create(*children, **props, display=["none", "none", "none", "block"]) + + +def tablet_and_desktop(*children, **props): + """Create a component that is only visible on tablet and desktop. + + Args: + *children: The children to pass to the component. + **props: The props to pass to the component. + + Returns: + The component. + """ + return Box.create(*children, **props, display=["none", "block", "block", "block"]) + + +def mobile_and_tablet(*children, **props): + """Create a component that is only visible on mobile and tablet. + + Args: + *children: The children to pass to the component. + **props: The props to pass to the component. + + Returns: + The component. + """ + return Box.create(*children, **props, display=["block", "block", "block", "none"]) diff --git a/pynecone/components/base/__init__.py b/pynecone/components/base/__init__.py new file mode 100644 index 000000000..4ec1d7775 --- /dev/null +++ b/pynecone/components/base/__init__.py @@ -0,0 +1,7 @@ +"""Base components.""" + +from .body import Body +from .document import DocumentHead, Html, Main, Script +from .head import Head +from .link import Link +from .title import Title diff --git a/pynecone/components/base/bare.py b/pynecone/components/base/bare.py new file mode 100644 index 000000000..abf5ffe88 --- /dev/null +++ b/pynecone/components/base/bare.py @@ -0,0 +1,30 @@ +"""A bare component.""" +from __future__ import annotations + +from typing import Any + +from pynecone.components.component import Component +from pynecone.components.tags import Tag +from pynecone.components.tags.tagless import Tagless +from pynecone.var import Var + + +class Bare(Component): + """A component with no tag.""" + + contents: Var[str] + + @classmethod + def create(cls, contents: Any) -> Component: + """Create a Bare component, with no tag. + + Args: + contents: The contents of the component. + + Returns: + The component. + """ + return cls(contents=str(contents)) # type: ignore + + def _render(self) -> Tag: + return Tagless(contents=str(self.contents)) diff --git a/pynecone/components/base/body.py b/pynecone/components/base/body.py new file mode 100644 index 000000000..55c76eda7 --- /dev/null +++ b/pynecone/components/base/body.py @@ -0,0 +1,12 @@ +"""Display the page body.""" + +from pynecone.components.component import Component +from pynecone.components.tags import Tag +from pynecone.var import Var + + +class Body(Component): + """A body component.""" + + def _render(self) -> Tag: + return Tag(name="body") diff --git a/pynecone/components/base/document.py b/pynecone/components/base/document.py new file mode 100644 index 000000000..f7b57d9f6 --- /dev/null +++ b/pynecone/components/base/document.py @@ -0,0 +1,33 @@ +"""Document components.""" + +from pynecone.components.component import Component + + +class NextDocumentLib(Component): + """Root document components.""" + + library = "next/document" + + +class Html(NextDocumentLib): + """The document html.""" + + tag = "Html" + + +class DocumentHead(NextDocumentLib): + """The document head.""" + + tag = "Head" + + +class Main(NextDocumentLib): + """The document main section.""" + + tag = "Main" + + +class Script(NextDocumentLib): + """The document main scripts.""" + + tag = "NextScript" diff --git a/pynecone/components/base/head.py b/pynecone/components/base/head.py new file mode 100644 index 000000000..314f78070 --- /dev/null +++ b/pynecone/components/base/head.py @@ -0,0 +1,15 @@ +"""The head component.""" + +from pynecone.components.component import Component + + +class NextHeadLib(Component): + """Header components.""" + + library = "next/head" + + +class Head(NextHeadLib): + """Head Component.""" + + tag = "NextHead" diff --git a/pynecone/components/base/link.py b/pynecone/components/base/link.py new file mode 100644 index 000000000..485b0e825 --- /dev/null +++ b/pynecone/components/base/link.py @@ -0,0 +1,21 @@ +"""Display the title of the current page.""" + +from pynecone.components.component import Component +from pynecone.components.tags import Tag +from pynecone.var import Var + + +class Link(Component): + """A component that displays the title of the current page.""" + + # The href. + href: Var[str] + + # The type of link. + rel: Var[str] + + def _render(self) -> Tag: + return Tag(name="link").add_attrs( + href=self.href, + rel=self.rel, + ) diff --git a/pynecone/components/base/title.py b/pynecone/components/base/title.py new file mode 100644 index 000000000..b8e4da3c7 --- /dev/null +++ b/pynecone/components/base/title.py @@ -0,0 +1,25 @@ +"""Display the title of the current page.""" + +from pynecone.components.base.bare import Bare +from pynecone.components.component import Component +from pynecone.components.tags import Tag + + +class Title(Component): + """A component that displays the title of the current page.""" + + def _render(self) -> Tag: + return Tag(name="title") + + def render(self) -> str: + """Render the title component. + + Returns: + The rendered title component. + """ + tag = self._render() + # Make sure the title is a single string. + assert len(self.children) == 1 and isinstance( + self.children[0], Bare + ), "Title must be a single string." + return str(tag.set(contents=str(self.children[0].contents))) diff --git a/pynecone/components/component.py b/pynecone/components/component.py new file mode 100644 index 000000000..ca31945a7 --- /dev/null +++ b/pynecone/components/component.py @@ -0,0 +1,345 @@ +"""Base component definitions.""" + +from __future__ import annotations + +from abc import ABC +from typing import Any, Callable, Dict, List, Optional, Set, Type, Union + +from pynecone import utils +from pynecone.base import Base +from pynecone.components.tags import Tag +from pynecone.event import ( + EVENT_ARG, + EVENT_TRIGGERS, + EventChain, + EventHandler, + EventSpec, +) +from pynecone.style import Style +from pynecone.var import Var + +ImportDict = Dict[str, Set[str]] + + +class Component(Base, ABC): + """The base class for all Pynecone components.""" + + # The children nested within the component. + children: List[Component] = [] + + # The style of the component. + style: Style = Style() + + # A mapping of event chains to event triggers. + event_triggers: Dict[str, EventChain] = {} + + # The library that the component is based on. + library: Optional[str] = None + + # The tag to use when rendering the component. + tag: Optional[str] = None + + # A unique key for the component. + key: Any = None + + @classmethod + def __init_subclass__(cls, **kwargs): + """Set default properties. + + Args: + **kwargs: The kwargs to pass to the superclass. + """ + super().__init_subclass__(**kwargs) + + # Get all the props for the component. + props = cls.get_props() + + # Convert fields to props, setting default values. + for field in cls.get_fields().values(): + # If the field is not a component prop, skip it. + if field.name not in props: + continue + + # Set default values for any props. + if utils._issubclass(field.type_, Var): + field.required = False + field.default = Var.create(field.default) + + def __init__(self, *args, **kwargs): + """Initialize the component. + + Args: + *args: Args to initialize the component. + **kwargs: Kwargs to initialize the component. + """ + # Get the component fields, triggers, and props. + fields = self.get_fields() + triggers = self.get_triggers() + props = self.get_props() + + # Add any events triggers. + if "event_triggers" not in kwargs: + kwargs["event_triggers"] = {} + kwargs["event_triggers"] = kwargs["event_triggers"].copy() + + # Iterate through the kwargs and set the props. + for key, value in kwargs.items(): + if key in triggers: + # Event triggers are bound to event chains. + field_type = EventChain + else: + # If the key is not in the fields, skip it. + if key not in props: + continue + + # Set the field type. + field_type = fields[key].type_ + + # Check whether the key is a component prop. + if utils._issubclass(field_type, Var): + # Convert any constants into vars and make sure the types match. + kwargs[key] = Var.create(value) + passed_type = kwargs[key].type_ + expected_type = fields[key].outer_type_.__args__[0] + assert utils._issubclass( + passed_type, expected_type + ), f"Invalid var passed for {key}, expected {expected_type}, got {passed_type}." + + # Check if the key is an event trigger. + if key in triggers: + kwargs["event_triggers"][key] = self._create_event_chain(key, value) + + # Remove any keys that were added as events. + for key in kwargs["event_triggers"]: + del kwargs[key] + + # Add style props to the component. + style = kwargs["style"] if "style" in kwargs else {} + kwargs["style"] = Style( + { + **style, + **{attr: value for attr, value in kwargs.items() if attr not in fields}, + } + ) + + # Construct the component. + super().__init__(*args, **kwargs) + + def _create_event_chain( + self, + event_trigger: str, + value: Union[EventHandler, List[EventHandler], Callable], + ) -> EventChain: + """Create an event chain from a variety of input types. + + Args: + event_trigger: The event trigger to bind the chain to. + value: The value to create the event chain from. + + Returns: + The event chain. + """ + arg = self.get_controlled_value() + + # If the input is a single event handler, wrap it in a list. + if isinstance(value, EventHandler): + value = [value] + + # If the input is a list of event handlers, create an event chain. + if isinstance(value, List): + events = [utils.call_event_handler(v, arg) for v in value] + + # If the input is a callable, create an event chain. + elif isinstance(value, Callable): + events = utils.call_event_fn(value, arg) + + # Otherwise, raise an error. + else: + raise ValueError(f"Invalid event chain: {value}") + + # Add args to the event specs if necessary. + if event_trigger in self.get_controlled_triggers(): + events = [ + EventSpec( + handler=e.handler, + local_args=(EVENT_ARG.name,), + args=utils.get_handler_args(e, arg), + ) + for e in events + ] + + # Return the event chain. + return EventChain(events=events) + + @classmethod + def get_triggers(cls) -> Set[str]: + """Get the event triggers for the component. + + Returns: + The event triggers. + """ + return EVENT_TRIGGERS | cls.get_controlled_triggers() + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return set() + + @classmethod + def get_controlled_value(cls) -> Var: + """Get the var that is passed to the event handler for controlled triggers. + + Returns: + The controlled value. + """ + return EVENT_ARG + + def __repr__(self) -> str: + """Represent the component in React. + + Returns: + The code to render the component. + """ + return self.render() + + def __str__(self) -> str: + """Represent the component in React. + + Returns: + The code to render the component. + """ + return self.render() + + def _render(self) -> Tag: + """Define how to render the component in React. + + Returns: + The tag to render. + """ + tag = Tag(name=self.tag).add_attrs( + **{attr: getattr(self, attr) for attr in self.get_props()} + ) + + # Special case for props named `type_`. + if hasattr(self, "type_"): + tag.add_attrs(type=getattr(self, "type_")) + return tag + + @classmethod + def get_props(cls) -> Set[str]: + """Get the unique fields for the component. + + Returns: + The unique fields. + """ + return set(cls.get_fields()) - set(Component.get_fields()) + + @classmethod + def create(cls, *children, **props) -> Component: + """Create the component. + + Args: + *children: The children of the component. + **props: The props of the component. + + Returns: + The component. + """ + # Import here to avoid circular imports. + from pynecone.components.base.bare import Bare + + children = [ + Bare.create(contents=Var.create(child, is_string=True)) + if not isinstance(child, Component) + else child + for child in children + ] + return cls(children=children, **props) + + def _add_style(self, style): + self.style.update(style) + + def add_style(self, style: ComponentStyle) -> Component: + """Add additional style to the component and its children. + + Args: + style: A dict from component to styling. + + Returns: + The component with the additional style. + """ + if type(self) in style: + # Extract the style for this component. + component_style = Style(style[type(self)]) + + # Only add stylee props that are not overriden. + component_style = { + k: v for k, v in component_style.items() if k not in self.style + } + + # Add the style to the component. + self._add_style(component_style) + + # Recursively add style to the children. + for child in self.children: + child.add_style(style) + return self + + def render(self) -> str: + """Render the component. + + Returns: + The code to render the component. + """ + tag = self._render() + return str( + tag.add_attrs(**self.event_triggers, key=self.key, sx=self.style).set( + contents=utils.join( + [str(tag.contents)] + [child.render() for child in self.children] + ), + ) + ) + + def _get_custom_code(self) -> str: + """Get custom code for the component. + + Returns: + The custom code. + """ + return "" + + def get_custom_code(self) -> str: + """Get custom code for the component and its children. + + Returns: + The custom code. + """ + code = self._get_custom_code() + for child in self.children: + child_code = child.get_custom_code() + if child_code != "" and child_code not in code: + code += child_code + return code + + def _get_imports(self) -> ImportDict: + if self.library is not None and self.tag is not None: + return {self.library: {self.tag}} + return {} + + def get_imports(self) -> ImportDict: + """Get all the libraries and fields that are used by the component. + + Returns: + The import dict with the required imports. + """ + return utils.merge_imports( + self._get_imports(), *[child.get_imports() for child in self.children] + ) + + +# Map from component to styling. +ComponentStyle = Dict[Union[str, Type[Component]], Any] diff --git a/pynecone/components/datadisplay/__init__.py b/pynecone/components/datadisplay/__init__.py new file mode 100644 index 000000000..f1b2eb12d --- /dev/null +++ b/pynecone/components/datadisplay/__init__.py @@ -0,0 +1,9 @@ +"""Data display components.""" + +from .badge import Badge +from .code import Code, CodeBlock +from .datatable import DataTable +from .divider import Divider +from .list import List, ListItem, OrderedList, UnorderedList +from .stat import Stat, StatArrow, StatGroup, StatHelpText, StatLabel, StatNumber +from .table import Table, TableCaption, TableContainer, Tbody, Td, Tfoot, Th, Thead, Tr diff --git a/pynecone/components/datadisplay/badge.py b/pynecone/components/datadisplay/badge.py new file mode 100644 index 000000000..3962d03ee --- /dev/null +++ b/pynecone/components/datadisplay/badge.py @@ -0,0 +1,16 @@ +"""Badge component.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Badge(ChakraComponent): + """A badge component.""" + + tag = "Badge" + + # Variant of the badge ("solid" | "subtle" | "outline") + variant: Var[str] + + # The color of the badge + color_scheme: Var[str] diff --git a/pynecone/components/datadisplay/code.py b/pynecone/components/datadisplay/code.py new file mode 100644 index 000000000..8c4104ac2 --- /dev/null +++ b/pynecone/components/datadisplay/code.py @@ -0,0 +1,71 @@ +"""A code component.""" + +import json +from typing import Dict + +from pynecone import utils +from pynecone.components.component import Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.components.tags import Tag +from pynecone.var import Var + + +class CodeBlock(Component): + """A code block.""" + + library = "react-syntax-highlighter" + + tag = "Prism" + + # The language to use. + language: Var[str] + + # If this is enabled line numbers will be shown next to the code block. + show_line_numbers: Var[bool] + + # The starting line number to use. + starting_line_number: Var[int] + + # Whether to wrap long lines. + wrap_long_lines: Var[bool] + + # A custom style for the code block. + custom_style: Var[Dict[str, str]] + + # Props passed down to the code tag. + code_tag_props: Var[Dict[str, str]] + + @classmethod + def create(cls, *children, **props): + """Create a text component. + + Args: + *children: The children of the component. + **props: The props to pass to the component. + + Returns: + The text component. + """ + # This component handles style in a special prop. + custom_style = props.get("custom_style", {}) + + # Transfer style props to the custom style prop. + for key, value in props.items(): + if key not in cls.get_fields(): + custom_style[key] = value + + # Create the component. + return super().create( + *children, + **props, + ) + + def _add_style(self, style): + self.custom_style = self.custom_style or {} + self.custom_style.update(style) # type: ignore + + +class Code(ChakraComponent): + """Used to display inline code.""" + + tag = "Code" diff --git a/pynecone/components/datadisplay/datatable.py b/pynecone/components/datadisplay/datatable.py new file mode 100644 index 000000000..f80c4ca08 --- /dev/null +++ b/pynecone/components/datadisplay/datatable.py @@ -0,0 +1,55 @@ +"""Table components.""" + +from typing import Any, Dict, List, Union + +from pynecone.components.component import Component +from pynecone.components.tags import Tag +from pynecone.var import Var + + +class Gridjs(Component): + """A component that wraps a nivo bar component.""" + + library = "gridjs-react" + + +class DataTable(Gridjs): + """A data table component.""" + + tag = "Grid" + + df: Var[Any] + + # The data to display. EIther a list of lists or a pandas dataframe. + data: Any + + # The columns to display. + columns: Var[List] + + # Enable a search bar. + search: Var[bool] + + # Enable sorting on columns. + sort: Var[bool] + + # Enable resizable columns. + resizable: Var[bool] + + # Enable pagination. + pagination: Var[bool] + + def _get_custom_code(self) -> str: + return """ +import "gridjs/dist/theme/mermaid.css"; +""" + + def _render(self) -> Tag: + if type(self.data).__name__ == "DataFrame": + self.columns = Var.create(list(self.data.columns.values.tolist())) # type: ignore + self.data = Var.create(list(self.data.values.tolist())) # type: ignore + + if isinstance(self.df, Var): + self.columns = self.df["columns"] + self.data = self.df["data"] + + return super()._render() diff --git a/pynecone/components/datadisplay/divider.py b/pynecone/components/datadisplay/divider.py new file mode 100644 index 000000000..1ea71854c --- /dev/null +++ b/pynecone/components/datadisplay/divider.py @@ -0,0 +1,16 @@ +"""A line to divide parts of the layout.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Divider(ChakraComponent): + """Dividers are used to visually separate content in a list or group.""" + + tag = "Divider" + + # Pass the orientation prop and set it to either horizontal or vertical. If the vertical orientation is used, make sure that the parent element is assigned a height. + orientation: Var[str] + + # Variant of the divider ("solid" | "dashed") + variant: Var[str] diff --git a/pynecone/components/datadisplay/list.py b/pynecone/components/datadisplay/list.py new file mode 100644 index 000000000..8b8c9cced --- /dev/null +++ b/pynecone/components/datadisplay/list.py @@ -0,0 +1,38 @@ +"""List components.""" + +from pynecone.components.component import Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class List(ChakraComponent): + """List component is used to display list items. It renders a ul element by default.""" + + tag = "List" + + # The space between each list item + spacing: Var[str] + + # Shorthand prop for listStylePosition + style_position: Var[str] + + # Shorthand prop for listStyleType + style_type: Var[str] + + +class ListItem(ChakraComponent): + """ListItem composes Box so you can pass all style and pseudo style props.""" + + tag = "ListItem" + + +class OrderedList(ChakraComponent): + """An ordered list component.""" + + tag = "OrderedList" + + +class UnorderedList(ChakraComponent): + """An unordered list component.""" + + tag = "UnorderedList" diff --git a/pynecone/components/datadisplay/stat.py b/pynecone/components/datadisplay/stat.py new file mode 100644 index 000000000..eaedaebac --- /dev/null +++ b/pynecone/components/datadisplay/stat.py @@ -0,0 +1,43 @@ +"""Statistics components.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Stat(ChakraComponent): + """The Stat component is used to display some statistics. It can take in a label, a number and a help text.""" + + tag = "Stat" + + +class StatLabel(ChakraComponent): + """A stat label component.""" + + tag = "StatLabel" + + +class StatNumber(ChakraComponent): + """The stat to display.""" + + tag = "StatNumber" + + +class StatHelpText(ChakraComponent): + """A helper text to display under the stat.""" + + tag = "StatHelpText" + + +class StatArrow(ChakraComponent): + """A stat arrow component indicating the direction of change.""" + + tag = "StatArrow" + + # The type of arrow, either increase or decrease. + type_: Var[str] + + +class StatGroup(ChakraComponent): + """A stat group component to evenly space out the stats.""" + + tag = "StatGroup" diff --git a/pynecone/components/datadisplay/table.py b/pynecone/components/datadisplay/table.py new file mode 100644 index 000000000..98e7dbc88 --- /dev/null +++ b/pynecone/components/datadisplay/table.py @@ -0,0 +1,81 @@ +"""Table components.""" + +from typing import List + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Table(ChakraComponent): + """A table component.""" + + tag = "Table" + + # The color scheme of the table + color_scheme: Var[str] + + # The variant of the table style to use + variant: Var[str] + + # The size of the table + size: Var[str] + + # The placement of the table caption. + placement: Var[str] + + +class Thead(Table): + """A table header component.""" + + tag = "Thead" + + +class Tbody(Table): + """A table body component.""" + + tag = "Tbody" + + +class Tfoot(Table): + """A table footer component.""" + + tag = "Tfoot" + + +class Tr(Table): + """A table row component.""" + + tag = "Tr" + + +class Th(ChakraComponent): + """A table header cell component.""" + + tag = "Th" + + # Aligns the cell content to the right. + is_numeric: Var[bool] + + +class Td(ChakraComponent): + """A table data cell component.""" + + tag = "Td" + + # Aligns the cell content to the right. + is_numeric: Var[bool] + + +class TableCaption(ChakraComponent): + """A table caption component.""" + + tag = "TableCaption" + + # The placement of the table caption. This sets the `caption-side` CSS attribute. + placement: Var[str] + + +class TableContainer(ChakraComponent): + """The table container component renders a div that wraps the table component.""" + + tag = "TableContainer" diff --git a/pynecone/components/disclosure/__init__.py b/pynecone/components/disclosure/__init__.py new file mode 100644 index 000000000..87e1e1c41 --- /dev/null +++ b/pynecone/components/disclosure/__init__.py @@ -0,0 +1,12 @@ +"""Disclosure components.""" + +from .accordion import ( + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, +) +from .tabs import Tab, TabList, TabPanel, TabPanels, Tabs + +__all__ = [f for f in dir() if f[0].isupper()] # type: ignore diff --git a/pynecone/components/disclosure/accordion.py b/pynecone/components/disclosure/accordion.py new file mode 100644 index 000000000..e9b383849 --- /dev/null +++ b/pynecone/components/disclosure/accordion.py @@ -0,0 +1,60 @@ +"""Container to stack elements with spacing.""" + +from typing import List, Optional, Union + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Accordion(ChakraComponent): + """The wrapper that uses cloneElement to pass props to AccordionItem children.""" + + tag = "Accordion" + + # If true, multiple accordion items can be expanded at once. + allow_multiple: Var[bool] + + # If true, any expanded accordion item can be collapsed again. + allow_toggle: Var[bool] + + # The initial index(es) of the expanded accordion item(s). + default_index: Var[Optional[List[int]]] + + # The index(es) of the expanded accordion item + index: Var[Union[int, List[int]]] + + # If true, height animation and transitions will be disabled. + reduce_motion: Var[bool] + + +class AccordionItem(ChakraComponent): + """A single accordion item.""" + + tag = "AccordionItem" + + # A unique id for the accordion item. + id_: Var[str] + + # If true, the accordion item will be disabled. + is_disabled: Var[bool] + + # If true, the accordion item will be focusable. + is_focusable: Var[bool] + + +class AccordionButton(ChakraComponent): + """The button that toggles the expand/collapse state of the accordion item. This button must be wrapped in an element with role heading.""" + + tag = "AccordionButton" + + +class AccordionPanel(ChakraComponent): + """The container for the details to be revealed.""" + + tag = "AccordionPanel" + + +class AccordionIcon(ChakraComponent): + """A chevron-down icon that rotates based on the expanded/collapsed state.""" + + tag = "AccordionIcon" diff --git a/pynecone/components/disclosure/tabs.py b/pynecone/components/disclosure/tabs.py new file mode 100644 index 000000000..9d602bd6e --- /dev/null +++ b/pynecone/components/disclosure/tabs.py @@ -0,0 +1,70 @@ +"""Tab components.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Tabs(ChakraComponent): + """An accessible tabs component that provides keyboard interactions and ARIA attributes described in the WAI-ARIA Tabs Design Pattern. Tabs, provides context and state for all components.""" + + tag = "Tabs" + + # The alignment of the tabs ("center" | "end" | "start"). + align: Var[str] + + # The initial index of the selected tab (in uncontrolled mode). + default_index: Var[int] + + # The id of the tab. + id_: Var[str] + + # If true, tabs will stretch to width of the tablist. + is_fitted: Var[bool] + + # Performance booster. If true, rendering of the tab panel's will be deferred until it is selected. + is_lazy: Var[bool] + + # If true, the tabs will be manually activated and display its panel by pressing Space or Enter. If false, the tabs will be automatically activated and their panel is displayed when they receive focus. + is_manual: Var[bool] + + # The orientation of the tab list. + orientation: Var[str] + + # "line" | "enclosed" | "enclosed-colored" | "soft-rounded" | "solid-rounded" | "unstyled" + variant: Var[str] + + +class Tab(ChakraComponent): + """An element that serves as a label for one of the tab panels and can be activated to display that panel..""" + + tag = "Tab" + + # If true, the Tab won't be toggleable. + is_disabled: Var[bool] + + # If true, the Tab will be selected. + is_selected: Var[bool] + + # The id of the tab. + id_: Var[str] + + # The id of the panel. + panel_id: Var[str] + + +class TabList(ChakraComponent): + """Wrapper for the Tab components.""" + + tag = "TabList" + + +class TabPanels(ChakraComponent): + """Wrapper for the Tab components.""" + + tag = "TabPanels" + + +class TabPanel(ChakraComponent): + """An element that contains the content associated with a tab.""" + + tag = "TabPanel" diff --git a/pynecone/components/feedback/__init__.py b/pynecone/components/feedback/__init__.py new file mode 100644 index 000000000..8037e366a --- /dev/null +++ b/pynecone/components/feedback/__init__.py @@ -0,0 +1,7 @@ +"""Convenience functions to define core components.""" + +from .alert import Alert, AlertDescription, AlertIcon, AlertTitle +from .circularprogress import CircularProgress, CircularProgressLabel +from .progress import Progress +from .skeleton import Skeleton, SkeletonCircle, SkeletonText +from .spinner import Spinner diff --git a/pynecone/components/feedback/alert.py b/pynecone/components/feedback/alert.py new file mode 100644 index 000000000..45e0e6a9f --- /dev/null +++ b/pynecone/components/feedback/alert.py @@ -0,0 +1,34 @@ +"""Alert components.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Alert(ChakraComponent): + """Container to stack elements with spacing.""" + + tag = "Alert" + + # The status of the alert ("success" | "info" | "warning" | "error") + status: Var[str] + + # "subtle" | "left-accent" | "top-accent" | "solid" + variant: Var[str] + + +class AlertIcon(ChakraComponent): + """AlertIcon composes Icon and changes the icon based on the status prop.""" + + tag = "AlertIcon" + + +class AlertTitle(ChakraComponent): + """AlertTitle composes the Box component.""" + + tag = "AlertTitle" + + +class AlertDescription(ChakraComponent): + """AlertDescription composes the Box component.""" + + tag = "AlertDescription" diff --git a/pynecone/components/feedback/circularprogress.py b/pynecone/components/feedback/circularprogress.py new file mode 100644 index 000000000..60934441a --- /dev/null +++ b/pynecone/components/feedback/circularprogress.py @@ -0,0 +1,40 @@ +"""Container to stack elements with spacing.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class CircularProgress(ChakraComponent): + """The CircularProgress component is used to indicate the progress for determinate and indeterminate processes.""" + + tag = "CircularProgress" + + # If true, the cap of the progress indicator will be rounded. + cap_is_round: Var[bool] + + # If true, the progress will be indeterminate and the value prop will be ignored + is_indeterminate: Var[bool] + + # Maximum value defining 100% progress made (must be higher than 'min') + max_: Var[int] + + # Minimum value defining 'no progress' (must be lower than 'max') + min_: Var[int] + + # This defines the stroke width of the svg circle. + thickness: Var[int] + + # The color name of the progress track. Use a color key in the theme object + track_color: Var[str] + + # Current progress (must be between min/max). + value: Var[int] + + # The desired valueText to use in place of the value. + value_text: Var[str] + + +class CircularProgressLabel(ChakraComponent): + """Label of CircularProcess.""" + + tag = "CircularProgressLabel" diff --git a/pynecone/components/feedback/progress.py b/pynecone/components/feedback/progress.py new file mode 100644 index 000000000..bfac78f44 --- /dev/null +++ b/pynecone/components/feedback/progress.py @@ -0,0 +1,31 @@ +"""Container to stack elements with spacing.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Progress(ChakraComponent): + """A bar to display progress.""" + + tag = "Progress" + + # If true, the progress bar will show stripe + has_striped: Var[bool] + + # If true, and hasStripe is true, the stripes will be animated + is_animated: Var[bool] + + # If true, the progress will be indeterminate and the value prop will be ignored + is_indeterminate: Var[bool] + + # The maximum value of the progress + max_: Var[int] + + # The minimum value of the progress + min_: Var[int] + + # The value of the progress indicator. If undefined the progress bar will be in indeterminate state + value: Var[int] + + # The color scheme of the progress bar. + color_scheme: Var[str] diff --git a/pynecone/components/feedback/skeleton.py b/pynecone/components/feedback/skeleton.py new file mode 100644 index 000000000..9826b8ff9 --- /dev/null +++ b/pynecone/components/feedback/skeleton.py @@ -0,0 +1,70 @@ +"""Container to stack elements with spacing.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Skeleton(ChakraComponent): + """Skeleton is used to display the loading state of some components. You can use it as a standalone component. Or to wrap another component to take the same height and width.""" + + tag = "Skeleton" + + # The color at the animation end + end_color: Var[str] + + # The fadeIn duration in seconds + fade_duration: Var[float] + + # If true, it'll render its children with a nice fade transition + is_loaded: Var[bool] + + # The animation speed in seconds + speed: Var[float] + + # The color at the animation start + start_color: Var[str] + + +class SkeletonCircle(ChakraComponent): + """SkeletonCircle is used to display the loading state of some components.""" + + tag = "SkeletonCircle" + + # The color at the animation end + end_color: Var[str] + + # The fadeIn duration in seconds + fade_duration: Var[float] + + # If true, it'll render its children with a nice fade transition + is_loaded: Var[bool] + + # The animation speed in seconds + speed: Var[float] + + # The color at the animation start + start_color: Var[str] + + +class SkeletonText(ChakraComponent): + """SkeletonText is used to display the loading state of some components.""" + + tag = "SkeletonText" + + # The color at the animation end + end_color: Var[str] + + # The fadeIn duration in seconds + fade_duration: Var[float] + + # If true, it'll render its children with a nice fade transition + is_loaded: Var[bool] + + # The animation speed in seconds + speed: Var[float] + + # The color at the animation start + start_color: Var[str] + + # Number is lines of text. + no_of_lines: Var[int] diff --git a/pynecone/components/feedback/spinner.py b/pynecone/components/feedback/spinner.py new file mode 100644 index 000000000..746616177 --- /dev/null +++ b/pynecone/components/feedback/spinner.py @@ -0,0 +1,25 @@ +"""Container to stack elements with spacing.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Spinner(ChakraComponent): + """The component that spins.""" + + tag = "Spinner" + + # The color of the empty area in the spinner + empty_color: Var[str] + + # For accessibility, it is important to add a fallback loading text. This text will be visible to screen readers. + label: Var[str] + + # The speed of the spinner must be as a string and in seconds '1s'. Default is '0.45s'. + speed: Var[str] + + # The thickness of the spinner. + thickness: Var[int] + + # "xs" | "sm" | "md" | "lg" | "xl" + size: Var[str] diff --git a/pynecone/components/forms/__init__.py b/pynecone/components/forms/__init__.py new file mode 100644 index 000000000..469b37b25 --- /dev/null +++ b/pynecone/components/forms/__init__.py @@ -0,0 +1,29 @@ +"""Convenience functions to define core components.""" + +from .button import Button, ButtonGroup +from .checkbox import Checkbox, CheckboxGroup +from .editable import Editable, EditableInput, EditablePreview, EditableTextarea +from .formcontrol import FormControl, FormErrorMessage, FormHelperText, FormLabel +from .iconbutton import IconButton +from .input import Input, InputGroup, InputLeftAddon, InputRightAddon +from .numberinput import ( + NumberDecrementStepper, + NumberIncrementStepper, + NumberInput, + NumberInputField, + NumberInputStepper, +) +from .pininput import PinInput, PinInputField +from .radio import Radio, RadioGroup +from .rangeslider import ( + RangeSlider, + RangeSliderFilledTrack, + RangeSliderThumb, + RangeSliderTrack, +) +from .select import Option, Select +from .slider import Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack +from .switch import Switch +from .textarea import TextArea + +__all__ = [f for f in dir() if f[0].isupper()] # type: ignore diff --git a/pynecone/components/forms/button.py b/pynecone/components/forms/button.py new file mode 100644 index 000000000..5e85aa041 --- /dev/null +++ b/pynecone/components/forms/button.py @@ -0,0 +1,55 @@ +"""A button component.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Button(ChakraComponent): + """The Button component is used to trigger an event or event, such as submitting a form, opening a dialog, canceling an event, or performing a delete operation.""" + + tag = "Button" + + # The type of button. + type: Var[str] + + # The space between the button icon and label. + icon_spacing: Var[int] + + # If true, the button will be styled in its active state. + is_active: Var[bool] + + # If true, the button will be styled in its disabled state. + is_disabled: Var[bool] + + # If true, the button will take up the full width of its container. + is_full_width: Var[bool] + + # If true, the button will show a spinner. + is_loading: Var[bool] + + # The label to show in the button when isLoading is true If no text is passed, it only shows the spinner. + loading_text: Var[str] + + # "lg" | "md" | "sm" | "xs" + size: Var[str] + + # "ghost" | "outline" | "solid" | "link" | "unstyled" + variant: Var[str] + + # Built in color scheme for ease of use. + color_scheme: Var[str] + + +class ButtonGroup(ChakraComponent): + """A group of buttons.""" + + tag = "ButtonGroup" + + # If true, the borderRadius of button that are direct children will be altered to look flushed together. + is_attached: Var[bool] + + # If true, all wrapped button will be disabled. + is_disabled: Var[bool] + + # The spacing between the buttons. + spacing: Var[int] diff --git a/pynecone/components/forms/checkbox.py b/pynecone/components/forms/checkbox.py new file mode 100644 index 000000000..ea287b9d6 --- /dev/null +++ b/pynecone/components/forms/checkbox.py @@ -0,0 +1,82 @@ +"""A checkbox component.""" + +from typing import Set + +from pynecone.components.component import EVENT_ARG +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Checkbox(ChakraComponent): + """The Checkbox component is used in forms when a user needs to select multiple values from several options.""" + + tag = "Checkbox" + + # Color scheme for checkbox. + color_scheme: Var[str] + + # "sm" | "md" | "lg" + size: Var[str] + + # If true, the checkbox will be checked. + is_checked: Var[bool] + + # If true, the checkbox will be disabled + is_disabled: Var[bool] + + # If true and is_disabled is passed, the checkbox will remain tabbable but not interactive + is_focusable: Var[bool] + + # If true, the checkbox will be indeterminate. This only affects the icon shown inside checkbox and does not modify the is_checked var. + is_indeterminate: Var[bool] + + # If true, the checkbox is marked as invalid. Changes style of unchecked state. + is_invalid: Var[bool] + + # If true, the checkbox will be readonly + is_read_only: Var[bool] + + # If true, the checkbox input is marked as required, and required attribute will be added + is_required: Var[bool] + + # The name of the input field in a checkbox (Useful for form submission). + name: Var[str] + + # The spacing between the checkbox and its label text (0.5rem) + spacing: Var[str] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return {"on_change"} + + @classmethod + def get_controlled_value(cls) -> Var: + """Get the var that is passed to the event handler for controlled triggers. + + Returns: + The controlled value. + """ + return EVENT_ARG.target.checked + + +class CheckboxGroup(ChakraComponent): + """A group of checkboxes.""" + + tag = "CheckboxGroup" + + # The value of the checkbox group + value: Var[str] + + # The initial value of the checkbox group + default_value: Var[str] + + # If true, all wrapped checkbox inputs will be disabled + is_disabled: Var[bool] + + # If true, input elements will receive checked attribute instead of isChecked. This assumes, you're using native radio inputs + is_native: Var[bool] diff --git a/pynecone/components/forms/editable.py b/pynecone/components/forms/editable.py new file mode 100644 index 000000000..350802896 --- /dev/null +++ b/pynecone/components/forms/editable.py @@ -0,0 +1,69 @@ +"""An editable component.""" + +from typing import Set + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.components.tags import Tag +from pynecone.var import Var + + +class Editable(ChakraComponent): + """The wrapper component that provides context value.""" + + tag = "Editable" + + # If true, the Editable will be disabled. + is_disabled: Var[bool] + + # If true, the read only view, has a tabIndex set to 0 so it can receive focus via the keyboard or click. + is_preview_focusable: Var[bool] + + # The placeholder text when the value is empty. + placeholder: Var[str] + + # If true, the input's text will be highlighted on focus. + select_all_on_focus: Var[bool] + + # If true, the Editable will start with edit mode by default. + start_with_edit_view: Var[bool] + + # If true, it'll update the value onBlur and turn off the edit mode. + submit_on_blur: Var[bool] + + # The value of the Editable in both edit & preview mode + value: Var[str] + + # The initial value of the Editable in both edit and preview mode. + default_value: Var[str] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return { + "on_change", + "on_edit", + "on_submit", + "on_cancel", + } + + +class EditableInput(ChakraComponent): + """The edit view of the component. It shows when you click or focus on the text.""" + + tag = "EditableInput" + + +class EditableTextarea(ChakraComponent): + """Use the textarea element to handle multi line text input in an editable context.""" + + tag = "EditableTextarea" + + +class EditablePreview(ChakraComponent): + """The read-only view of the component.""" + + tag = "EditablePreview" diff --git a/pynecone/components/forms/formcontrol.py b/pynecone/components/forms/formcontrol.py new file mode 100644 index 000000000..adb980f69 --- /dev/null +++ b/pynecone/components/forms/formcontrol.py @@ -0,0 +1,46 @@ +"""Form components.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class FormControl(ChakraComponent): + """FormControl provides context such as isInvalid, isDisabled, and isRequired to form elements.""" + + tag = "FormControl" + + # If true, the form control will be disabled. + is_disabled: Var[bool] + + # If true, the form control will be invalid. + is_invalid: Var[bool] + + # If true, the form control will be readonly + is_read_only: Var[bool] + + # If true, the form control will be required. + is_required: Var[bool] + + # The label text used to inform users as to what information is requested for a text field. + label: Var[str] + + +class FormHelperText(ChakraComponent): + """A form helper text component.""" + + tag = "FormHelperText" + + +class FormLabel(ChakraComponent): + """A form label component.""" + + tag = "FormLabel" + + # Link + html_for: Var[str] + + +class FormErrorMessage(ChakraComponent): + """A form error message component.""" + + tag = "FormErrorMessage" diff --git a/pynecone/components/forms/iconbutton.py b/pynecone/components/forms/iconbutton.py new file mode 100644 index 000000000..1ff5effc9 --- /dev/null +++ b/pynecone/components/forms/iconbutton.py @@ -0,0 +1,34 @@ +"""A button component.""" + +from pynecone.components.typography.text import Text +from pynecone.var import Var + + +class IconButton(Text): + """A button that can be clicked.""" + + tag = "IconButton" + + # The type of button. + type: Var[str] + + # A label that describes the button + aria_label: Var[str] + + # The icon to be used in the button. + icon: Var[str] + + # If true, the button will be styled in its active state. + is_active: Var[bool] + + # If true, the button will be disabled. + is_disabled: Var[bool] + + # If true, the button will show a spinner. + is_loading: Var[bool] + + # If true, the button will be perfectly round. Else, it'll be slightly round + is_round: Var[bool] + + # Replace the spinner component when isLoading is set to true + spinner: Var[str] diff --git a/pynecone/components/forms/input.py b/pynecone/components/forms/input.py new file mode 100644 index 000000000..01969d78a --- /dev/null +++ b/pynecone/components/forms/input.py @@ -0,0 +1,82 @@ +"""An input component.""" + +from typing import Set + +from pynecone.components.component import EVENT_ARG +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Input(ChakraComponent): + """The Input component is a component that is used to get user input in a text field.""" + + tag = "Input" + + # State var to bind the the input. + value: Var[str] + + # The default value of the input. + default_value: Var[str] + + # The placeholder text. + placeholder: Var[str] + + # The type of input. + type_: Var[str] = "text" # type: ignore + + # The border color when the input is invalid. + error_border_color: Var[str] + + # The border color when the input is focused. + focus_border_color: Var[str] + + # If true, the form control will be disabled. This has 2 side effects - The FormLabel will have `data-disabled` attribute - The form element (e.g, Input) will be disabled + is_disabled: Var[bool] + + # If true, the form control will be invalid. This has 2 side effects - The FormLabel and FormErrorIcon will have `data-invalid` set to true - The form element (e.g, Input) will have `aria-invalid` set to true + is_invalid: Var[bool] + + # If true, the form control will be readonly. + is_read_only: Var[bool] + + # If true, the form control will be required. This has 2 side effects - The FormLabel will show a required indicator - The form element (e.g, Input) will have `aria-required` set to true + is_required: Var[bool] + + # "outline" | "filled" | "flushed" | "unstyled" + variant: Var[str] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return {"on_change", "on_focus", "on_blur"} + + @classmethod + def get_controlled_value(cls) -> Var: + """Get the var that is passed to the event handler for controlled triggers. + + Returns: + The controlled value. + """ + return EVENT_ARG.target.value + + +class InputGroup(ChakraComponent): + """The InputGroup component is a component that is used to group a set of inputs.""" + + tag = "InputGroup" + + +class InputLeftAddon(ChakraComponent): + """The InputLeftAddon component is a component that is used to add an addon to the left of an input.""" + + tag = "InputLeftAddon" + + +class InputRightAddon(ChakraComponent): + """The InputRightAddon component is a component that is used to add an addon to the right of an input.""" + + tag = "InputRightAddon" diff --git a/pynecone/components/forms/numberinput.py b/pynecone/components/forms/numberinput.py new file mode 100644 index 000000000..f1dfe46ba --- /dev/null +++ b/pynecone/components/forms/numberinput.py @@ -0,0 +1,120 @@ +"""A number input component.""" + +from typing import Set + +from pynecone.components.component import Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class NumberInput(ChakraComponent): + """The wrapper that provides context and logic to the components.""" + + tag = "NumberInput" + + # State var to bind the the input. + value: Var[int] + + # If true, the input's value will change based on mouse wheel. + allow_mouse_wheel: Var[bool] + + # This controls the value update when you blur out of the input. - If true and the value is greater than max, the value will be reset to max - Else, the value remains the same. + clamped_value_on_blur: Var[bool] + + # The initial value of the counter. Should be less than max and greater than min + default_value: Var[int] + + # The border color when the input is invalid. + error_border_color: Var[str] + + # The border color when the input is focused. + focus_border_color: Var[str] + + # If true, the input will be focused as you increment or decrement the value with the stepper + focus_input_on_change: Var[bool] + + # Hints at the type of data that might be entered by the user. It also determines the type of keyboard shown to the user on mobile devices ("text" | "search" | "none" | "tel" | "url" | "email" | "numeric" | "decimal") + input_mode: Var[str] + + # Whether the input should be disabled. + is_disabled: Var[bool] + + # If true, the input will have `aria-invalid` set to true + is_invalid: Var[bool] + + # If true, the input will be in readonly mode + is_read_only: Var[bool] + + # Whether the input is required + is_required: Var[bool] + + # Whether the pressed key should be allowed in the input. The default behavior is to allow DOM floating point characters defined by /^[Ee0-9+\-.]$/ + is_valid_character: Var[str] + + # This controls the value update behavior in general. - If true and you use the stepper or up/down arrow keys, the value will not exceed the max or go lower than min - If false, the value will be allowed to go out of range. + keep_within_range: Var[bool] + + # The maximum value of the counter + max_: Var[int] + + # The minimum value of the counter + min_: Var[int] + + # "outline" | "filled" | "flushed" | "unstyled" + variant: Var[str] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return {"on_change"} + + @classmethod + def create(cls, *children, **props) -> Component: + """Create a number input component. + + If no children are provided, a default stepper will be used. + + Args: + *children: The children of the component. + **props: The props of the component. + + Returns: + The component. + """ + if len(children) == 0: + children = [ + NumberInputField.create(), + NumberInputStepper.create( + NumberIncrementStepper.create(), + NumberDecrementStepper.create(), + ), + ] + return super().create(*children, **props) + + +class NumberInputField(ChakraComponent): + """The input field itself.""" + + tag = "NumberInputField" + + +class NumberInputStepper(ChakraComponent): + """The wrapper for the input's stepper buttons.""" + + tag = "NumberInputStepper" + + +class NumberIncrementStepper(ChakraComponent): + """The button to increment the value of the input.""" + + tag = "NumberIncrementStepper" + + +class NumberDecrementStepper(ChakraComponent): + """The button to decrement the value of the input.""" + + tag = "NumberDecrementStepper" diff --git a/pynecone/components/forms/pininput.py b/pynecone/components/forms/pininput.py new file mode 100644 index 000000000..469ed3b3a --- /dev/null +++ b/pynecone/components/forms/pininput.py @@ -0,0 +1,88 @@ +"""A pin input component.""" + +from typing import Set + +from pynecone.components.component import Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class PinInput(ChakraComponent): + """The component that provides context to all the pin-input fields.""" + + tag = "PinInput" + + # State var to bind the the input. + value: Var[str] + + # If true, the pin input receives focus on mount + auto_focus: Var[bool] + + # The default value of the pin input + default_value: Var[str] + + # The border color when the input is invalid. + error_border_color: Var[str] + + # The border color when the input is focused. + focus_border_color: Var[str] + + # The top-level id string that will be applied to the input fields. The index of the input will be appended to this top-level id. + id_: Var[str] + + # The length of the number input. + length: Var[int] + + # If true, the pin input component is put in the disabled state + is_disabled: Var[bool] + + # If true, the pin input component is put in the invalid state + is_invalid: Var[bool] + + # If true, focus will move automatically to the next input once filled + manage_focus: Var[bool] + + # If true, the input's value will be masked just like `type=password` + mask: Var[bool] + + # The placeholder for the pin input + placeholder: Var[str] + + # The type of values the pin-input should allow ("number" | "alphanumeric"). + type_: Var[str] + + # "outline" | "flushed" | "filled" | "unstyled" + variant: Var[str] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return {"on_change", "on_complete"} + + @classmethod + def create(cls, *children, **props) -> Component: + """Create a pin input component. + + If no children are passed in, the component will create a default pin input + based on the length prop. + + Args: + *children: The children of the component. + **props: The props of the component. + + Returns: + The pin input component. + """ + if len(children) == 0 and "length" in props: + children = [PinInputField()] * props["length"] + return super().create(*children, **props) + + +class PinInputField(ChakraComponent): + """The text field that user types in - must be a direct child of PinInput.""" + + tag = "PinInputField" diff --git a/pynecone/components/forms/radio.py b/pynecone/components/forms/radio.py new file mode 100644 index 000000000..0fa0d4d2e --- /dev/null +++ b/pynecone/components/forms/radio.py @@ -0,0 +1,93 @@ +"""A radio component.""" + + +from typing import Any, Set + +from pynecone.components.component import Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.components.typography.text import Text +from pynecone.var import Var + + +class RadioGroup(ChakraComponent): + """A grouping of individual radio options.""" + + tag = "RadioGroup" + + # State var to bind the the input. + value: Var[Any] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return {"on_change"} + + @classmethod + def create(cls, *children, **props) -> Component: + """Create a radio group component. + + Args: + *children: The children of the component. + **props: The props of the component. + + Returns: + The component. + """ + if len(children) == 1 and isinstance(children[0], list): + children = [Radio.create(child) for child in children[0]] + return super().create(*children, **props) + + +class Radio(Text): + """Radios are used when only one choice may be selected in a series of options.""" + + tag = "Radio" + + # Value of radio. + value: Var[Any] + + # The default value. + default_value: Var[Any] + + # The color scheme. + color_scheme: Var[str] + + # If true, the radio will be initially checked. + default_checked: Var[bool] + + # If true, the radio will be checked. You'll need to pass onChange to update its value (since it is now controlled) + is_checked: Var[bool] + + # If true, the radio will be disabled. + is_disabled: Var[bool] + + # If true, the radio button will be invalid. This also sets `aria-invalid` to true. + is_invalid: Var[bool] + + # If true, the radio will be read-only + is_read_only: Var[bool] + + # If true, the radio button will be required. This also sets `aria-required` to true. + is_required: Var[bool] + + @classmethod + def create(cls, *children, **props) -> Component: + """Create a radio component. + + By default, the value is bound to the first child. + + Args: + *children: The children of the component. + **props: The props of the component. + + Returns: + The radio component. + """ + if "value" not in props: + assert len(children) == 1 + props["value"] = children[0] + return super().create(*children, **props) diff --git a/pynecone/components/forms/rangeslider.py b/pynecone/components/forms/rangeslider.py new file mode 100644 index 000000000..d2b62c40c --- /dev/null +++ b/pynecone/components/forms/rangeslider.py @@ -0,0 +1,99 @@ +"""A button component.""" + +from typing import List, Set + +from pynecone.components.component import Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class RangeSlider(ChakraComponent): + """The RangeSlider is a multi thumb slider used to select a range of related values. A common use-case of this component is a price range picker that allows a user to set the minimum and maximum price.""" + + tag = "RangeSlider" + + # State var to bind the the input. + value: Var[List[int]] + + # The default values. + default_value: Var[List[int]] + + # The writing mode ("ltr" | "rtl") + direction: Var[str] + + # If false, the slider handle will not capture focus when value changes. + focus_thumb_on_change: Var[bool] + + # If true, the slider will be disabled + is_disabled: Var[bool] + + # If true, the slider will be in `read-only` state. + is_read_only: Var[bool] + + # If true, the value will be incremented or decremented in reverse. + is_reversed: Var[bool] + + # The minimum value of the slider. + min_: Var[int] + + # The maximum value of the slider. + max_: Var[int] + + # The minimum distance between slider thumbs. Useful for preventing the thumbs from being too close together. + min_steps_between_thumbs: Var[int] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return { + "on_change", + "on_change_end", + "on_change_start", + } + + @classmethod + def create(cls, *children, **props) -> Component: + """Create a RangeSlider component. + + If no children are provided, a default RangeSlider will be created. + + Args: + children: The children of the component. + props: The properties of the component. + + Returns: + The RangeSlider component. + """ + if len(children) == 0: + children = [ + RangeSliderTrack.create( + RangeSliderFilledTrack.create(), + ), + RangeSliderThumb.create(index=0), + RangeSliderThumb.create(index=1), + ] + return super().create(*children, **props) + + +class RangeSliderTrack(ChakraComponent): + """A button component.""" + + tag = "RangeSliderTrack" + + +class RangeSliderFilledTrack(ChakraComponent): + """A button component.""" + + tag = "RangeSliderFilledTrack" + + +class RangeSliderThumb(ChakraComponent): + """A button component.""" + + tag = "RangeSliderThumb" + + index: Var[int] diff --git a/pynecone/components/forms/select.py b/pynecone/components/forms/select.py new file mode 100644 index 000000000..7307eb6f5 --- /dev/null +++ b/pynecone/components/forms/select.py @@ -0,0 +1,108 @@ +"""A select component.""" + +from typing import Any, Set + +from pynecone import utils +from pynecone.components.component import EVENT_ARG, Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.components.tags import Tag +from pynecone.components.typography.text import Text +from pynecone.var import Var + + +class Select(ChakraComponent): + """Select component is a component that allows users pick a value from predefined options. Ideally, it should be used when there are more than 5 options, otherwise you might consider using a radio group instead.""" + + tag = "Select" + + # State var to bind the the select. + value: Var[str] + + # The default value of the select. + default_value: Var[str] + + # The placeholder text. + placeholder: Var[str] + + # The border color when the select is invalid. + error_border_color: Var[str] + + # The border color when the select is focused. + focus_border_color: Var[str] + + # If true, the select will be disabled. + is_disabled: Var[bool] + + # If true, the form control will be invalid. This has 2 side effects: - The FormLabel and FormErrorIcon will have `data-invalid` set to true - The form element (e.g, Input) will have `aria-invalid` set to true + is_invalid: Var[bool] + + # If true, the form control will be readonly + is_read_only: Var[bool] + + # If true, the form control will be required. This has 2 side effects: - The FormLabel will show a required indicator - The form element (e.g, Input) will have `aria-required` set to true + is_required: Var[bool] + + # "outline" | "filled" | "flushed" | "unstyled" + variant: Var[str] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return {"on_change"} + + @classmethod + def get_controlled_value(cls) -> Var: + """Get the var that is passed to the event handler for controlled triggers. + + Returns: + The controlled value. + """ + return EVENT_ARG.target.value + + @classmethod + def create(cls, *children, **props) -> Component: + """Create a select component. + + If a list is provided as the first children, a default component + will be created for each item in the list. + + Args: + *children: The children of the component. + **props: The props of the component. + + Returns: + The component. + """ + if len(children) == 1 and isinstance(children[0], list): + children = [Option.create(child) for child in children[0]] + return super().create(*children, **props) + + +class Option(Text): + """A button component.""" + + tag = "option" + + value: Var[Any] + + @classmethod + def create(cls, *children, **props) -> Component: + """Create a select option component. + + By default, the value of the option is the text of the option. + + Args: + *children: The children of the component. + **props: The props of the component. + + Returns: + The component. + """ + if "value" not in props: + assert len(children) == 1 + props["value"] = children[0] + return super().create(*children, **props) diff --git a/pynecone/components/forms/slider.py b/pynecone/components/forms/slider.py new file mode 100644 index 000000000..ff86315c5 --- /dev/null +++ b/pynecone/components/forms/slider.py @@ -0,0 +1,102 @@ +"""A slider component.""" + +from typing import Set + +from pynecone.components.component import Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Slider(ChakraComponent): + """The wrapper that provides context and functionality for all children.""" + + tag = "Slider" + + # State var to bind the the input. + value: Var[int] + + # The placeholder text. + default_value: Var[int] + + # The writing mode ("ltr" | "rtl") + direction: Var[str] + + # If false, the slider handle will not capture focus when value changes. + focus_thumb_on_change: Var[bool] + + # If true, the slider will be disabled + is_disabled: Var[bool] + + # If true, the slider will be in `read-only` state. + is_read_only: Var[bool] + + # If true, the value will be incremented or decremented in reverse. + is_reversed: Var[bool] + + # The minimum value of the slider. + min_: Var[int] + + # The maximum value of the slider. + max_: Var[int] + + # The minimum distance between slider thumbs. Useful for preventing the thumbs from being too close together. + min_steps_between_thumbs: Var[int] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return { + "on_change", + "on_change_end", + "on_change_start", + } + + @classmethod + def create(cls, *children, **props) -> Component: + """Create a slider component. + + If no children are provided, a default slider will be created. + + Args: + children: The children of the component. + props: The properties of the component. + + Returns: + The slider component. + """ + if len(children) == 0: + children = [ + SliderTrack.create( + SliderFilledTrack.create(), + ), + SliderThumb.create(), + ] + return super().create(*children, **props) + + +class SliderTrack(ChakraComponent): + """The empty part of the slider that shows the track.""" + + tag = "SliderTrack" + + +class SliderFilledTrack(ChakraComponent): + """The filled part of the slider.""" + + tag = "SliderFilledTrack" + + +class SliderThumb(ChakraComponent): + """The handle that's used to change the slider value.""" + + tag = "SliderThumb" + + +class SliderMark(ChakraComponent): + """The label or mark that shows names for specific slider values.""" + + tag = "SliderMark" diff --git a/pynecone/components/forms/switch.py b/pynecone/components/forms/switch.py new file mode 100644 index 000000000..12a4b43b4 --- /dev/null +++ b/pynecone/components/forms/switch.py @@ -0,0 +1,57 @@ +"""A switch component.""" +from typing import Set + +from pynecone.components.component import EVENT_ARG +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Switch(ChakraComponent): + """Togglable switch component.""" + + tag = "Switch" + + # If true, the switch will be checked. You'll need to pass onChange to update its value (since it is now controlled) + is_checked: Var[bool] + + # If true, the switch will be disabled + is_disabled: Var[bool] + + # If true and isDisabled is passed, the switch will remain tabbable but not interactive + is_focusable: Var[bool] + + # If true, the switch is marked as invalid. Changes style of unchecked state. + is_invalid: Var[bool] + + # If true, the switch will be readonly + is_read_only: Var[bool] + + # If true, the switch will be required + is_required: Var[bool] + + # The name of the input field in a switch (Useful for form submission). + name: Var[str] + + # The spacing between the switch and its label text (0.5rem) + spacing: Var[str] + + # The placeholder text. + placeholder: Var[str] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return {"on_change"} + + @classmethod + def get_controlled_value(cls) -> Var: + """Get the var that is passed to the event handler for controlled triggers. + + Returns: + The controlled value. + """ + return EVENT_ARG.target.checked diff --git a/pynecone/components/forms/textarea.py b/pynecone/components/forms/textarea.py new file mode 100644 index 000000000..35fa01c39 --- /dev/null +++ b/pynecone/components/forms/textarea.py @@ -0,0 +1,61 @@ +"""A textarea component.""" + +from typing import Set + +from pynecone.components.component import EVENT_ARG +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class TextArea(ChakraComponent): + """A text area component.""" + + tag = "Textarea" + + # State var to bind the the input. + value: Var[str] + + # The default value of the textarea. + default_value: Var[str] + + # The placeholder text. + placeholder: Var[str] + + # The border color when the input is invalid. + error_border_color: Var[str] + + # The border color when the input is focused. + focus_border_color: Var[str] + + # If true, the form control will be disabled. + is_disabled: Var[bool] + + # If true, the form control will be invalid. + is_invalid: Var[bool] + + # If true, the form control will be readonly. + is_read_only: Var[bool] + + # If true, the form control will be required. + is_required: Var[bool] + + # "outline" | "filled" | "flushed" | "unstyled" + variant: Var[str] + + @classmethod + def get_controlled_triggers(cls) -> Set[str]: + """Get the event triggers that pass the component's value to the handler. + + Returns: + The controlled event triggers. + """ + return {"on_change", "on_focus", "on_blur"} + + @classmethod + def get_controlled_value(cls) -> Var: + """Get the var that is passed to the event handler for controlled triggers. + + Returns: + The controlled value. + """ + return EVENT_ARG.target.value diff --git a/pynecone/components/graphing/__init__.py b/pynecone/components/graphing/__init__.py new file mode 100644 index 000000000..70727c408 --- /dev/null +++ b/pynecone/components/graphing/__init__.py @@ -0,0 +1,5 @@ +"""Convenience functions to define layout components.""" + +from .plotly import Plotly + +__all__ = [f for f in dir() if f[0].isupper()] # type: ignore diff --git a/pynecone/components/graphing/plotly.py b/pynecone/components/graphing/plotly.py new file mode 100644 index 000000000..7251e3a37 --- /dev/null +++ b/pynecone/components/graphing/plotly.py @@ -0,0 +1,53 @@ +"""Component for displaying a plotly graph.""" + +from typing import Dict, Union + +from plotly.graph_objects import Figure +from plotly.io import to_json + +from pynecone.components.component import Component +from pynecone.components.tags import Tag +from pynecone.var import Var + + +class PlotlyLib(Component): + """A component that wraps a plotly lib.""" + + library = "react-plotly.js" + + +class Plotly(PlotlyLib): + """Display a plotly graph.""" + + tag = "Plot" + + # The figure to display. This can be a plotly figure or a plotly data json. + data: Var[Figure] + + # The layout of the graph. + layout: Var[Dict] + + # The width of the graph. + width: Var[str] + + # The height of the graph. + height: Var[str] + + def _get_imports(self): + return {} + + def _get_custom_code(self) -> str: + return """ +import dynamic from 'next/dynamic' +const Plot = dynamic(() => import('react-plotly.js'), { ssr: false }); +""" + + def _render(self) -> Tag: + if isinstance(self.data, Figure): + if self.layout is None: + if self.width is not None: + layout = Var.create({"width": self.width, "height": self.height}) + assert layout is not None + self.layout = layout + + return super()._render() diff --git a/pynecone/components/layout/__init__.py b/pynecone/components/layout/__init__.py new file mode 100644 index 000000000..9443e0c20 --- /dev/null +++ b/pynecone/components/layout/__init__.py @@ -0,0 +1,14 @@ +"""Convenience functions to define layout components.""" + +from .box import Box +from .center import Center, Circle, Square +from .cond import Cond +from .container import Container +from .flex import Flex +from .foreach import Foreach +from .grid import Grid, GridItem, ResponsiveGrid +from .spacer import Spacer +from .stack import Hstack, Stack, Vstack +from .wrap import Wrap, WrapItem + +__all__ = [f for f in dir() if f[0].isupper()] # type: ignore diff --git a/pynecone/components/layout/box.py b/pynecone/components/layout/box.py new file mode 100644 index 000000000..00317c362 --- /dev/null +++ b/pynecone/components/layout/box.py @@ -0,0 +1,25 @@ +"""A box component that can contain other components.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.components.tags import Tag +from pynecone.var import Var + + +class Box(ChakraComponent): + """Renders a box component that can contain other components.""" + + tag = "Box" + + # The element to render. + element: Var[str] + + def _render(self) -> Tag: + return ( + super() + ._render() + .add_attrs( + **{ + "as": self.element, + } + ) + ) diff --git a/pynecone/components/layout/center.py b/pynecone/components/layout/center.py new file mode 100644 index 000000000..671d31008 --- /dev/null +++ b/pynecone/components/layout/center.py @@ -0,0 +1,21 @@ +"""A box that centers its contents.""" + +from pynecone.components.libs.chakra import ChakraComponent + + +class Center(ChakraComponent): + """Center is a layout component that centers its child within itself. It's useful for centering text, images, and other elements. All box can be used on center to style.""" + + tag = "Center" + + +class Square(ChakraComponent): + """Square centers its child given size (width and height). All box props can be used on Square.""" + + tag = "Square" + + +class Circle(ChakraComponent): + """Circle a Square with round border-radius. All box props can be used on Circle.""" + + tag = "Circle" diff --git a/pynecone/components/layout/cond.py b/pynecone/components/layout/cond.py new file mode 100644 index 000000000..eeb9443db --- /dev/null +++ b/pynecone/components/layout/cond.py @@ -0,0 +1,75 @@ +"""Create a list of components from an iterable.""" +from __future__ import annotations + +from typing import Optional + +import pydantic + +from pynecone.components.component import Component +from pynecone.components.layout.box import Box +from pynecone.components.tags import CondTag, Tag +from pynecone.var import Var + + +class Cond(Component): + """Display a conditional render.""" + + # The cond to determine which component to render. + cond: Var[bool] + + # The component to render if the cond is true. + comp1: Component + + # The component to render if the cond is false. + comp2: Component + + # Whether the cond is within another cond. + is_nested: bool = False + + @pydantic.validator("cond") + def validate_cond(cls, cond: Var) -> Var: + """Validate that the cond is a boolean. + + Args: + cond: The cond to validate. + + Returns: + The validated cond. + """ + assert issubclass(cond.type_, bool), "The var must be a boolean." + return cond + + @classmethod + def create( + cls, cond: Var, comp1: Component, comp2: Optional[Component] = None + ) -> Cond: + """Create a conditional component. + + Args: + cond: The cond to determine which component to render. + comp1: The component to render if the cond is true. + comp2: The component to render if the cond is false. + + Returns: + The conditional component. + """ + if comp2 is None: + comp2 = Box.create() + if isinstance(comp1, Cond): + comp1.is_nested = True + if isinstance(comp2, Cond): + comp2.is_nested = True + return cls( + cond=cond, + comp1=comp1, + comp2=comp2, + children=[comp1, comp2], + ) # type: ignore + + def _render(self) -> Tag: + return CondTag( + cond=self.cond, + true_value=self.comp1.render(), + false_value=self.comp2.render(), + is_nested=self.is_nested, + ) diff --git a/pynecone/components/layout/container.py b/pynecone/components/layout/container.py new file mode 100644 index 000000000..00af8f081 --- /dev/null +++ b/pynecone/components/layout/container.py @@ -0,0 +1,13 @@ +"""A flexbox container.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Container(ChakraComponent): + """Container composes Box so you can pass all Box related props in addition to this.""" + + tag = "Container" + + # If true, container will center its children regardless of their width. + center_content: Var[bool] diff --git a/pynecone/components/layout/flex.py b/pynecone/components/layout/flex.py new file mode 100644 index 000000000..503126832 --- /dev/null +++ b/pynecone/components/layout/flex.py @@ -0,0 +1,31 @@ +"""A reflexive container component.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Flex(ChakraComponent): + """Flex is Box with display, flex and comes with helpful style shorthand. It renders a div element.""" + + tag = "Flex" + + # How to align items in the flex. + align: Var[str] + + # Shorthand for flexBasis style prop + basis: Var[str] + + # Shorthand for flexDirection style prop + direction: Var[str] + + # Shorthand for flexGrow style prop + grow: Var[str] + + # The way to justify the items. + justify: Var[str] + + # Shorthand for flexWrap style prop + wrap: Var[str] + + # Shorthand for flexShrink style prop + shrink: Var[str] diff --git a/pynecone/components/layout/foreach.py b/pynecone/components/layout/foreach.py new file mode 100644 index 000000000..a89c67d93 --- /dev/null +++ b/pynecone/components/layout/foreach.py @@ -0,0 +1,62 @@ +"""Create a list of components from an iterable.""" +from __future__ import annotations + +from typing import Any, List, Protocol, runtime_checkable + +from pynecone.components.component import Component +from pynecone.components.tags import IterTag, Tag +from pynecone.var import BaseVar, Var + + +@runtime_checkable +class RenderFn(Protocol): + """A function that renders a component.""" + + def __call__(self, *args, **kwargs) -> Component: + """Render a component. + + Args: + *args: The positional arguments. + **kwargs: The keyword arguments. + + Returns: # noqa: DAR202 + The rendered component. + """ + ... + + +class Foreach(Component): + """Display a foreach.""" + + # The iterable to create components from. + iterable: Var[List] + + # A function from the render args to the component. + render_fn: RenderFn + + @classmethod + def create(cls, iterable: Var[List], render_fn: RenderFn, **props) -> Foreach: + """Create a foreach component. + + Args: + iterable: The iterable to create components from. + render_fn: A function from the render args to the component. + **props: The attributes to pass to each child component. + + Returns: + The foreach component. + """ + try: + type_ = iterable.type_.__args__[0] + except: + type_ = Any + arg = BaseVar(name="_", type_=type_, is_local=True) + return cls( + iterable=iterable, + render_fn=render_fn, + children=[IterTag.render_component(render_fn, arg=arg)], + **props, + ) + + def _render(self) -> Tag: + return IterTag(iterable=self.iterable, render_fn=self.render_fn) diff --git a/pynecone/components/layout/grid.py b/pynecone/components/layout/grid.py new file mode 100644 index 000000000..9f84a17c3 --- /dev/null +++ b/pynecone/components/layout/grid.py @@ -0,0 +1,105 @@ +"""Container to stack elements with spacing.""" + +from typing import List + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Grid(ChakraComponent): + """The main wrapper of th egrid component.""" + + tag = "Grid" + + # Shorthand prop for gridAutoColumns + auto_columns: Var[str] + + # Shorthand prop for gridAutoFlow + auto_flow: Var[str] + + # Shorthand prop for gridAutoRows + auto_rows: Var[str] + + # Shorthand prop for gridColumn + column: Var[str] + + # Shorthand prop for gridRow + row: Var[str] + + # Shorthand prop for gridTemplateColumns + template_columns: Var[str] + + # Shorthand prop for gridTemplateRows + template_rows: Var[str] + + +class GridItem(ChakraComponent): + """Used as a child of Grid to control the span, and start positions within the grid.""" + + tag = "GridItem" + + # Shorthand prop for gridArea + area: Var[str] + + # Shorthand prop for gridColumnEnd + col_end: Var[str] + + # The column number the grid item should start. + col_start: Var[int] + + # The number of columns the grid item should span. + col_span: Var[int] + + # Shorthand prop for gridRowEnd + row_end: Var[str] + + # The row number the grid item should start. + row_start: Var[int] + + # The number of rows the grid item should span. + row_span: Var[int] + + +class ResponsiveGrid(ChakraComponent): + """A responsive grid component.""" + + tag = "SimpleGrid" + + # Shorthand prop for gridAutoColumns + auto_columns: Var[str] + + # Shorthand prop for gridAutoFlow + auto_flow: Var[str] + + # Shorthand prop for gridAutoRows + auto_rows: Var[str] + + # Shorthand prop for gridColumn + column: Var[str] + + # Shorthand prop for gridRow + row: Var[str] + + # Alist that defines the number of columns for each breakpoint. + columns: Var[List[int]] + + # The width at which child elements will break into columns. Pass a number for pixel values or a string for any other valid CSS length. + min_child_width: Var[str] + + # The gap between the grid items + spacing: Var[str] + + # The column gap between the grid items + spacing_x: Var[str] + + # The row gap between the grid items + spacing_y: Var[str] + + # Shorthand prop for gridTemplateAreas + template_areas: Var[str] + + # Shorthand prop for gridTemplateColumns + template_columns: Var[str] + + # Shorthand prop for gridTemplateRows + template_rows: Var[str] diff --git a/pynecone/components/layout/spacer.py b/pynecone/components/layout/spacer.py new file mode 100644 index 000000000..50c6b71bf --- /dev/null +++ b/pynecone/components/layout/spacer.py @@ -0,0 +1,9 @@ +"""A flexible space component.""" + +from pynecone.components.libs.chakra import ChakraComponent + + +class Spacer(ChakraComponent): + """Display a flexible space.""" + + tag = "Spacer" diff --git a/pynecone/components/layout/stack.py b/pynecone/components/layout/stack.py new file mode 100644 index 000000000..70654e229 --- /dev/null +++ b/pynecone/components/layout/stack.py @@ -0,0 +1,46 @@ +"""Container to stack elements with spacing.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Stack(ChakraComponent): + """Display a square box.""" + + tag = "Stack" + + # Shorthand for alignItems style prop + align_items: Var[str] + + # The direction to stack the items. + direction: Var[str] + + # If true the items will be stacked horizontally. + is_inline: Var[bool] + + # Shorthand for justifyContent style prop + justify_content: Var[str] + + # If true, the children will be wrapped in a Box, and the Box will take the spacing props + should_wrap_children: Var[bool] + + # The space between each stack item + spacing: Var[str] + + # Shorthand for flexWrap style prop + wrap: Var[str] + + # Alignment of contents. + justify: Var[str] + + +class Hstack(Stack): + """The HStack component is a component which is only facing the horizontal direction. Additionally you can add a divider and horizontal spacing between the items.""" + + tag = "HStack" + + +class Vstack(Stack): + """The VStack component is a component which is only facing the vertical direction. Additionally you can add a divider and vertical spacing between the items.""" + + tag = "VStack" diff --git a/pynecone/components/layout/wrap.py b/pynecone/components/layout/wrap.py new file mode 100644 index 000000000..53dd5e96f --- /dev/null +++ b/pynecone/components/layout/wrap.py @@ -0,0 +1,15 @@ +"""Container to stack elements with spacing.""" + +from pynecone.components.libs.chakra import ChakraComponent + + +class Wrap(ChakraComponent): + """Layout component used to add space between elements and wraps automatically if there isn't enough space.""" + + tag = "Wrap" + + +class WrapItem(ChakraComponent): + """Item of the Wrap component.""" + + tag = "WrapItem" diff --git a/pynecone/components/libs/__init__.py b/pynecone/components/libs/__init__.py new file mode 100644 index 000000000..8b1550d77 --- /dev/null +++ b/pynecone/components/libs/__init__.py @@ -0,0 +1 @@ +"""React component libraries.""" diff --git a/pynecone/components/libs/chakra.py b/pynecone/components/libs/chakra.py new file mode 100644 index 000000000..6ba9cb18a --- /dev/null +++ b/pynecone/components/libs/chakra.py @@ -0,0 +1,9 @@ +"""Components that are based on Chakra-UI.""" + +from pynecone.components.component import Component + + +class ChakraComponent(Component): + """A component that wraps a Chakra component.""" + + library = "@chakra-ui/react" diff --git a/pynecone/components/media/__init__.py b/pynecone/components/media/__init__.py new file mode 100644 index 000000000..8346a37a6 --- /dev/null +++ b/pynecone/components/media/__init__.py @@ -0,0 +1,7 @@ +"""Media components.""" + +from .avatar import Avatar, AvatarBadge, AvatarGroup +from .icon import Icon +from .image import Image + +__all__ = [f for f in dir() if f[0].isupper()] # type: ignore diff --git a/pynecone/components/media/avatar.py b/pynecone/components/media/avatar.py new file mode 100644 index 000000000..725ba85fb --- /dev/null +++ b/pynecone/components/media/avatar.py @@ -0,0 +1,69 @@ +"""Avatar components.""" + +from typing import Set + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Avatar(ChakraComponent): + """The image that represents the user.""" + + tag = "Avatar" + + # Function to get the initials to display. + get_initials: Var[str] + + # The default avatar used as fallback when name, and src is not specified. + icon: Var[str] + + # The label of the icon. + icon_label: Var[str] + + # If true, opt out of the avatar's fallback logic and renders the img at all times. + ignore_fallback: Var[bool] + + # Defines loading strategy ("eager" | "lazy"). + loading: Var[str] + + # The name of the person in the avatar. + name: Var[str] + + # If true, the Avatar will show a border around it. Best for a group of avatars. + show_border: Var[bool] + + # The image url of the Avatar. + src: Var[str] + + # List of sources to use for different screen resolutions. + src_set: Var[str] + + # "2xs" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "full" + size: Var[str] + + @classmethod + def get_triggers(cls) -> Set[str]: + """Get the event triggers for the component. + + Returns: + The event triggers. + """ + return super().get_triggers() | {"on_error"} + + +class AvatarBadge(ChakraComponent): + """A wrapper that displays its content on the right corner of the avatar.""" + + tag = "AvatarBadge" + + +class AvatarGroup(ChakraComponent): + """A wrapper to stack multiple Avatars together.""" + + tag = "AvatarGroup" + + # The maximum number of visible avatars. + max_: Var[int] + + # The space between the avatars in the group. + spacing: Var[int] diff --git a/pynecone/components/media/icon.py b/pynecone/components/media/icon.py new file mode 100644 index 000000000..fd7c1a18a --- /dev/null +++ b/pynecone/components/media/icon.py @@ -0,0 +1,15 @@ +"""An image component.""" + +from pynecone.components.component import Component + + +class ChakraIconComponent(Component): + """A component that wraps a chakra icon component.""" + + library = "@chakra-ui/icons" + + +class Icon(ChakraIconComponent): + """The Avatar component is used to represent a user, and displays the profile picture, initials or fallback icon.""" + + tag = "None" diff --git a/pynecone/components/media/image.py b/pynecone/components/media/image.py new file mode 100644 index 000000000..83ab1c912 --- /dev/null +++ b/pynecone/components/media/image.py @@ -0,0 +1,53 @@ +"""An image component.""" +from __future__ import annotations + +from typing import Optional, Set + +from pynecone.components.component import Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Image(ChakraComponent): + """The Image component is used to display images. Image composes Box so you can use all the style props and add responsive styles as well.""" + + tag = "Image" + + # How to align the image within its bounds. It maps to css `object-position` property. + align: Var[str] + + # Fallback Pynecone component to show if image is loading or image fails. + fallback: Optional[Component] = None + + # Fallback image src to show if image is loading or image fails. + fallback_src: Var[str] + + # How the image to fit within its bounds. It maps to css `object-fit` property. + fit: Var[str] + + # The native HTML height attribute to the passed to the img. + html_height: Var[str] + + # The native HTML width attribute to the passed to the img. + html_width: Var[str] + + # If true, opt out of the fallbackSrc logic and use as img. + ignore_fallback: Var[bool] + + # "eager" | "lazy" + loading: Var[str] + + # The image src attribute. + src: Var[str] + + # The image srcset attribute. + src_set: Var[str] + + @classmethod + def get_triggers(cls) -> Set[str]: + """Get the event triggers for the component. + + Returns: + The event triggers. + """ + return super().get_triggers() | {"on_error", "on_load"} diff --git a/pynecone/components/navigation/__init__.py b/pynecone/components/navigation/__init__.py new file mode 100644 index 000000000..9e6c8ad37 --- /dev/null +++ b/pynecone/components/navigation/__init__.py @@ -0,0 +1,8 @@ +"""Navigation components.""" + +from .breadcrumb import Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator +from .link import Link +from .linkoverlay import LinkBox, LinkOverlay +from .nextlink import NextLink + +__all__ = [f for f in dir() if f[0].isupper()] # type: ignore diff --git a/pynecone/components/navigation/breadcrumb.py b/pynecone/components/navigation/breadcrumb.py new file mode 100644 index 000000000..f0df3ed84 --- /dev/null +++ b/pynecone/components/navigation/breadcrumb.py @@ -0,0 +1,52 @@ +"""Breadcrumb components.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Breadcrumb(ChakraComponent): + """The parent container for breadcrumbs.""" + + tag = "Breadcrumb" + + # The visual separator between each breadcrumb item + separator: Var[str] + + # The left and right margin applied to the separator + separator_margin: Var[str] + + +class BreadcrumbItem(ChakraComponent): + """Individual breadcrumb element containing a link and a divider.""" + + tag = "BreadcrumbItem" + + # Is the current page of the breadcrumb. + is_current_page: Var[bool] + + # Is the last child of the breadcrumb. + is_last_child: Var[bool] + + # The visual separator between each breadcrumb item + separator: Var[str] + + # The left and right margin applied to the separator + spacing: Var[str] + + # The href of the item. + href: Var[str] + + +class BreadcrumbSeparator(ChakraComponent): + """The visual separator between each breadcrumb.""" + + tag = "BreadcrumbSeparator" + + +class BreadcrumbLink(ChakraComponent): + """The breadcrumb link.""" + + tag = "BreadcrumbLink" + + # Is the current page of the breadcrumb. + is_current_page: Var[bool] diff --git a/pynecone/components/navigation/link.py b/pynecone/components/navigation/link.py new file mode 100644 index 000000000..b94933c61 --- /dev/null +++ b/pynecone/components/navigation/link.py @@ -0,0 +1,42 @@ +"""A link component.""" + +from pynecone.components.component import Component +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.components.navigation.nextlink import NextLink +from pynecone.var import Var + + +class Link(ChakraComponent): + """Component the provides a link.""" + + tag = "Link" + + # The rel. + rel: Var[str] + + # The page to link to. + href: Var[str] + + # The text to display. + text: Var[str] + + # If true, the link will open in new tab. + is_external: Var[bool] + + @classmethod + def create(cls, *children, **props) -> Component: + """Create a NextJS link component, wrapping a Chakra link component. + + Args: + *children: The children to pass to the component. + **props: The attributes to pass to the component. + + Returns: + The component. + """ + kwargs = {} + if "href" in props: + kwargs["href"] = props.pop("href") + else: + kwargs["href"] = "#" + return NextLink.create(super().create(*children, **props), **kwargs) diff --git a/pynecone/components/navigation/linkoverlay.py b/pynecone/components/navigation/linkoverlay.py new file mode 100644 index 000000000..2467a656a --- /dev/null +++ b/pynecone/components/navigation/linkoverlay.py @@ -0,0 +1,22 @@ +"""Link overlay components.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class LinkOverlay(ChakraComponent): + """Wraps cild componet in a link.""" + + tag = "LinkOverlay" + + # If true, the link will open in new tab + is_external: Var[bool] + + # Href of the link overlay. + href: Var[str] + + +class LinkBox(ChakraComponent): + """The LinkBox lifts any nested links to the top using z-index to ensure proper keyboard navigation between links.""" + + tag = "LinkBox" diff --git a/pynecone/components/navigation/nextlink.py b/pynecone/components/navigation/nextlink.py new file mode 100644 index 000000000..9fc55e0e1 --- /dev/null +++ b/pynecone/components/navigation/nextlink.py @@ -0,0 +1,22 @@ +"""A link component.""" + +from pynecone.components.component import Component +from pynecone.var import Var + + +class NextLinkLib(Component): + """A component that inherits from next/link.""" + + library = "next/link" + + +class NextLink(NextLinkLib): + """Links are accessible elements used primarily for navigation. This component is styled to resemble a hyperlink and semantically renders an .""" + + tag = "NextLink" + + # The page to link to. + href: Var[str] + + # Whether to pass the href prop to the child. + pass_href: Var[bool] = True # type: ignore diff --git a/pynecone/components/overlay/__init__.py b/pynecone/components/overlay/__init__.py new file mode 100644 index 000000000..322d09318 --- /dev/null +++ b/pynecone/components/overlay/__init__.py @@ -0,0 +1,50 @@ +"""Overlay components.""" + +from .alertdialog import ( + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, +) +from .drawer import ( + Drawer, + DrawerBody, + DrawerCloseButton, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerOverlay, +) +from .menu import ( + Menu, + MenuButton, + MenuDivider, + MenuGroup, + MenuItem, + MenuItemOption, + MenuList, + MenuOptionGroup, +) +from .modal import ( + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +) +from .popover import ( + Popover, + PopoverAnchor, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverFooter, + PopoverHeader, + PopoverTrigger, +) +from .tooltip import Tooltip diff --git a/pynecone/components/overlay/alertdialog.py b/pynecone/components/overlay/alertdialog.py new file mode 100644 index 000000000..a73c78aaf --- /dev/null +++ b/pynecone/components/overlay/alertdialog.py @@ -0,0 +1,101 @@ +"""Alert dialog components.""" + +from typing import Set + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class AlertDialog(ChakraComponent): + """Provides context and state for the dialog.""" + + tag = "AlertDialog" + + # If true, the modal will be open. + is_open: Var[bool] + + # The least destructive element to focus when the dialog opens. + least_destructive_ref: Var[str] + + # Handle zoom/pinch gestures on iOS devices when scroll locking is enabled. Defaults to false. + allow_pinch_zoom: Var[bool] + + # If true, the modal will autofocus the first enabled and interactive element within the ModalContent + auto_focus: Var[bool] + + # If true, scrolling will be disabled on the body when the modal opens. + block_scroll_on_mount: Var[bool] + + # If true, the modal will close when the Esc key is pressed + close_on_esc: Var[bool] + + # If true, the modal will close when the overlay is clicked + close_on_overlay_click: Var[bool] + + # If true, the modal will be centered on screen. + is_centered: Var[bool] + + # Enables aggressive focus capturing within iframes. If true, keep focus in the lock, no matter where lock is active. If false, allows focus to move outside of iframe. + lock_focus_across_frames: Var[bool] + + # If true, a `padding-right` will be applied to the body element that's equal to the width of the scrollbar. This can help prevent some unpleasant flickering effect and content adjustment when the modal opens + preserve_scroll_bar_gap: Var[bool] + + # If true, the modal will return focus to the element that triggered it when it closes. + return_focus_on_close: Var[bool] + + # "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "full" + size: Var[str] + + # If true, the siblings of the modal will have `aria-hidden` set to true so that screen readers can only see the modal. This is commonly known as making the other elements **inert** + use_intert: Var[bool] + + @classmethod + def get_triggers(cls) -> Set[str]: + """Get the event triggers for the component. + + Returns: + The event triggers. + """ + return super().get_triggers() | { + "on_close", + "on_close_complete", + "on_esc", + "on_overlay_click", + } + + +class AlertDialogBody(ChakraComponent): + """Should contain the description announced by screen readers.""" + + tag = "AlertDialogBody" + + +class AlertDialogHeader(ChakraComponent): + """Should contain the title announced by screen readers.""" + + tag = "AlertDialogHeader" + + +class AlertDialogFooter(ChakraComponent): + """Should contain the events of the dialog.""" + + tag = "AlertDialogFooter" + + +class AlertDialogContent(ChakraComponent): + """The wrapper for the alert dialog's content.""" + + tag = "AlertDialogContent" + + +class AlertDialogOverlay(ChakraComponent): + """The dimmed overlay behind the dialog.""" + + tag = "AlertDialogOverlay" + + +class AlertDialogCloseButton(ChakraComponent): + """The button that closes the dialog.""" + + tag = "AlertDialogCloseButton" diff --git a/pynecone/components/overlay/drawer.py b/pynecone/components/overlay/drawer.py new file mode 100644 index 000000000..876aa65cc --- /dev/null +++ b/pynecone/components/overlay/drawer.py @@ -0,0 +1,107 @@ +"""Container to stack elements with spacing.""" + +from typing import Set + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Drawer(ChakraComponent): + """A drawer component.""" + + tag = "Drawer" + + # If true, the modal will be open. + is_open: Var[bool] + + # Handle zoom/pinch gestures on iOS devices when scroll locking is enabled. Defaults to false. + allow_pinch_zoom: Var[bool] + + # If true, the modal will autofocus the first enabled and interactive element within the ModalContent + auto_focus: Var[bool] + + # If true, scrolling will be disabled on the body when the modal opens. + block_scroll_on_mount: Var[bool] + + # If true, the modal will close when the Esc key is pressed + close_on_esc: Var[bool] + + # If true, the modal will close when the overlay is clicked + close_on_overlay_click: Var[bool] + + # If true, the modal will be centered on screen. + is_centered: Var[bool] + + # If true and drawer's placement is top or bottom, the drawer will occupy the viewport height (100vh) + is_full_height: Var[bool] + + # Enables aggressive focus capturing within iframes. - If true: keep focus in the lock, no matter where lock is active - If false: allows focus to move outside of iframe + lock_focus_across_frames: Var[bool] + + # The placement of the drawer + placement: Var[str] + + # If true, a `padding-right` will be applied to the body element that's equal to the width of the scrollbar. This can help prevent some unpleasant flickering effect and content adjustment when the modal opens + preserve_scroll_bar_gap: Var[bool] + + # If true, the modal will return focus to the element that triggered it when it closes. + return_focus_on_close: Var[bool] + + # "xs" | "sm" | "md" | "lg" | "xl" | "full" + size: Var[str] + + # A11y: If true, the siblings of the modal will have `aria-hidden` set to true so that screen readers can only see the modal. This is commonly known as making the other elements **inert** + use_intert: Var[bool] + + # Variant of drawer + variant: Var[str] + + @classmethod + def get_triggers(cls) -> Set[str]: + """Get the event triggers for the component. + + Returns: + The event triggers. + """ + return super().get_triggers() | { + "on_close", + "on_close_complete", + "on_esc", + "on_overlay_click", + } + + +class DrawerBody(ChakraComponent): + """Drawer body.""" + + tag = "DrawerBody" + + +class DrawerHeader(ChakraComponent): + """Drawer header.""" + + tag = "DrawerHeader" + + +class DrawerFooter(ChakraComponent): + """Drawer footer.""" + + tag = "DrawerFooter" + + +class DrawerOverlay(Drawer): + """Drawer overlay.""" + + tag = "DrawerOverlay" + + +class DrawerContent(Drawer): + """Drawer content.""" + + tag = "DrawerContent" + + +class DrawerCloseButton(Drawer): + """Drawer close button.""" + + tag = "DrawerCloseButton" diff --git a/pynecone/components/overlay/menu.py b/pynecone/components/overlay/menu.py new file mode 100644 index 000000000..b272a5a7d --- /dev/null +++ b/pynecone/components/overlay/menu.py @@ -0,0 +1,160 @@ +"""Menu components.""" + +from typing import Set + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Menu(ChakraComponent): + """The wrapper component provides context, state, and focus management.""" + + tag = "Menu" + + # The padding required to prevent the arrow from reaching the very edge of the popper. + arrow_padding: Var[int] + + # If true, the first enabled menu item will receive focus and be selected when the menu opens. + auto_select: Var[bool] + + # The boundary area for the popper. Used within the preventOverflow modifier + boundary: Var[str] + + # If true, the menu will close when you click outside the menu list + close_on_blur: Var[bool] + + # If true, the menu will close when a menu item is clicked + close_on_select: Var[bool] + + # If by default the menu is open. + default_is_open: Var[bool] + + # If rtl, poper placement positions will be flipped i.e. 'top-right' will become 'top-left' and vice-verse ("ltr" | "rtl") + direction: Var[str] + + # If true, the popper will change its placement and flip when it's about to overflow its boundary area. + flip: Var[bool] + + # The distance or margin between the reference and popper. It is used internally to create an offset modifier. NB: If you define offset prop, it'll override the gutter. + gutter: Var[int] + + # Performance 🚀: If true, the MenuItem rendering will be deferred until the menu is open. + is_lazy: Var[bool] + + # Performance 🚀: The lazy behavior of menu's content when not visible. Only works when `isLazy={true}` - "unmount": The menu's content is always unmounted when not open. - "keepMounted": The menu's content initially unmounted, but stays mounted when menu is open. + lazy_behavior: Var[str] + + # Determines if the menu is open or not. + is_open: Var[bool] + + # If true, the popper will match the width of the reference at all times. It's useful for autocomplete, `date-picker` and select patterns. + match_width: Var[bool] + + # The placement of the popper relative to its reference. + placement: Var[str] + + # If true, will prevent the popper from being cut off and ensure it's visible within the boundary area. + prevent_overflow: Var[bool] + + # The CSS positioning strategy to use. ("fixed" | "absolute") + strategy: Var[str] + + @classmethod + def get_triggers(cls) -> Set[str]: + """Get the event triggers for the component. + + Returns: + The event triggers. + """ + return super().get_triggers() | {"on_close", "on_open"} + + +class MenuButton(ChakraComponent): + """The trigger for the menu list. Must be a direct child of Menu.""" + + tag = "MenuButton" + + variant: Var[str] + + as_: Var[str] + + +class MenuList(ChakraComponent): + """The wrapper for the menu items. Must be a direct child of Menu.""" + + tag = "MenuList" + + +class MenuItem(Menu): + """The trigger that handles menu selection. Must be a direct child of a MenuList.""" + + tag = "MenuItem" + + # Overrides the parent menu's closeOnSelect prop. + close_on_select: Var[bool] + + # Right-aligned label text content, useful for displaying hotkeys. + command: Var[str] + + # The spacing between the command and menu item's label. + command_spacing: Var[int] + + # If true, the menuitem will be disabled. + is_disabled: Var[bool] + + # If true and the menuitem is disabled, it'll remain keyboard-focusable + is_focusable: Var[bool] + + +class MenuItemOption(Menu): + """The checkable menu item, to be used with MenuOptionGroup.""" + + tag = "MenuItemOption" + + # Overrides the parent menu's closeOnSelect prop. + close_on_select: Var[bool] + + # Right-aligned label text content, useful for displaying hotkeys. + command: Var[str] + + # The spacing between the command and menu item's label. + command_spacing: Var[int] + + # Determines if menu item is checked. + is_checked: Var[bool] + + # If true, the menuitem will be disabled. + is_disabled: Var[bool] + + # If true and the menuitem is disabled, it'll remain keyboard-focusable + is_focusable: Var[bool] + + # "checkbox" | "radio" + type_: Var[str] + + # Value of the menu item. + value: Var[str] + + +class MenuGroup(Menu): + """A wrapper to group related menu items.""" + + tag = "MenuGroup" + + +class MenuOptionGroup(Menu): + """A wrapper for checkable menu items (radio and checkbox).""" + + tag = "MenuOptionGroup" + + # "checkbox" | "radio" + type_: Var[str] + + # Value of the option group. + value: Var[str] + + +class MenuDivider(Menu): + """A visual separator for menu items and groups.""" + + tag = "MenuDivider" diff --git a/pynecone/components/overlay/modal.py b/pynecone/components/overlay/modal.py new file mode 100644 index 000000000..b80d04b60 --- /dev/null +++ b/pynecone/components/overlay/modal.py @@ -0,0 +1,101 @@ +"""Modal components.""" + +from typing import Set + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Modal(ChakraComponent): + """The wrapper that provides context for its children.""" + + tag = "Modal" + + # If true, the modal will be open. + is_open: Var[bool] + + # Handle zoom/pinch gestures on iOS devices when scroll locking is enabled. Defaults to false. + allow_pinch_zoom: Var[bool] + + # If true, the modal will autofocus the first enabled and interactive element within the ModalContent + auto_focus: Var[bool] + + # If true, scrolling will be disabled on the body when the modal opens. + block_scroll_on_mount: Var[bool] + + # If true, the modal will close when the Esc key is pressed + close_on_esc: Var[bool] + + # If true, the modal will close when the overlay is clicked + close_on_overlay_click: Var[bool] + + # If true, the modal will be centered on screen. + is_centered: Var[bool] + + # Enables aggressive focus capturing within iframes. - If true: keep focus in the lock, no matter where lock is active - If false: allows focus to move outside of iframe + lock_focus_across_frames: Var[bool] + + # The transition that should be used for the modal + motion_preset: Var[str] + + # If true, a `padding-right` will be applied to the body element that's equal to the width of the scrollbar. This can help prevent some unpleasant flickering effect and content adjustment when the modal opens + preserve_scroll_bar_gap: Var[bool] + + # If true, the modal will return focus to the element that triggered it when it closes. + return_focus_on_close: Var[bool] + + # "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "full" + size: Var[str] + + # A11y: If true, the siblings of the modal will have `aria-hidden` set to true so that screen readers can only see the modal. This is commonly known as making the other elements **inert** + use_intert: Var[bool] + + @classmethod + def get_triggers(cls) -> Set[str]: + """Get the event triggers for the component. + + Returns: + The event triggers. + """ + return super().get_triggers() | { + "on_close", + "on_close_complete", + "on_esc", + "on_overlay_click", + } + + +class ModalOverlay(Modal): + """The dimmed overlay behind the modal dialog.""" + + tag = "ModalOverlay" + + +class ModalHeader(ChakraComponent): + """The header that labels the modal dialog.""" + + tag = "ModalHeader" + + +class ModalFooter(ChakraComponent): + """The footer that houses the modal events.""" + + tag = "ModalFooter" + + +class ModalContent(Modal): + """The container for the modal dialog's content.""" + + tag = "ModalContent" + + +class ModalBody(ChakraComponent): + """The wrapper that houses the modal's main content.""" + + tag = "ModalBody" + + +class ModalCloseButton(Modal): + """The button that closes the modal.""" + + tag = "ModalCloseButton" diff --git a/pynecone/components/overlay/popover.py b/pynecone/components/overlay/popover.py new file mode 100644 index 000000000..f6b4f5554 --- /dev/null +++ b/pynecone/components/overlay/popover.py @@ -0,0 +1,132 @@ +"""Popover components.""" + +from typing import Set + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Popover(ChakraComponent): + """The wrapper that provides props, state, and context to its children.""" + + tag = "Popover" + + # The padding required to prevent the arrow from reaching the very edge of the popper. + arrow_padding: Var[int] + + # The `box-shadow` of the popover arrow + arrow_shadow_color: Var[str] + + # The size of the popover arrow + arrow_size: Var[int] + + # If true, focus will be transferred to the first interactive element when the popover opens + auto_focus: Var[bool] + + # The boundary area for the popper. Used within the preventOverflow modifier + boundary: Var[str] + + # If true, the popover will close when you blur out it by clicking outside or tabbing out + close_on_blur: Var[bool] + + # If true, the popover will close when you hit the Esc key + close_on_esc: Var[bool] + + # If true, the popover will be initially opened. + default_is_open: Var[bool] + + # Theme direction ltr or rtl. Popper's placement will be set accordingly + direction: Var[str] + + # If true, the popper will change its placement and flip when it's about to overflow its boundary area. + flip: Var[bool] + + # The distance or margin between the reference and popper. It is used internally to create an offset modifier. NB: If you define offset prop, it'll override the gutter. + gutter: Var[int] + + # The html id attribute of the popover. If not provided, we generate a unique id. This id is also used to auto-generate the `aria-labelledby` and `aria-describedby` attributes that points to the PopoverHeader and PopoverBody + id_: Var[str] + + # Performance 🚀: If true, the PopoverContent rendering will be deferred until the popover is open. + is_lazy: Var[bool] + + # Performance 🚀: The lazy behavior of popover's content when not visible. Only works when `isLazy={true}` - "unmount": The popover's content is always unmounted when not open. - "keepMounted": The popover's content initially unmounted, but stays mounted when popover is open. + lazy_behavior: Var[str] + + # If true, the popover will be opened in controlled mode. + is_open: Var[bool] + + # If true, the popper will match the width of the reference at all times. It's useful for autocomplete, `date-picker` and select patterns. + match_width: Var[bool] + + # The placement of the popover. It's used internally by Popper.js. + placement: Var[str] + + # If true, will prevent the popper from being cut off and ensure it's visible within the boundary area. + prevent_overflow: Var[bool] + + # If true, focus will be returned to the element that triggers the popover when it closes + return_focus_on_close: Var[bool] + + # The CSS positioning strategy to use. ("fixed" | "absolute") + strategy: Var[str] + + # The interaction that triggers the popover. hover - means the popover will open when you hover with mouse or focus with keyboard on the popover trigger click - means the popover will open on click or press Enter to Space on keyboard ("click" | "hover") + trigger: Var[str] + + @classmethod + def get_triggers(cls) -> Set[str]: + """Get the event triggers for the component. + + Returns: + The event triggers. + """ + return super().get_triggers() | {"on_close", "on_open"} + + +class PopoverContent(Popover): + """The popover itself.""" + + tag = "PopoverContent" + + +class PopoverHeader(ChakraComponent): + """The header of the popover.""" + + tag = "PopoverHeader" + + +class PopoverFooter(ChakraComponent): + """Display a popover footer.""" + + tag = "PopoverFooter" + + +class PopoverBody(ChakraComponent): + """The body of the popover.""" + + tag = "PopoverBody" + + +class PopoverArrow(Popover): + """A visual arrow that points to the reference (or trigger).""" + + tag = "PopoverArrow" + + +class PopoverCloseButton(Popover): + """A button to close the popover.""" + + tag = "PopoverCloseButton" + + +class PopoverAnchor(Popover): + """Used to wrap the position-reference element.""" + + tag = "PopoverAnchor" + + +class PopoverTrigger(Popover): + """Used to wrap the reference (or trigger) element.""" + + tag = "PopoverTrigger" diff --git a/pynecone/components/overlay/tooltip.py b/pynecone/components/overlay/tooltip.py new file mode 100644 index 000000000..32729b58a --- /dev/null +++ b/pynecone/components/overlay/tooltip.py @@ -0,0 +1,72 @@ +"""Tooltip components.""" + +from typing import Set + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Tooltip(ChakraComponent): + """A tooltip message to appear.""" + + tag = "Tooltip" + + # The padding required to prevent the arrow from reaching the very edge of the popper. + arrow_padding: Var[int] + + # The color of the arrow shadow. + arrow_shadow_color: Var[str] + + # Size of the arrow. + arrow_size: Var[int] + + # Delay (in ms) before hiding the tooltip + delay: Var[int] + + # If true, the tooltip will hide on click + close_on_click: Var[bool] + + # If true, the tooltip will hide on pressing Esc key + close_on_esc: Var[bool] + + # If true, the tooltip will hide while the mouse is down + close_on_mouse_down: Var[bool] + + # If true, the tooltip will be initially shown + default_is_open: Var[bool] + + # Theme direction ltr or rtl. Popper's placement will be set accordingly + direction: Var[str] + + # The distance or margin between the reference and popper. It is used internally to create an offset modifier. NB: If you define offset prop, it'll override the gutter. + gutter: Var[int] + + # If true, the tooltip will show an arrow tip + has_arrow: Var[bool] + + # If true, th etooltip with be disabled. + is_disabled: Var[bool] + + # If true, the tooltip will be open. + is_open: Var[bool] + + # The label of the tooltip + label: Var[str] + + # Delay (in ms) before showing the tooltip + open_delay: Var[int] + + # The placement of the popper relative to its reference. + placement: Var[str] + + # If true, the tooltip will wrap its children in a `` with `tabIndex=0` + should_wrap_children: Var[bool] + + @classmethod + def get_triggers(cls) -> Set[str]: + """Get the event triggers for the component. + + Returns: + The event triggers. + """ + return super().get_triggers() | {"on_close", "on_open"} diff --git a/pynecone/components/tags/__init__.py b/pynecone/components/tags/__init__.py new file mode 100644 index 000000000..b7eb8c4b5 --- /dev/null +++ b/pynecone/components/tags/__init__.py @@ -0,0 +1,5 @@ +"""Representations for React tags.""" + +from .cond_tag import CondTag +from .iter_tag import IterTag +from .tag import Tag diff --git a/pynecone/components/tags/cond_tag.py b/pynecone/components/tags/cond_tag.py new file mode 100644 index 000000000..5aa5e5117 --- /dev/null +++ b/pynecone/components/tags/cond_tag.py @@ -0,0 +1,35 @@ +"""Tag to conditionally render components.""" + +from pynecone import utils +from pynecone.components.tags.tag import Tag +from pynecone.var import Var + + +class CondTag(Tag): + """A conditional tag.""" + + # The condition to determine which component to render. + cond: Var[bool] + + # The code to render if the condition is true. + true_value: str + + # The code to render if the condition is false. + false_value: str + + # Whether the cond tag is nested. + is_nested: bool = False + + def __str__(self) -> str: + """Render the tag as a React string. + + Returns: + The React code to render the tag. + """ + assert self.cond is not None, "The condition must be set." + return utils.format_cond( + cond=self.cond.full_name, + true_value=self.true_value, + false_value=self.false_value, + is_nested=self.is_nested, + ) diff --git a/pynecone/components/tags/iter_tag.py b/pynecone/components/tags/iter_tag.py new file mode 100644 index 000000000..f4da3e23a --- /dev/null +++ b/pynecone/components/tags/iter_tag.py @@ -0,0 +1,90 @@ +"""Tag to loop through a list of components.""" +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING, Any, Callable, List + +from pynecone import utils +from pynecone.components.tags.tag import Tag +from pynecone.var import BaseVar, Var + +if TYPE_CHECKING: + from pynecone.components.component import Component + + +INDEX_VAR = "i" + + +class IterTag(Tag): + """An iterator tag.""" + + # The var to iterate over. + iterable: Var[List] + + # The component render function for each item in the iterable. + render_fn: Callable + + @staticmethod + def get_index_var() -> Var: + """Get the index var for the tag. + + Returns: + The index var. + """ + index = Var.create(INDEX_VAR, is_local=False) + assert index is not None + return index + + @staticmethod + def get_index_var_arg() -> Var: + """Get the index var for the tag. + + Returns: + The index var. + """ + arg = Var.create(INDEX_VAR) + assert arg is not None + return arg + + @staticmethod + def render_component(render_fn: Callable, arg: Var) -> Component: + """Render the component. + + Args: + render_fn: The render function. + arg: The argument to pass to the render function. + + Returns: + The rendered component. + """ + args = inspect.getfullargspec(render_fn).args + index = IterTag.get_index_var() + if len(args) == 1: + component = render_fn(arg) + else: + assert len(args) == 2 + component = render_fn(arg, index) + if component.key is None: + component.key = utils.wrap(str(index), "{", check_first=False) + return component + + def __str__(self) -> str: + """Render the tag as a React string. + + Returns: + The React code to render the tag. + """ + try: + type_ = self.iterable.type_.__args__[0] + except: + type_ = Any + arg = BaseVar( + name=utils.get_unique_variable_name(), + type_=type_, + ) + index_arg = self.get_index_var_arg() + component = self.render_component(self.render_fn, arg) + return utils.wrap( + f"{self.iterable.full_name}.map(({arg.name}, {index_arg}) => {component})", + "{", + ) diff --git a/pynecone/components/tags/tag.py b/pynecone/components/tags/tag.py new file mode 100644 index 000000000..77a7ff0be --- /dev/null +++ b/pynecone/components/tags/tag.py @@ -0,0 +1,196 @@ +"""A React tag.""" + +from __future__ import annotations + +import json +import os +import re +from typing import Any, Dict, Optional, Union + +import pydantic +from plotly.graph_objects import Figure +from plotly.io import to_json + +from pynecone import utils +from pynecone.base import Base +from pynecone.event import EventChain +from pynecone.var import Var + + +class Tag(Base): + """A React tag.""" + + # The name of the tag. + name: str = "" + + # The attributes of the tag. + attrs: Dict[str, Any] = {} + + # The inner contents of the tag. + contents: str = "" + + def __init__(self, *args, **kwargs): + """Initialize the tag. + + Args: + *args: Args to initialize the tag. + **kwargs: Kwargs to initialize the tag. + """ + # Convert any attrs to properties. + if "attrs" in kwargs: + kwargs["attrs"] = { + name: Var.create(value) for name, value in kwargs["attrs"].items() + } + super().__init__(*args, **kwargs) + + @staticmethod + def format_attr_value( + value: Union[Var, EventChain, Dict[str, Var], str], + ) -> Union[int, float, str]: + """Format an attribute value. + + Args: + value: The value of the attribute + + Returns: + The formatted value to display within the tag. + """ + + def format_fn(value): + args = ",".join([":".join((name, val)) for name, val in value.args]) + return f"E(\"{utils.to_snake_case(value.handler.fn.__qualname__)}\", {utils.wrap(args, '{')})" + + # Handle var attributes. + if isinstance(value, Var): + if not value.is_local or value.is_string: + return str(value) + if issubclass(value.type_, str): + value = json.dumps(value.full_name) + value = re.sub('"{', "", value) + value = re.sub('}"', "", value) + value = re.sub('"`', '{"', value) + value = re.sub('`"', '"}', value) + return value + value = value.full_name + + # Handle events. + elif isinstance(value, EventChain): + local_args = ",".join(value.events[0].local_args) + fns = ",".join([format_fn(event) for event in value.events]) + value = f"({local_args}) => Event([{fns}])" + + # Handle other types. + elif isinstance(value, str): + if utils.is_wrapped(value, "{"): + return value + return json.dumps(value) + + elif isinstance(value, Figure): + value = json.loads(to_json(value))["data"] + + # For dictionaries, convert any properties to strings. + else: + if isinstance(value, dict): + value = json.dumps( + { + key: str(val) if isinstance(val, Var) else val + for key, val in value.items() + } + ) + else: + value = json.dumps(value) + + value = re.sub('"{', "", value) + value = re.sub('}"', "", value) + value = re.sub('"`', '{"', value) + value = re.sub('`"', '"}', value) + + # Wrap the variable in braces. + assert isinstance(value, str), "The value must be a string." + return utils.wrap(value, "{", check_first=False) + + def format_attrs(self) -> str: + """Format a dictionary of attributes. + + Returns: + The formatted attributes. + """ + # If there are no attributes, return an empty string. + if len(self.attrs) == 0: + return "" + + # Get the string representation of all the attributes joined. + # We need a space at the beginning for formatting. + return os.linesep.join( + f"{name}={self.format_attr_value(value)}" + for name, value in self.attrs.items() + if value is not None + ) + + def __str__(self) -> str: + """Render the tag as a React string. + + Returns: + The React code to render the tag. + """ + # Get the tag attributes. + attrs_str = self.format_attrs() + if len(attrs_str) > 0: + attrs_str = " " + attrs_str + + if len(self.contents) == 0: + # If there is no inner content, we don't need a closing tag. + tag_str = utils.wrap(f"{self.name}{attrs_str}/", "<") + else: + # Otherwise wrap it in opening and closing tags. + open = utils.wrap(f"{self.name}{attrs_str}", "<") + close = utils.wrap(f"/{self.name}", "<") + tag_str = utils.wrap(self.contents, open, close) + + return tag_str + + def add_attrs(self, **kwargs: Optional[Any]) -> Tag: + """Add attributes to the tag. + + Args: + **kwargs: The attributes to add. + + Returns: + The tag with the attributes added. + """ + self.attrs.update( + { + utils.to_camel_case(name): attr + if utils._isinstance(attr, Union[EventChain, dict]) + else Var.create(attr) + for name, attr in kwargs.items() + if self.is_valid_attr(attr) + } + ) + return self + + def remove_attrs(self, *args: str) -> Tag: + """Remove attributes from the tag. + + Args: + *args: The attributes to remove. + + Returns: + The tag with the attributes removed. + """ + for name in args: + if name in self.attrs: + del self.attrs[name] + return self + + @staticmethod + def is_valid_attr(attr: Optional[Var]) -> bool: + """Check if the attr is valid. + + Args: + attr: The value to check. + + Returns: + Whether the value is valid. + """ + return attr is not None and not (isinstance(attr, dict) and len(attr) == 0) diff --git a/pynecone/components/tags/tagless.py b/pynecone/components/tags/tagless.py new file mode 100644 index 000000000..daf96da61 --- /dev/null +++ b/pynecone/components/tags/tagless.py @@ -0,0 +1,22 @@ +"""A tag with no tag.""" + +from pynecone import utils +from pynecone.components.tags import Tag + + +class Tagless(Tag): + """A tag with no tag.""" + + def __str__(self) -> str: + """Return the string representation of the tag. + + Returns: + The string representation of the tag. + """ + out = self.contents + space = utils.wrap(" ", "{") + if len(self.contents) > 0 and self.contents[0] == " ": + out = space + out + if len(self.contents) > 0 and self.contents[-1] == " ": + out = out + space + return out diff --git a/pynecone/components/transitions/__init__.py b/pynecone/components/transitions/__init__.py new file mode 100644 index 000000000..ff585c081 --- /dev/null +++ b/pynecone/components/transitions/__init__.py @@ -0,0 +1 @@ +"""Overlay components.""" diff --git a/pynecone/components/transitions/observer.py b/pynecone/components/transitions/observer.py new file mode 100644 index 000000000..d99abe48a --- /dev/null +++ b/pynecone/components/transitions/observer.py @@ -0,0 +1,15 @@ +"""Container to observer element.""" + +from pynecone.components.component import Component + + +class Observer(Component): + """A component that wraps a react-visibility-sensor component.""" + + library = "react-visibility-sensor" + + +class VisibilitySensor(Observer): + """Display a square box.""" + + tag = "VisibilitySensor" diff --git a/pynecone/components/transitions/transitions.py b/pynecone/components/transitions/transitions.py new file mode 100644 index 000000000..9cf5d3658 --- /dev/null +++ b/pynecone/components/transitions/transitions.py @@ -0,0 +1,29 @@ +"""Container to stack elements with spacing.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.components.tags import Tag +from pynecone.var import Var + + +class Fade(ChakraComponent): + """Display a square box.""" + + tag = "Fade" + + +class ScaleFade(ChakraComponent): + """Display a square box.""" + + tag = "ScaleFade" + + +class Slide(ChakraComponent): + """Display a square box.""" + + tag = "Slide" + + +class SlideFade(ChakraComponent): + """Display a square box.""" + + tag = "SlideFade" diff --git a/pynecone/components/typography/__init__.py b/pynecone/components/typography/__init__.py new file mode 100644 index 000000000..0f6bf676a --- /dev/null +++ b/pynecone/components/typography/__init__.py @@ -0,0 +1,23 @@ +"""Typography components.""" + +from pynecone.components.component import Component + +from .heading import Heading +from .markdown import Markdown +from .text import Text + + +def span(text: str, **kwargs) -> Component: + """Create a span component. + + Args: + text: The text to display. + **kwargs: The keyword arguments to pass to the Text component. + + Returns: + The span component. + """ + return Text.create(text, as_="span", **kwargs) + + +__all__ = [f for f in dir() if f[0].isupper() or f in ("span",)] # type: ignore diff --git a/pynecone/components/typography/heading.py b/pynecone/components/typography/heading.py new file mode 100644 index 000000000..76f798f5f --- /dev/null +++ b/pynecone/components/typography/heading.py @@ -0,0 +1,13 @@ +"""A heading component.""" + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Heading(ChakraComponent): + """Heading composes Box so you can use all the style props and add responsive styles as well. It renders an h2 tag by default.""" + + tag = "Heading" + + # "4xl" | "3xl" | "2xl" | "xl" | "lg" | "md" | "sm" | "xs" + size: Var[str] diff --git a/pynecone/components/typography/highlight.py b/pynecone/components/typography/highlight.py new file mode 100644 index 000000000..30ecb1d79 --- /dev/null +++ b/pynecone/components/typography/highlight.py @@ -0,0 +1,15 @@ +"""A heading component.""" + +from typing import List, Union + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.var import Var + + +class Highlight(ChakraComponent): + """Highlights a specific part of a string.""" + + tag = "Highlight" + + # A query for the text to highlight. Can be a string or a list of strings. + query: Var[List[str]] diff --git a/pynecone/components/typography/markdown.py b/pynecone/components/typography/markdown.py new file mode 100644 index 000000000..6a62922eb --- /dev/null +++ b/pynecone/components/typography/markdown.py @@ -0,0 +1,61 @@ +"""Table components.""" + +from pynecone import utils +from pynecone.components.component import Component +from pynecone.var import Var + + +class Markdown(Component): + """A markdown component.""" + + library = "react-markdown" + + tag = "ReactMarkdown" + + src: Var[str] = "" # type: ignore + + def _get_custom_code(self) -> str: + return "import 'katex/dist/katex.min.css'" + + def _get_imports(self): + imports = super()._get_imports() + imports["@chakra-ui/react"] = {"Heading", "Code", "Text", "Link"} + imports["react-syntax-highlighter"] = {"Prism"} + imports["remark-math"] = {"remarkMath"} + imports["remark-gfm"] = {"remarkGfm"} + imports["rehype-katex"] = {"rehypeKatex"} + return imports + + def _render(self): + tag = super()._render() + return tag.add_attrs( + children=utils.wrap(str(self.src).strip(), "`"), + components={ + "h1": "{({node, ...props}) => }", + "h2": "{({node, ...props}) => }", + "h3": "{({node, ...props}) => }", + "p": "{Text}", + "a": "{Link}", + # "code": "{Code}" + "code": """{({node, inline, className, children, ...props}) => + { + const match = (className || '').match(/language-(?.*)/); + return !inline ? ( + + ) : ( + + {children} + + ); + }}""".replace( + "\n", " " + ), + }, + remark_plugins="{{[remarkMath, remarkGfm]}}", + rehype_plugins="{{[rehypeKatex]}}", + src="", + ) diff --git a/pynecone/components/typography/text.py b/pynecone/components/typography/text.py new file mode 100644 index 000000000..827d64231 --- /dev/null +++ b/pynecone/components/typography/text.py @@ -0,0 +1,27 @@ +"""A text component.""" +from __future__ import annotations + +from pynecone.components.libs.chakra import ChakraComponent +from pynecone.components.tags import Tag +from pynecone.var import Var + + +class Text(ChakraComponent): + """Text component is the used to render text and paragraphs within an interface. It renders a p tag by default.""" + + tag = "Text" + + # The text to display. + text: Var[str] + + # The CSS `text-align` property + text_align: Var[str] + + # The CSS `text-transform` property + casing: Var[str] + + # The CSS `text-decoration` property + decoration: Var[str] + + # Override the text element. Tpyes are b, strong, i, em, mark, small, del, ins, sub, and sup. + as_: Var[str] diff --git a/pynecone/constants.py b/pynecone/constants.py new file mode 100644 index 000000000..4f42c6b0e --- /dev/null +++ b/pynecone/constants.py @@ -0,0 +1,128 @@ +"""Constants used throughout the package.""" + +import os +from enum import Enum + +import pkg_resources + +# App names and versions. +# The name of the Pynecone module. +MODULE_NAME = "pynecone" +# The name of the pip install package. +PACKAGE_NAME = "pynecone-io" +# The current version of Pynecone. +VERSION = pkg_resources.get_distribution(PACKAGE_NAME).version + +# Files and directories used to init a new project. +# The root directory of the pynecone library. +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# The name of the file used for pc init. +APP_TEMPLATE_FILE = "tutorial.py" +# The name of the assets directory. +APP_ASSETS_DIR = "assets" +# The template directory used during pc init. +TEMPLATE_DIR = os.path.join(ROOT_DIR, MODULE_NAME, ".templates") +# The web subdirectory of the template directory. +WEB_TEMPLATE_DIR = os.path.join(TEMPLATE_DIR, "web") +# The app subdirectory of the template directory. +APP_TEMPLATE_DIR = os.path.join(TEMPLATE_DIR, "app") +# The assets subdirectory of the template directory. +ASSETS_TEMPLATE_DIR = os.path.join(TEMPLATE_DIR, APP_ASSETS_DIR) + +# The frontend directories in a project. +# The web folder where the NextJS app is compiled to. +WEB_DIR = ".web" +# The name of the utils file. +UTILS_DIR = "utils" +# The name of the state file. +STATE_PATH = os.path.join(UTILS_DIR, "state") +# The directory where the app pages are compiled to. +WEB_PAGES_DIR = os.path.join(WEB_DIR, "pages") +# The directory where the utils file is located. +WEB_UTILS_DIR = os.path.join(WEB_DIR, UTILS_DIR) +# The directory where the assets are located. +WEB_ASSETS_DIR = os.path.join(WEB_DIR, "public") +# The node modules directory. +NODE_MODULES = "node_modules" +# The package lock file. +PACKAGE_LOCK = "package-lock.json" + +# Commands to run the app. +# Command to install bun. +INSTALL_BUN = "curl https://bun.sh/install | bash" +# Command to run the backend in dev mode. +RUN_BACKEND = "uvicorn --log-level critical --reload --host 0.0.0.0".split() +# The number of workers to run in production mode by default. +NUM_WORKERS = (os.cpu_count() or 1) * 2 + 1 +# The default timeout when launching the gunicorn server. +TIMEOUT = 120 +# The command to run the backend in production mode. +RUN_BACKEND_PROD = f"gunicorn --worker-class uvicorn.workers.UvicornH11Worker --bind 0.0.0.0:8000 --workers {NUM_WORKERS} --threads {NUM_WORKERS} --preload --timeout {TIMEOUT} --log-level debug".split() + +# Compiler variables. +# The extension for compiled Javascript files. +JS_EXT = ".js" +# The extension for python files. +PY_EXT = ".py" +# The expected variable name where the pc.App is stored. +APP_VAR = "app" +# The expected variable name where the API object is stored for deployment. +API_VAR = "api" +# The name of the router variable. +ROUTER = "router" +# The name of the initial hydrate event. +HYDRATE = "hydrate" +# The name of the index page. +INDEX_ROUTE = "index" +# The name of the document root page. +DOCUMENT_ROOT = "_document" +# The name of the theme page. +THEME = "theme" +# The prefix used to create setters for state vars. +SETTER_PREFIX = "set_" +# The name of the frontend zip during deployment. +FRONTEND_ZIP = "frontend.zip" +# The name of the backend zip during deployment. +BACKEND_ZIP = "backend.zip" +# The name of the sqlite database. +DB_NAME = "pynecone.db" +# The default title to show for Pynecone apps. +DEFAULT_TITLE = "Pynecone App" +# The name of the pynecone config module. +CONFIG_MODULE = "pcconfig" +# The python config file. +CONFIG_FILE = f"{CONFIG_MODULE}.{PY_EXT}" +# The deployment URL. +PRODUCTION_BACKEND_URL = "https://{username}-{app_name}.api.pynecone.app" + + +# Env modes +class Env(Enum): + """The environment modes.""" + + DEV = "dev" + PROD = "prod" + + +class Endpoint(Enum): + """Endpoints for the pynecone backend API.""" + + PING = "ping" + EVENT = "event" + + def __str__(self) -> str: + """Get the string representation of the endpoint. + + Returns: + The path for the endpoint. + """ + return f"/{self.value}" + + def get_url(self) -> str: + """Get the URL for the endpoint. + + Returns: + The full URL for the endpoint. + """ + pcconfig = __import__(CONFIG_MODULE) + return "".join([pcconfig.API_HOST, str(self)]) diff --git a/pynecone/event.py b/pynecone/event.py new file mode 100644 index 000000000..eb483c1b2 --- /dev/null +++ b/pynecone/event.py @@ -0,0 +1,167 @@ +"""Define event classes to connect the frontend and backend.""" +from __future__ import annotations + +import inspect +from typing import Any, Callable, Dict, List, Set, Tuple + +from pynecone.base import Base +from pynecone.var import BaseVar, Var + + +class Event(Base): + """An event that describes any state change in the app.""" + + # The token to specify the client that the event is for. + token: str + + # The event name. + name: str + + # The event payload. + payload: Dict[str, Any] = {} + + +class EventHandler(Base): + """An event handler responds to an event to update the state.""" + + # The function to call in response to the event. + fn: Callable + + class Config: + """The Pydantic config.""" + + # Needed to allow serialization of Callable. + frozen = True + + def __call__(self, *args: Var) -> EventSpec: + """Pass arguments to the handler to get an event spec. + + This method configures event handlers that take in arguments. + + Args: + *args: The arguments to pass to the handler. + + Returns: + The event spec, containing both the function and args. + """ + # Get the function args. + fn_args = inspect.getfullargspec(self.fn).args[1:] + + # Construct the payload. + payload = tuple(zip(fn_args, [Var.create(arg).full_name for arg in args])) # type: ignore + + # Return the event spec. + return EventSpec(handler=self, args=payload) + + +class EventSpec(Base): + """An event specification. + + Whereas an Event object is passed during runtime, a spec is used + during compile time to outline the structure of an event. + """ + + # The event handler. + handler: EventHandler + + # The local arguments on the frontend. + local_args: Tuple[str, ...] = () + + # The arguments to pass to the function. + args: Tuple[Any, ...] = () + + class Config: + """The Pydantic config.""" + + # Required to allow tuple fields. + frozen = True + + +class EventChain(Base): + """Container for a chain of events that will be executed in order.""" + + events: List[EventSpec] + + +class Target(Base): + """A Javascript event target.""" + + checked: bool = False + value: Any = None + + +class FrontendEvent(Base): + """A Javascript event.""" + + target: Target = Target() + + +# The default event argument. +EVENT_ARG = BaseVar(name="_e", type_=FrontendEvent, is_local=True) + + +# Special server-side events. +def redirect(path: str) -> Event: + """Redirect to a new path. + + Args: + path: The path to redirect to. + + Returns: + An event to redirect to the path. + """ + return Event( + token="", + name="_redirect", + payload={"path": path}, + ) + + +def console_log(message: str) -> Event: + """Do a console.log on the browser. + + Args: + message: The message to log. + + Returns: + An event to log the message. + """ + return Event( + token="", + name="_console", + payload={"message": message}, + ) + + +def window_alert(message: str) -> Event: + """Create a window alert on the browser. + + Args: + message: The message to alert. + + Returns: + An event to alert the message. + """ + return Event( + token="", + name="_alert", + payload={"message": message}, + ) + + +# A set of common event triggers. +EVENT_TRIGGERS: Set[str] = { + "on_focus", + "on_blur", + "on_click", + "on_context_menu", + "on_double_click", + "on_mouse_down", + "on_mouse_enter", + "on_mouse_leave", + "on_mouse_move", + "on_mouse_out", + "on_mouse_over", + "on_mouse_up", + "on_scroll", +} diff --git a/pynecone/middleware/__init__.py b/pynecone/middleware/__init__.py new file mode 100644 index 000000000..e20de5c9f --- /dev/null +++ b/pynecone/middleware/__init__.py @@ -0,0 +1,5 @@ +"""Pynecone middleware.""" + +from .hydrate_middleware import HydrateMiddleware +from .logging_middleware import LoggingMiddleware +from .middleware import Middleware diff --git a/pynecone/middleware/hydrate_middleware.py b/pynecone/middleware/hydrate_middleware.py new file mode 100644 index 000000000..9325c1c0d --- /dev/null +++ b/pynecone/middleware/hydrate_middleware.py @@ -0,0 +1,30 @@ +"""Middleware to hydrate the state.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from pynecone import constants, utils +from pynecone.event import Event +from pynecone.middleware.middleware import Middleware +from pynecone.state import Delta, State + +if TYPE_CHECKING: + from pynecone.app import App + + +class HydrateMiddleware(Middleware): + """Middleware to handle initial app hydration.""" + + def preprocess(self, app: App, state: State, event: Event) -> Optional[Delta]: + """Preprocess the event. + + Args: + app: The app to apply the middleware to. + state: The client state. + event: The event to preprocess. + + Returns: + An optional state to return. + """ + if event.name == utils.get_hydrate_event(state): + return utils.format_state({state.get_name(): state.dict()}) diff --git a/pynecone/middleware/logging_middleware.py b/pynecone/middleware/logging_middleware.py new file mode 100644 index 000000000..e5e95399d --- /dev/null +++ b/pynecone/middleware/logging_middleware.py @@ -0,0 +1,36 @@ +"""Logging middleware.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pynecone.event import Event +from pynecone.middleware.middleware import Middleware +from pynecone.state import Delta, State + +if TYPE_CHECKING: + from pynecone.app import App + + +class LoggingMiddleware(Middleware): + """Middleware to log requests and responses.""" + + def preprocess(self, app: App, state: State, event: Event): + """Preprocess the event. + + Args: + app: The app to apply the middleware to. + state: The client state. + event: The event to preprocess. + """ + print(f"Event {event}") + + def postprocess(self, app: App, state: State, event: Event, delta: Delta): + """Postprocess the event. + + Args: + app: The app to apply the middleware to. + state: The client state. + event: The event to postprocess. + delta: The delta to postprocess. + """ + print(f"Delta {delta}") diff --git a/pynecone/middleware/middleware.py b/pynecone/middleware/middleware.py new file mode 100644 index 000000000..fbc0dc103 --- /dev/null +++ b/pynecone/middleware/middleware.py @@ -0,0 +1,45 @@ +"""Base Pynecone middelware.""" +from __future__ import annotations + +from abc import ABC +from typing import TYPE_CHECKING, Optional + +from pynecone.base import Base +from pynecone.event import Event +from pynecone.state import Delta, State + +if TYPE_CHECKING: + from pynecone.app import App + + +class Middleware(Base, ABC): + """Middleware to preprocess and postprocess requests.""" + + def preprocess(self, app: App, state: State, event: Event) -> Optional[Delta]: + """Preprocess the event. + + Args: + app: The app. + state: The client state. + event: The event to preprocess. + + Returns: + An optional state to return. + """ + return None + + def postprocess( + self, app: App, state: State, event: Event, delta + ) -> Optional[Delta]: + """Postprocess the event. + + Args: + app: The app. + state: The client state. + event: The event to postprocess. + delta: The delta to postprocess. + + Returns: + An optional state to return. + """ + return None diff --git a/pynecone/model.py b/pynecone/model.py new file mode 100644 index 000000000..baec3da1b --- /dev/null +++ b/pynecone/model.py @@ -0,0 +1,54 @@ +"""Database built into Pynecone.""" + +import sqlmodel + +from pynecone import utils +from pynecone.base import Base + + +def get_engine(): + """Get the database engine. + + Returns: + The database engine. + """ + uri = utils.get_config().DB_URI + return sqlmodel.create_engine(uri, echo=False) + + +class Model(Base, sqlmodel.SQLModel): + """Base class to define a table in the database.""" + + # The primary key for the table. + id: int = sqlmodel.Field(primary_key=True) + + @staticmethod + def create_all(): + """Create all the tables.""" + engine = get_engine() + sqlmodel.SQLModel.metadata.create_all(engine) + + @classmethod + @property + def select(cls): + """Select rows from the table. + + Returns: + The select statement. + """ + return sqlmodel.select(cls) + + +def session(url=None): + """Get a session to interact with the database. + + Args: + url: The database url. + + Returns: + A database session. + """ + if url is not None: + return sqlmodel.Session(sqlmodel.create_engine(url)) + engine = get_engine() + return sqlmodel.Session(engine) diff --git a/pynecone/pc.py b/pynecone/pc.py new file mode 100644 index 000000000..a0f31f77c --- /dev/null +++ b/pynecone/pc.py @@ -0,0 +1,128 @@ +"""Pynecone CLI to create, run, and deploy apps.""" + +import os + +import requests +import typer + +from pynecone import constants, utils +from pynecone.compiler import templates + +# Create the app. +cli = typer.Typer() + + +@cli.command() +def version(): + """Get the Pynecone version.""" + utils.console.print(constants.VERSION) + + +@cli.command() +def init(): + """Initialize a new Pynecone app.""" + app_name = utils.get_default_app_name() + with utils.console.status(f"[bold]Initializing {app_name}") as status: + # Only create the app directory if it doesn't exist. + if not os.path.exists(constants.CONFIG_FILE): + # Create a configuration file. + with open(constants.CONFIG_FILE, "w") as f: + f.write(templates.PCCONFIG.format(app_name=app_name)) + utils.console.log(f"Initialize the app directory.") + + # Initialize the app directory. + utils.cp(constants.APP_TEMPLATE_DIR, app_name) + utils.mv( + os.path.join(app_name, constants.APP_TEMPLATE_FILE), + os.path.join(app_name, app_name + constants.PY_EXT), + ) + utils.cp(constants.ASSETS_TEMPLATE_DIR, constants.APP_ASSETS_DIR) + + # Install bun if it isn't already installed. + if not os.path.exists(utils.get_bun_path()): + utils.console.log(f"Installing bun...") + os.system(constants.INSTALL_BUN) + + # Initialize the web directory. + utils.console.log(f"Initializing the web directory.") + utils.rm(os.path.join(constants.WEB_TEMPLATE_DIR, constants.NODE_MODULES)) + utils.rm(os.path.join(constants.WEB_TEMPLATE_DIR, constants.PACKAGE_LOCK)) + utils.cp(constants.WEB_TEMPLATE_DIR, constants.WEB_DIR) + utils.console.log(f"[bold green]Finished Initializing: {app_name}") + + +@cli.command() +def run( + env: constants.Env = constants.Env.DEV.value, # type: ignore + frontend: bool = True, + backend: bool = True, +): + """Run the app. + + Args: + env: The environment to run the app in. + frontend: Whether to run the frontend. + backend: Whether to run the backend. + """ + utils.console.rule("[bold]Starting Pynecone App") + app = utils.get_app() + frontend_cmd = backend_cmd = None + if env == constants.Env.DEV: + frontend_cmd, backend_cmd = utils.run_frontend, utils.run_backend + if env == constants.Env.PROD: + frontend_cmd, backend_cmd = utils.run_frontend_prod, utils.run_backend_prod + assert frontend_cmd and backend_cmd, "Invalid env" + + if frontend: + frontend_cmd(app) + if backend: + backend_cmd(app) + + +@cli.command() +def deploy(dry_run: bool = False): + """Deploy the app to the hosting service. + + Args: + dry_run: Whether to run a dry run. + """ + # Get the app config. + pcconfig = utils.get_config() + pcconfig.API_HOST = utils.get_production_backend_url() + + # Check if the deploy URI is set. + if not pcconfig.DEPLOY_URI: + typer.echo("This feature is coming soon!") + typer.echo("Join our waitlist to be notified: https://pynecone.io/waitlist") + return + + # Compile the app in production mode. + typer.echo("Compiling production app") + app = utils.get_app() + utils.export_app(app) + + # Exit early if this is a dry run. + if dry_run: + return + + # Deploy the app. + data = {"userId": pcconfig.USERNAME, "projectId": pcconfig.APP_NAME} + original_response = requests.get(pcconfig.DEPLOY_URI, params=data) + response = original_response.json() + print("response", response) + frontend = response["frontend_resources_url"] + backend = response["backend_resources_url"] + + # Upload the frontend and backend. + with open(constants.FRONTEND_ZIP, "rb") as f: + response = requests.put(frontend, data=f) + + with open(constants.BACKEND_ZIP, "rb") as f: + response = requests.put(backend, data=f) + + +main = cli + + +if __name__ == "__main__": + main() diff --git a/pynecone/state.py b/pynecone/state.py new file mode 100644 index 000000000..c219de1e8 --- /dev/null +++ b/pynecone/state.py @@ -0,0 +1,508 @@ +"""Define the pynecone state specification.""" +from __future__ import annotations + +import asyncio +import functools +import pickle +import traceback +from abc import ABC +from typing import Any, Callable, ClassVar, Dict, List, Optional, Sequence, Set, Type + +from pynecone import utils +from pynecone.base import Base +from pynecone.event import Event, EventHandler, EventSpec, window_alert +from pynecone.var import BaseVar, ComputedVar, Var + +Delta = Dict[str, Any] + + +class State(Base, ABC): + """The state of the app.""" + + # A map from the var name to the var. + vars: ClassVar[Dict[str, Var]] = {} + + # The base vars of the class. + base_vars: ClassVar[Dict[str, BaseVar]] = {} + + # The computed vars of the class. + computed_vars: ClassVar[Dict[str, ComputedVar]] = {} + + # vars inherited by the parent state. + inherited_vars: ClassVar[Dict[str, Var]] = {} + + # The parent state. + parent_state: Optional[State] = None + + # The substates of the state. + substates: Dict[str, State] = {} + + # The set of dirty vars. + dirty_vars: Set[str] = set() + + # The set of dirty substates. + dirty_substates: Set[str] = set() + + def __init__(self, *args, **kwargs): + """Initialize the state. + + Args: + *args: The args to pass to the Pydantic init method. + **kwargs: The kwargs to pass to the Pydantic init method. + """ + super().__init__(*args, **kwargs) + + # Setup the substates. + for substate in self.get_substates(): + self.substates[substate.get_name()] = substate().set(parent_state=self) + + def __repr__(self) -> str: + """Get the string representation of the state. + + Returns: + The string representation of the state. + """ + return f"{self.__class__.__name__}({self.dict()})" + + @classmethod + def __init_subclass__(cls, **kwargs): + """Do some magic for the subclass initialization. + + Args: + **kwargs: The kwargs to pass to the pydantic init_subclass method. + """ + super().__init_subclass__(**kwargs) + + # Get the parent vars. + parent_state = cls.get_parent_state() + if parent_state is not None: + cls.inherited_vars = parent_state.vars + + # Set the base and computed vars. + skip_vars = set(cls.inherited_vars) | { + "parent_state", + "substates", + "dirty_vars", + "dirty_substates", + } + cls.base_vars = { + f.name: BaseVar(name=f.name, type_=f.outer_type_).set_state(cls) + for f in cls.get_fields().values() + if f.name not in skip_vars + } + cls.computed_vars = { + v.name: v.set_state(cls) + for v in cls.__dict__.values() + if isinstance(v, ComputedVar) + } + cls.vars = { + **cls.inherited_vars, + **cls.base_vars, + **cls.computed_vars, + } + + # Setup the base vars at the class level. + for prop in cls.base_vars.values(): + cls._set_var(prop) + cls._create_setter(prop) + cls._set_default_value(prop) + + # Set up the event handlers. + events = { + name: fn + for name, fn in cls.__dict__.items() + if not name.startswith("_") and isinstance(fn, Callable) + } + for name, fn in events.items(): + event_handler = EventHandler(fn=fn) + setattr(cls, name, event_handler) + + @classmethod + @functools.lru_cache() + def get_parent_state(cls) -> Optional[Type[State]]: + """Get the parent state. + + Returns: + The parent state. + """ + parent_states = [ + base + for base in cls.__bases__ + if utils._issubclass(base, State) and base is not State + ] + assert len(parent_states) < 2, "Only one parent state is allowed." + return parent_states[0] if len(parent_states) == 1 else None # type: ignore + + @classmethod + @functools.lru_cache() + def get_substates(cls) -> Set[Type[State]]: + """Get the substates of the state. + + Returns: + The substates of the state. + """ + return {subclass for subclass in cls.__subclasses__()} + + @classmethod + @functools.lru_cache() + def get_name(cls) -> str: + """Get the name of the state. + + Returns: + The name of the state. + """ + return utils.to_snake_case(cls.__name__) + + @classmethod + @functools.lru_cache() + def get_full_name(cls) -> str: + """Get the full name of the state. + + Returns: + The full name of the state. + """ + name = cls.get_name() + parent_state = cls.get_parent_state() + if parent_state is not None: + name = ".".join((parent_state.get_full_name(), name)) + return name + + @classmethod + @functools.lru_cache() + def get_class_substate(cls, path: Sequence[str]) -> Type[State]: + """Get the class substate. + + Args: + path: The path to the substate. + + Returns: + The class substate. + """ + if len(path) == 0: + return cls + if path[0] == cls.get_name(): + if len(path) == 1: + return cls + path = path[1:] + for substate in cls.get_substates(): + if path[0] == substate.get_name(): + return substate.get_class_substate(path[1:]) + raise ValueError(f"Invalid path: {path}") + + @classmethod + def get_class_var(cls, path: Sequence[str]) -> Any: + """Get the class var. + + Args: + path: The path to the var. + + Returns: + The class var. + """ + path, name = path[:-1], path[-1] + substate = cls.get_class_substate(tuple(path)) + if not hasattr(substate, name): + raise ValueError(f"Invalid path: {path}") + return getattr(substate, name) + + @classmethod + def _set_var(cls, prop: BaseVar): + """Set the var as a class member. + + Args: + prop: The var instance to set. + """ + setattr(cls, prop.name, prop) + + @classmethod + def _create_setter(cls, prop: BaseVar): + """Create a setter for the var. + + Args: + prop: The var to create a setter for. + """ + setter_name = prop.get_setter_name(include_state=False) + if setter_name not in cls.__dict__: + setattr(cls, setter_name, prop.get_setter()) + + @classmethod + def _set_default_value(cls, prop: BaseVar): + """Set the default value for the var. + + Args: + prop: The var to set the default value for. + """ + # Get the pydantic field for the var. + field = cls.get_fields()[prop.name] + default_value = prop.get_default_value() + if field.required and default_value is not None: + field.required = False + field.default = default_value + + def __getattribute__(self, name: str) -> Any: + """Get the attribute. + + Args: + name: The name of the attribute. + + Returns: + The attribute. + """ + # If it is an inherited var, return from the parent state. + if name != "inherited_vars" and name in self.inherited_vars: + return getattr(self.parent_state, name) + try: + return super().__getattribute__(name) + except Exception as e: + # Check if the attribute is a substate. + if name in self.substates: + return self.substates[name] + raise e + + def __setattr__(self, name: str, value: Any): + """Set the attribute. + + Args: + name: The name of the attribute. + value: The value of the attribute. + """ + if name != "inherited_vars" and name in self.inherited_vars: + setattr(self.parent_state, name, value) + return + + # Set the attribute. + super().__setattr__(name, value) + + # Add the var to the dirty list. + if name in self.vars: + self.dirty_vars.add(name) + self.mark_dirty() + + def reset(self): + """Reset all the base vars to their default values.""" + # Reset the base vars. + fields = self.get_fields() + for prop_name in self.base_vars: + setattr(self, prop_name, fields[prop_name].default) + + # Recursively reset the substates. + for substate in self.substates.values(): + substate.reset() + + # Clean the state. + self.clean() + + def get_substate(self, path: Sequence[str]) -> Optional[State]: + """Get the substate. + + Args: + path: The path to the substate. + + Returns: + The substate. + """ + if len(path) == 0: + return self + if path[0] == self.get_name(): + if len(path) == 1: + return self + path = path[1:] + if path[0] not in self.substates: + raise ValueError(f"Invalid path: {path}") + return self.substates[path[0]].get_substate(path[1:]) + + async def process(self, event: Event) -> StateUpdate: + """Process an event. + + Args: + event: The event to process. + + Returns: + The state update after processing the event. + """ + + def fix_events(events): + if events is None: + return [] + if not isinstance(events, List): + events = [events] + out = [] + for e in events: + if isinstance(e, Event): + out.append( + Event( + token=event.token, + name=e.name, + payload=e.payload, + ) + ) + else: + if isinstance(e, EventHandler): + e = e() + assert isinstance(e, EventSpec) + out.append( + Event( + token=event.token, + name=utils.to_snake_case(e.handler.fn.__qualname__), + payload=dict(e.args), + ) + ) + return out + + # Get the event handler. + path = event.name.split(".") + path, name = path[:-1], path[-1] + substate = self.get_substate(path) + handler = getattr(substate, name) + + # Process the event. + fn = functools.partial(handler.fn, substate) + try: + if asyncio.iscoroutinefunction(fn.func): + events = await fn(**event.payload) + else: + events = fn(**event.payload) + except: + error = traceback.format_exc() + print(error) + return StateUpdate(events=[window_alert(error)]) + + # Return the substate and the delta. + events = fix_events(events) + delta = self.get_delta() + self.clean() + return StateUpdate(delta=delta, events=events) + + def get_delta(self) -> Delta: + """Get the delta for the state. + + Returns: + The delta for the state. + """ + delta = { + self.get_full_name(): { + prop: getattr(self, prop) + for prop in self.dirty_vars | set(self.computed_vars.keys()) + } + } + for substate in self.dirty_substates: + delta.update(self.substates[substate].get_delta()) + delta = utils.format_state(delta) + return delta + + def mark_dirty(self): + """Mark the substate and all parent states as dirty.""" + if self.parent_state is not None: + self.parent_state.dirty_substates.add(self.get_name()) + self.parent_state.mark_dirty() + + def clean(self): + """Reset the dirty vars.""" + for substate in self.dirty_substates: + self.substates[substate].clean() + self.dirty_vars = set() + self.dirty_substates = set() + + def dict(self, include_computed: bool = True, **kwargs) -> Dict[str, Any]: + """Convert the object to a dictionary. + + Args: + include_computed: Whether to include computed vars. + **kwargs: Kwargs to pass to the pydantic dict method. + + Returns: + The object as a dictionary. + """ + base_vars = { + prop_name: self.get_value(getattr(self, prop_name)) + for prop_name in self.base_vars + } + computed_vars = ( + { + # Include the computed vars. + prop_name: self.get_value(getattr(self, prop_name)) + for prop_name in self.computed_vars + } + if include_computed + else {} + ) + substate_vars = { + k: v.dict(include_computed=include_computed, **kwargs) + for k, v in self.substates.items() + } + vars = {**base_vars, **computed_vars, **substate_vars} + return {k: vars[k] for k in sorted(vars)} + + +class DefaultState(State): + """The default empty state.""" + + pass + + +class StateUpdate(Base): + """A state update sent to the frontend.""" + + # The state delta. + delta: Delta = {} + + # Events to be added to the event queue. + events: List[Event] = [] + + +redis = None + + +class StateManager(Base): + """A class to manage many client states.""" + + # The state class to use. + state: Type[State] = DefaultState + + # The mapping of client ids to states. + states: Dict[str, State] = {} + + # The token expiration time (s). + token_expiration: int = 60 * 60 + + def __init__(self, *args, **kwargs): + """Initialize the state manager. + + Args: + *args: Args to pass to the base class. + **kwargs: Kwargs to pass to the base class. + """ + super().__init__(*args, **kwargs) + global redis + redis = utils.get_redis() + + def get_state(self, token: str) -> State: + """Get the state for a token. + + Args: + token: The token to get the state for. + + Returns: + The state for the token. + """ + if redis is not None: + redis_state = redis.get(token) + if redis_state is None: + self.set_state(token, self.state()) + return self.get_state(token) + return pickle.loads(redis_state) + + if token not in self.states: + self.states[token] = self.state() + return self.states[token] + + def set_state(self, token: str, state: State): + """Set the state for a token. + + Args: + token: The token to set the state for. + state: The state to set. + """ + if redis is None: + return + redis.set(token, pickle.dumps(state), ex=self.token_expiration) diff --git a/pynecone/style.py b/pynecone/style.py new file mode 100644 index 000000000..ce7d77c7e --- /dev/null +++ b/pynecone/style.py @@ -0,0 +1,40 @@ +"""Handle styling.""" + +from typing import Optional + +from pynecone import utils +from pynecone.var import Var + + +def convert(style_dict): + """Format a style dictionary. + + Args: + style_dict: The style dictionary to format. + + Returns: + The formatted style dictionary. + """ + out = {} + for key, value in style_dict.items(): + key = utils.to_camel_case(key) + if isinstance(value, dict): + out[key] = convert(value) + elif isinstance(value, Var): + out[key] = str(value) + else: + out[key] = value + return out + + +class Style(dict): + """A style dictionary.""" + + def __init__(self, style_dict: Optional[dict] = None): + """Initialize the style. + + Args: + style_dict: The style dictionary. + """ + style_dict = style_dict or {} + super().__init__(convert(style_dict)) diff --git a/pynecone/utils.py b/pynecone/utils.py new file mode 100644 index 000000000..b78f76f6e --- /dev/null +++ b/pynecone/utils.py @@ -0,0 +1,769 @@ +"""General utility functions.""" + +from __future__ import annotations + +import inspect +import json +import os +import random +import re +import shutil +import signal +import string +import subprocess +import sys +from collections import defaultdict +from subprocess import PIPE +from typing import _GenericAlias # type: ignore +from typing import _UnionGenericAlias # type: ignore +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Type, + Union, +) + +import plotly.graph_objects as go +from plotly.io import to_json +from rich.console import Console + +from pynecone import constants + +if TYPE_CHECKING: + from pynecone.components.component import ImportDict + from pynecone.event import EventHandler, EventSpec + from pynecone.var import Var + + +# Shorthand for join. +join = os.linesep.join + +# Console for pretty printing. +console = Console() + + +def get_args(alias: _GenericAlias) -> Tuple[Type, ...]: + """Get the arguments of a type alias. + + Args: + alias: The type alias. + + Returns: + The arguments of the type alias. + """ + return alias.__args__ + + +def get_base_class(cls: Type) -> Type: + """Get the base class of a class. + + Args: + cls: The class. + + Returns: + The base class of the class. + """ + # For newer versions of Python. + try: + from types import GenericAlias + + if isinstance(cls, GenericAlias): + return get_base_class(cls.__origin__) + except: + pass + + # Check Union types first. + if isinstance(cls, _UnionGenericAlias): + return tuple(get_base_class(arg) for arg in get_args(cls)) + + # Check other generic aliases. + if isinstance(cls, _GenericAlias): + return get_base_class(cls.__origin__) + + # This is the base class. + return cls + + +def _issubclass( + cls: Union[Type, _GenericAlias], cls_check: Union[Type, _GenericAlias] +) -> bool: + """Check if a class is a subclass of another class. + + Args: + cls: The class to check. + cls_check: The class to check against. + + Returns: + Whether the class is a subclass of the other class. + """ + # Special check for Any. + if cls_check == Any: + return True + if cls == Any: + return False + cls_base = get_base_class(cls) + cls_check_base = get_base_class(cls_check) + return cls_check_base == Any or issubclass(cls_base, cls_check_base) + + +def _isinstance(obj: Any, cls: Union[Type, _GenericAlias]) -> bool: + """Check if an object is an instance of a class. + + Args: + obj: The object to check. + cls: The class to check against. + + Returns: + Whether the object is an instance of the class. + """ + return isinstance(obj, get_base_class(cls)) + + +def rm(path: str): + """Remove a file or directory. + + Args: + path: The path to the file or directory. + """ + if os.path.isdir(path): + shutil.rmtree(path) + elif os.path.isfile(path): + os.remove(path) + + +def cp(src: str, dest: str, overwrite: bool = True) -> bool: + """Copy a file or directory. + + Args: + src: The path to the file or directory. + dest: The path to the destination. + overwrite: Whether to overwrite the destination. + + Returns: + Whether the copy was successful. + """ + if src == dest: + return False + if not overwrite and os.path.exists(dest): + return False + if os.path.isdir(src): + rm(dest) + shutil.copytree(src, dest) + else: + shutil.copyfile(src, dest) + return True + + +def mv(src: str, dest: str, overwrite: bool = True) -> bool: + """Move a file or directory. + + Args: + src: The path to the file or directory. + dest: The path to the destination. + overwrite: Whether to overwrite the destination. + + Returns: + Whether the move was successful. + """ + if src == dest: + return False + if not overwrite and os.path.exists(dest): + return False + rm(dest) + shutil.move(src, dest) + return True + + +def mkdir(path: str): + """Create a directory. + + Args: + path: The path to the directory. + """ + if not os.path.exists(path): + os.makedirs(path) + + +def ln(src: str, dest: str, overwrite: bool = False) -> bool: + """Create a symbolic link. + + Args: + src: The path to the file or directory. + dest: The path to the destination. + overwrite: Whether to overwrite the destination. + + Returns: + Whether the link was successful. + """ + if src == dest: + return False + if not overwrite and (os.path.exists(dest) or os.path.islink(dest)): + return False + if os.path.isdir(src): + rm(dest) + os.symlink(src, dest, target_is_directory=True) + else: + os.symlink(src, dest) + return True + + +def kill(pid): + """Kill a process. + + Args: + pid: The process ID. + """ + os.kill(pid, signal.SIGTERM) + + +def which(program: str) -> Optional[str]: + """Find the path to an executable. + + Args: + program: The name of the executable. + + Returns: + The path to the executable. + """ + return shutil.which(program) + + +def get_config() -> Any: + """Get the default pcconfig. + + Returns: + The default pcconfig. + """ + sys.path.append(os.getcwd()) + return __import__(constants.CONFIG_MODULE) + + +def get_bun_path(): + """Get the path to the bun executable. + + Returns: + The path to the bun executable. + """ + return os.path.expandvars(get_config().BUN_PATH) + + +def get_app() -> Any: + """Get the app based on the default config. + + Returns: + The app based on the default config. + """ + config = get_config() + module = ".".join([config.APP_NAME, config.APP_NAME]) + app = __import__(module, fromlist=(constants.APP_VAR,)) + return app + + +def install_dependencies(): + """Install the dependencies.""" + subprocess.call([get_bun_path(), "install"], cwd=constants.WEB_DIR, stdout=PIPE) + + +def export_app(app): + """Zip up the app for deployment. + + Args: + app: The app. + """ + app.app.compile(ignore_env=False) + cmd = r"rm -rf .web/_static; cd .web && bun run export && cd _static && zip -r ../../frontend.zip ./* && cd ../.. && zip -r backend.zip ./* -x .web/\* ./assets\* ./frontend.zip\* ./backend.zip\*" + os.system(cmd) + + +def setup_frontend(app): + """Set up the frontend. + + Args: + app: The app. + """ + # Initialize the web directory if it doesn't exist. + cp(constants.WEB_TEMPLATE_DIR, constants.WEB_DIR, overwrite=False) + + # Install the frontend dependencies. + console.rule("[bold]Installing Dependencies") + install_dependencies() + + # Link the assets folder. + ln(src=os.path.join("..", constants.APP_ASSETS_DIR), dest=constants.WEB_ASSETS_DIR) + + # Compile the frontend. + app.app.compile(ignore_env=False) + + +def run_frontend(app) -> subprocess.Popen: + """Run the frontend. + + Args: + app: The app. + + Returns: + The frontend process. + """ + setup_frontend(app) + command = [get_bun_path(), "run", "dev"] + console.rule("[bold green]App Running") + return subprocess.Popen( + command, cwd=constants.WEB_DIR + ) # stdout=PIPE to hide output + + +def run_frontend_prod(app) -> subprocess.Popen: + """Run the frontend. + + Args: + app: The app. + + Returns: + The frontend process. + """ + setup_frontend(app) + # Export and zip up the frontend and backend then start the frontend in production mode. + cmd = r"rm -rf .web/_static || true; cd .web && bun run export" + os.system(cmd) + command = [get_bun_path(), "run", "prod"] + return subprocess.Popen(command, cwd=constants.WEB_DIR) + + +def run_backend(app): + """Run the backend. + + Args: + app: The app. + """ + command = constants.RUN_BACKEND + [ + f"{app.__name__}:{constants.APP_VAR}.{constants.API_VAR}" + ] + subprocess.call(command) + + +def run_backend_prod(app) -> None: + """Run the backend. + + Args: + app: The app. + """ + command = constants.RUN_BACKEND_PROD + [f"{app.__name__}:{constants.API_VAR}"] + subprocess.call(command) + + +def get_production_backend_url() -> str: + """Get the production backend URL. + + Returns: + The production backend URL. + """ + config = get_config() + return constants.PRODUCTION_BACKEND_URL.format( + username=config.USERNAME, + app_name=config.APP_NAME, + ) + + +def to_snake_case(text: str) -> str: + """Convert a string to snake case. + + The words in the text are converted to lowercase and + separated by underscores. + + Args: + text: The string to convert. + + Returns: + The snake case string. + """ + s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", text) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() + + +def to_camel_case(text: str) -> str: + """Convert a string to camel case. + + The first word in the text is converted to lowercase and + the rest of the words are converted to title case, removing underscores. + + Args: + text: The string to convert. + + Returns: + The camel case string. + """ + if "_" not in text: + return text + camel = "".join( + word.capitalize() if i > 0 else word.lower() + for i, word in enumerate(text.lstrip("_").split("_")) + ) + prefix = "_" if text.startswith("_") else "" + return prefix + camel + + +def to_title(text: str) -> str: + """Convert a string from snake case to a title. + + Each word is converted to title case and separated by a space. + + Args: + text: The string to convert. + + Returns: + The title case string. + """ + return " ".join(word.capitalize() for word in text.split("_")) + + +WRAP_MAP = { + "{": "}", + "(": ")", + "[": "]", + "<": ">", + '"': '"', + "'": "'", + "`": "`", +} + + +def get_close_char(open: str, close: Optional[str] = None) -> str: + """Check if the given character is a valid brace. + + Args: + open: The open character. + close: The close character if provided. + + Returns: + The close character. + """ + if close is not None: + return close + if open not in WRAP_MAP: + raise ValueError(f"Invalid wrap open: {open}, must be one of {WRAP_MAP.keys()}") + return WRAP_MAP[open] + + +def is_wrapped(text: str, open: str, close: Optional[str] = None) -> bool: + """Check if the given text is wrapped in the given open and close characters. + + Args: + text: The text to check. + open: The open character. + close: The close character. + + Returns: + Whether the text is wrapped. + """ + close = get_close_char(open, close) + return text.startswith(open) and text.endswith(close) + + +def wrap( + text: str, + open: str, + close: Optional[str] = None, + check_first: bool = True, + num: int = 1, +) -> str: + """Wrap the given text in the given open and close characters. + + Args: + text: The text to wrap. + open: The open character. + close: The close character. + check_first: Whether to check if the text is already wrapped. + num: The number of times to wrap the text. + + Returns: + The wrapped text. + """ + close = get_close_char(open, close) + + # If desired, check if the text is already wrapped in braces. + if check_first and is_wrapped(text=text, open=open, close=close): + return text + + # Wrap the text in braces. + return f"{open * num}{text}{close * num}" + + +def indent(text: str, indent_level: int = 2) -> str: + """Indent the given text by the given indent level. + + Args: + text: The text to indent. + indent_level: The indent level. + + Returns: + The indented text. + """ + lines = text.splitlines() + if len(lines) < 2: + return text + return os.linesep.join(f"{' ' * indent_level}{line}" for line in lines) + os.linesep + + +def get_page_path(path: str) -> str: + """Get the path of the compiled JS file for the given page. + + Args: + path: The path of the page. + + Returns: + The path of the compiled JS file. + """ + return os.path.join(constants.WEB_PAGES_DIR, path + constants.JS_EXT) + + +def get_theme_path() -> str: + """Get the path of the base theme style. + + Returns: + The path of the theme style. + """ + return os.path.join(constants.WEB_UTILS_DIR, constants.THEME + constants.JS_EXT) + + +def write_page(path: str, code: str): + """Write the given code to the given path. + + Args: + path: The path to write the code to. + code: The code to write. + """ + mkdir(os.path.dirname(path)) + with open(path, "w") as f: + f.write(code) + + +def format_route(route: str): + """Format the given route. + + Args: + route: The route to format. + + Returns: + The formatted route. + """ + route = route.strip(os.path.sep) + route = to_snake_case(route).replace("_", "-") + if route == "": + return constants.INDEX_ROUTE + return route + + +def format_cond( + cond: str, true_value: str, false_value: str = '""', is_nested: bool = False +) -> str: + """Format a conditional expression. + + Args: + cond: The cond. + true_value: The value to return if the cond is true. + false_value: The value to return if the cond is false. + is_nested: Whether the cond is nested. + + Returns: + The formatted conditional expression. + """ + expr = f"{cond} ? {true_value} : {false_value}" + if not is_nested: + expr = wrap(expr, "{") + return expr + + +def format_event_fn(fn: Callable) -> str: + """Format a function as an event. + + Args: + fn: The function to format. + + Returns: + The formatted function. + """ + from pynecone.event import EventHandler + + if isinstance(fn, EventHandler): + fn = fn.fn + return fn.__qualname__.replace(".", "_") + + +USED_VARIABLES = set() + + +def get_unique_variable_name() -> str: + """Get a unique variable name. + + Returns: + The unique variable name. + """ + name = "".join([random.choice(string.ascii_lowercase) for _ in range(8)]) + if name not in USED_VARIABLES: + USED_VARIABLES.add(name) + return name + return get_unique_variable_name() + + +def get_default_app_name() -> str: + """Get the default app name. + + The default app name is the name of the current directory. + + Returns: + The default app name. + """ + return os.getcwd().split(os.path.sep)[-1].replace("-", "_") + + +def format_state(value: Dict) -> Dict: + """Recursively format values in the given state. + + Args: + value: The state to format. + + Returns: + The formatted state. + """ + if isinstance(value, go.Figure): + return json.loads(to_json(value))["data"] + import pandas as pd + + if isinstance(value, pd.DataFrame): + return { + "columns": value.columns.tolist(), + "data": value.values.tolist(), + } + if isinstance(value, dict): + return {k: format_state(v) for k, v in value.items()} + return value + + +def get_event(state, event): + """Get the event from the given state. + + Args: + state: The state. + event: The event. + + Returns: + The event. + """ + return f"{state.get_name()}.{event}" + + +def call_event_handler(event_handler: EventHandler, arg: Var) -> EventSpec: + """Call an event handler to get the event spec. + + This function will inspect the function signature of the event handler. + If it takes in an arg, the arg will be passed to the event handler. + Otherwise, the event handler will be called with no args. + + Args: + event_handler: The event handler. + arg: The argument to pass to the event handler. + + Returns: + The event spec from calling the event handler. + """ + args = inspect.getfullargspec(event_handler.fn).args + if len(args) == 1: + return event_handler() + assert ( + len(args) == 2 + ), f"Event handler {event_handler.fn} must have 1 or 2 arguments." + return event_handler(arg) + + +def call_event_fn(fn: Callable, arg: Var) -> List[EventSpec]: + """Call a function to a list of event specs. + + The function should return either a single EventSpec or a list of EventSpecs. + If the function takes in an arg, the arg will be passed to the function. + Otherwise, the function will be called with no args. + + Args: + fn: The function to call. + arg: The argument to pass to the function. + + Returns: + The event specs from calling the function. + """ + args = inspect.getfullargspec(fn).args + if len(args) == 0: + out = fn() + elif len(args) == 1: + out = fn(arg) + else: + raise ValueError(f"Lambda {fn} must have 0 or 1 arguments.") + if not isinstance(out, List): + out = [out] + return out + + +def get_handler_args(event_spec: EventSpec, arg: Var) -> Tuple[Tuple[str, str], ...]: + """Get the handler args for the given event spec. + + Args: + event_spec: The event spec. + arg: The controlled event argument. + + Returns: + The handler args. + """ + args = inspect.getfullargspec(event_spec.handler.fn).args + if len(args) > 2: + return event_spec.args + else: + return ((args[1], arg.name),) + + +def merge_imports(*imports) -> ImportDict: + """Merge two import dicts together. + + Args: + *imports: The list of import dicts to merge. + + Returns: + The merged import dicts. + """ + all_imports = defaultdict(set) + for import_dict in imports: + for lib, fields in import_dict.items(): + for field in fields: + all_imports[lib].add(field) + return all_imports + + +def get_hydrate_event(state) -> str: + """Get the name of the hydrate event for the state. + + Args: + state: The state. + + Returns: + The name of the hydrate event. + """ + return get_event(state, constants.HYDRATE) + + +def get_redis(): + """Get the redis client. + + Returns: + The redis client. + """ + try: + import redis + + config = get_config() + redis_host, redis_port = config.REDIS_HOST.split(":") + print("Using redis at", config.REDIS_HOST) + return redis.Redis(host=redis_host, port=redis_port, db=0) + except: + return None diff --git a/pynecone/var.py b/pynecone/var.py new file mode 100644 index 000000000..32c55372f --- /dev/null +++ b/pynecone/var.py @@ -0,0 +1,705 @@ +"""Define a state var.""" +from __future__ import annotations + +import json +from abc import ABC +from typing import _GenericAlias # type: ignore +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Type, Union + +from plotly.graph_objects import Figure +from plotly.io import to_json +from pydantic.fields import ModelField + +from pynecone import constants, utils +from pynecone.base import Base + +if TYPE_CHECKING: + from pynecone.state import State + + +class Var(ABC): + """An abstract var.""" + + # The name of the var. + name: str + + # The type of the var. + type_: Type + + # The name of the enclosing state. + state: str = "" + + # Whether this is a local javascript variable. + is_local: bool = False + + # Whether the var is a string literal. + is_string: bool = False + + @classmethod + def create( + cls, value: Any, is_local: bool = True, is_string: bool = False + ) -> Optional[Var]: + """Create a var from a value. + + Args: + value: The value to create the var from. + is_local: Whether the var is local. + is_string: Whether the var is a string literal. + + Returns: + The var. + """ + # Check for none values. + if value is None: + return None + + # If the value is already a var, do nothing. + if isinstance(value, Var): + return value + + type_ = type(value) + + # Special case for plotly figures. + if isinstance(value, Figure): + value = json.loads(to_json(value))["data"] + type_ = Figure + + name = json.dumps(value) if not isinstance(value, str) else value + + return BaseVar(name=name, type_=type_, is_local=is_local, is_string=is_string) + + @classmethod + def __class_getitem__(cls, type_: str) -> _GenericAlias: + """Get a typed var. + + Args: + type_: The type of the var. + + Returns: + The var class item. + """ + return _GenericAlias(cls, type_) + + def equals(self, other: Var) -> bool: + """Check if two vars are equal. + + Args: + other: The other var to compare. + + Returns: + Whether the vars are equal. + """ + return ( + self.name == other.name + and self.type_ == other.type_ + and self.state == other.state + and self.is_local == other.is_local + ) + + def to_string(self) -> Var: + """Convert a var to a string. + + Returns: + The stringified var. + """ + return self.operation(fn="JSON.stringify") + + def __hash__(self) -> int: + """Define a hash function for a var. + + Returns: + The hash of the var. + """ + return hash((self.name, str(self.type_))) + + def __str__(self) -> str: + """Wrap the var so it can be used in templates. + + Returns: + The wrapped var, i.e. {state.var}. + """ + if self.is_local: + out = self.full_name + else: + out = utils.wrap(self.full_name, "{") + if self.is_string: + out = out.replace("\`", "`") # type: ignore + out = out.replace("`", "\`") # type: ignore + out = utils.wrap(out, "`") + out = utils.wrap(out, "{") + return out + + def __getitem__(self, i) -> Var: + """Index into a var. + + Args: + i: The index to index into. + + Returns: + The indexed var. + """ + # The type of the indexed var. + type_ = str + import pandas as pd + + # Convert any vars to local vars. + if isinstance(i, Var): + i = BaseVar(name=i.name, type_=i.type_, state=i.state, is_local=True) + + if utils._issubclass(self.type_, List): + assert isinstance( + i, utils.get_args(Union[int, Var]) + ), "Index must be an integer." + if isinstance(self.type_, _GenericAlias): + type_ = utils.get_args(self.type_)[0] + else: + type_ = Any + elif utils._issubclass(self.type_, Union[dict, pd.DataFrame]): + if isinstance(i, str): + i = utils.wrap(i, '"') + if isinstance(self.type_, _GenericAlias): + type_ = utils.get_args(self.type_)[1] + else: + type_ = Any + else: + raise TypeError("Var does not support indexing.") + + return BaseVar( + name=f"{self.name}[{i}]", + type_=type_, + state=self.state, + ) + + def __getattribute__(self, name: str) -> Var: + """Get a var attribute. + + Args: + name: The name of the attribute. + + Returns: + The var attribute. + """ + try: + return super().__getattribute__(name) + except Exception as e: + # Check if the attribute is one of the class fields. + if ( + not name.startswith("_") + and hasattr(self.type_, "__fields__") + and name in self.type_.__fields__ + ): + type_ = self.type_.__fields__[name].type_ + if isinstance(type_, ModelField): + type_ = type_.type_ + return BaseVar( + name=f"{self.name}.{name}", + type_=type_, + state=self.state, + ) + raise e + + def operation( + self, + op: str = "", + other: Optional[Var] = None, + type_: Optional[Type] = None, + flip: bool = False, + fn: Optional[str] = None, + ) -> Var: + """Perform an operation on a var. + + Args: + op: The operation to perform. + other: The other var to perform the operation on. + type_: The type of the operation result. + flip: Whether to flip the order of the operation. + fn: A function to apply to the operation. + + Returns: + The operation result. + """ + # Wrap strings in quotes. + if isinstance(other, str): + other = Var.create(json.dumps(other)) + else: + other = Var.create(other) + if type_ is None: + type_ = self.type_ + if other is None: + name = f"{op}{self.full_name}" + else: + props = (self, other) if not flip else (other, self) + name = f"{props[0].full_name} {op} {props[1].full_name}" + if fn is None: + name = utils.wrap(name, "(") + if fn is not None: + name = f"{fn}({name})" + return BaseVar( + name=name, + type_=type_, + ) + + def compare(self, op: str, other: Var) -> Var: + """Compare two vars with inequalities. + + Args: + op: The comparison operator. + other: The other var to compare with. + + Returns: + The comparison result. + """ + return self.operation(op, other, bool) + + def __invert__(self) -> Var: + """Invert a var. + + Returns: + The inverted var. + """ + return self.operation("!", type_=bool) + + def __neg__(self) -> Var: + """Negate a var. + + Returns: + The negated var. + """ + return self.operation(fn="-") + + def __abs__(self) -> Var: + """Get the absolute value of a var. + + Returns: + A var with the absolute value. + """ + return self.operation(fn="Math.abs") + + def __eq__(self, other: Var) -> Var: + """Perform an equality comparison. + + Args: + other: The other var to compare with. + + Returns: + A var representing the equality comparison. + """ + return self.compare("==", other) + + def __ne__(self, other: Var) -> Var: + """Perform an inequality comparison. + + Args: + other: The other var to compare with. + + Returns: + A var representing the inequality comparison. + """ + return self.compare("!=", other) + + def __gt__(self, other: Var) -> Var: + """Perform a greater than comparison. + + Args: + other: The other var to compare with. + + Returns: + A var representing the greater than comparison. + """ + return self.compare(">", other) + + def __ge__(self, other: Var) -> Var: + """Perform a greater than or equal to comparison. + + Args: + other: The other var to compare with. + + Returns: + A var representing the greater than or equal to comparison. + """ + return self.compare(">=", other) + + def __lt__(self, other: Var) -> Var: + """Perform a less than comparison. + + Args: + other: The other var to compare with. + + Returns: + A var representing the less than comparison. + """ + return self.compare("<", other) + + def __le__(self, other: Var) -> Var: + """Perform a less than or equal to comparison. + + Args: + other: The other var to compare with. + + Returns: + A var representing the less than or equal to comparison. + """ + return self.compare("<=", other) + + def __add__(self, other: Var) -> Var: + """Add two vars. + + Args: + other: The other var to add. + + Returns: + A var representing the sum. + """ + return self.operation("+", other) + + def __radd__(self, other: Var) -> Var: + """Add two vars. + + Args: + other: The other var to add. + + Returns: + A var representing the sum. + """ + return self.operation("+", other, flip=True) + + def __sub__(self, other: Var) -> Var: + """Subtract two vars. + + Args: + other: The other var to subtract. + + Returns: + A var representing the difference. + """ + return self.operation("-", other) + + def __rsub__(self, other: Var) -> Var: + """Subtract two vars. + + Args: + other: The other var to subtract. + + Returns: + A var representing the difference. + """ + return self.operation("-", other, flip=True) + + def __mul__(self, other: Var) -> Var: + """Multiply two vars. + + Args: + other: The other var to multiply. + + Returns: + A var representing the product. + """ + return self.operation("*", other) + + def __rmul__(self, other: Var) -> Var: + """Multiply two vars. + + Args: + other: The other var to multiply. + + Returns: + A var representing the product. + """ + return self.operation("*", other, flip=True) + + def __pow__(self, other: Var) -> Var: + """Raise a var to a power. + + Args: + other: The power to raise to. + + Returns: + A var representing the power. + """ + return self.operation(",", other, fn="Math.pow") + + def __rpow__(self, other: Var) -> Var: + """Raise a var to a power. + + Args: + other: The power to raise to. + + Returns: + A var representing the power. + """ + return self.operation(",", other, flip=True, fn="Math.pow") + + def __truediv__(self, other: Var) -> Var: + """Divide two vars. + + Args: + other: The other var to divide. + + Returns: + A var representing the quotient. + """ + return self.operation("/", other) + + def __rtruediv__(self, other: Var) -> Var: + """Divide two vars. + + Args: + other: The other var to divide. + + Returns: + A var representing the quotient. + """ + return self.operation("/", other, flip=True) + + def __floordiv__(self, other: Var) -> Var: + """Divide two vars. + + Args: + other: The other var to divide. + + Returns: + A var representing the quotient. + """ + return self.operation("/", other, fn="Math.floor") + + def __mod__(self, other: Var) -> Var: + """Get the remainder of two vars. + + Args: + other: The other var to divide. + + Returns: + A var representing the remainder. + """ + return self.operation("%", other) + + def __rmod__(self, other: Var) -> Var: + """Get the remainder of two vars. + + Args: + other: The other var to divide. + + Returns: + A var representing the remainder. + """ + return self.operation("%", other, flip=True) + + def __and__(self, other: Var) -> Var: + """Perform a logical and. + + Args: + other: The other var to perform the logical and with. + + Returns: + A var representing the logical and. + """ + return self.operation("&&", other) + + def __rand__(self, other: Var) -> Var: + """Perform a logical and. + + Args: + other: The other var to perform the logical and with. + + Returns: + A var representing the logical and. + """ + return self.operation("&&", other, flip=True) + + def __or__(self, other: Var) -> Var: + """Perform a logical or. + + Args: + other: The other var to perform the logical or with. + + Returns: + A var representing the logical or. + """ + return self.operation("||", other) + + def __ror__(self, other: Var) -> Var: + """Perform a logical or. + + Args: + other: The other var to perform the logical or with. + + Returns: + A var representing the logical or. + """ + return self.operation("||", other, flip=True) + + def foreach(self, fn: Callable) -> Var: + """Return a list of components. after doing a foreach on this var. + + Args: + fn: The function to call on each component. + + Returns: + A var representing foreach operation. + """ + arg = BaseVar( + name=utils.get_unique_variable_name(), + type_=self.type_, + ) + return BaseVar( + name=f"{self.full_name}.map(({arg.name}, i) => {fn(arg, key='i')})", + type_=self.type_, + ) + + def to(self, type_: Type) -> Var: + """Convert the type of the var. + + Args: + type_: The type to convert to. + + Returns: + The converted var. + """ + return BaseVar( + name=self.name, + type_=type_, + state=self.state, + is_local=self.is_local, + ) + + @property + def full_name(self) -> str: + """Get the full name of the var. + + Returns: + The full name of the var. + """ + if self.state == "": + return self.name + return ".".join([self.state, self.name]) + + def set_state(self, state: Type[State]) -> Any: + """Set the state of the var. + + Args: + state: The state to set. + + Returns: + The var with the set state. + """ + self.state = state.get_full_name() + return self + + +class BaseVar(Var, Base): + """A base (non-computed) var of the app state.""" + + # The name of the var. + name: str + + # The type of the var. + type_: Any + + # The name of the enclosing state. + state: str = "" + + # Whether this is a local javascript variable. + is_local: bool = False + + is_string: bool = False + + def __hash__(self) -> int: + """Define a hash function for a var. + + Returns: + The hash of the var. + """ + return hash((self.name, str(self.type_))) + + def get_default_value(self) -> Any: + """Get the default value of the var. + + Returns: + The default value of the var. + """ + if isinstance(self.type_, _GenericAlias): + type_ = self.type_.__origin__ + else: + type_ = self.type_ + if issubclass(type_, str): + return "" + if issubclass(type_, utils.get_args(Union[int, float])): + return 0 + if issubclass(type_, bool): + return False + if issubclass(type_, list): + return [] + if issubclass(type_, dict): + return {} + if issubclass(type_, tuple): + return () + if issubclass(type_, set): + return set() + return None + + def get_setter_name(self, include_state: bool = True) -> str: + """Get the name of the var's generated setter function. + + Args: + include_state: Whether to include the state name in the setter name. + + Returns: + The name of the setter function. + """ + setter = constants.SETTER_PREFIX + self.name + if not include_state or self.state == "": + return setter + return ".".join((self.state, setter)) + + def get_setter(self) -> Callable[[State, Any], None]: + """Get the var's setter function. + + Returns: + A function that that creates a setter for the var. + """ + + def setter(state: State, value: Any): + """Get the setter for the var. + + Args: + state: The state within which we add the setter function. + value: The value to set. + """ + setattr(state, self.name, value) + + setter.__qualname__ = self.get_setter_name() + + return setter + + def json(self) -> str: + """Convert the object to a json string. + + Returns: + The object as a json string. + """ + return self.__config__.json_dumps(self.dict()) + + +class ComputedVar(property, Var): + """A field with computed getters.""" + + @property + def name(self) -> str: + """Get the name of the var. + + Returns: + The name of the var. + """ + assert self.fget is not None, "Var must have a getter." + return self.fget.__name__ + + @property + def type_(self): + """Get the type of the var. + + Returns: + The type of the var. + """ + if "return" in self.fget.__annotations__: + return self.fget.__annotations__["return"] + return Any diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..51c195ba8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[tool.poetry] +name = "pynecone-io" +version = "0.1.0" +description = "" +authors = [ + "Nikhil Rao ", + "Alek Petuskey ", +] +packages = [ + {include = "pynecone"} +] + +[tool.poetry.dependencies] +python = "^3.7.2" +fastapi = "^0.75.0" +gunicorn = "^20.1.0" +plotly = "^5.10.0" +pydantic = "1.9.0" +requests = "^2.28.1" +sqlmodel = "^0.0.6" +typer = "^0.4.1" +uvicorn = "^0.17.6" +rich = "^12.6.0" + +[tool.poetry.dev-dependencies] +pytest = "^7.1.2" +pyright = "^1.1.229" +darglint = "^1.8.1" +pydocstyle = "^6.1.1" +toml = "^0.10.2" +isort = "^5.10.1" +pylint = "^2.14.5" +pytest-asyncio = "^0.20.1" + +[tool.poetry.scripts] +pc = "pynecone.pc:main" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.pyright] diff --git a/tests/components/test_tag.py b/tests/components/test_tag.py new file mode 100644 index 000000000..1d18c2df0 --- /dev/null +++ b/tests/components/test_tag.py @@ -0,0 +1,181 @@ +from typing import Dict + +import pydantic +import pytest + +from pynecone.components.tags import CondTag, Tag +from pynecone.event import EventHandler, EventSpec, EventChain +from pynecone.var import BaseVar, Var + + +def mock_event(arg): + pass + + +@pytest.mark.parametrize( + "cond,valid", + [ + (BaseVar(name="p", type_=bool), True), + (BaseVar(name="p", type_=int), False), + (BaseVar(name="p", type_=str), False), + ], +) +def test_validate_cond(cond: BaseVar, valid: bool): + """Test that the cond is a boolean. + + Args: + cond: The cond to test. + valid: The expected validity of the cond. + """ + if not valid: + with pytest.raises(pydantic.ValidationError): + Tag(cond=cond) + else: + assert cond == Tag(cond=cond).cond + + +@pytest.mark.parametrize( + "attr,formatted", + [ + ("string", '"string"'), + ("{wrapped_string}", "{wrapped_string}"), + (True, "{true}"), + (False, "{false}"), + (123, "{123}"), + (3.14, "{3.14}"), + ([1, 2, 3], "{[1, 2, 3]}"), + (["a", "b", "c"], '{["a", "b", "c"]}'), + ({"a": 1, "b": 2, "c": 3}, '{{"a": 1, "b": 2, "c": 3}}'), + ( + EventSpec(handler=EventHandler(fn=mock_event)), + '{() => Event([E("mock_event", {})])}', + ), + ( + EventSpec( + handler=EventHandler(fn=mock_event), + local_args=("e",), + args=(("arg", "e.target.value"),), + ), + '{(e) => Event([E("mock_event", {arg:e.target.value})])}', + ), + ], +) +def test_format_value(attr: Var, formatted: str): + """Test that the formatted value of an attribute is correct. + + Args: + attr: The attribute to test. + formatted: The expected formatted value. + """ + assert Tag.format_attr_value(attr) == formatted + + +@pytest.mark.parametrize( + "attrs,formatted", + [ + ({}, ""), + ({"key": 1}, "key={1}"), + ({"key": "value"}, 'key="value"'), + ({"key": True, "key2": "value2"}, 'key={true}\nkey2="value2"'), + ], +) +def test_format_attrs(attrs: Dict[str, Var], formatted: str): + """Test that the formatted attributes are correct. + + Args: + attrs: The attributes to test. + formatted: The expected formatted attributes. + """ + assert Tag(attrs=attrs).format_attrs() == formatted + + +@pytest.mark.parametrize( + "attr,valid", + [ + (1, True), + (3.14, True), + ("string", True), + (False, True), + ([], True), + ({}, False), + (None, False), + ], +) +def test_is_valid_attr(attr: Var, valid: bool): + """Test that the attribute is valid. + + Args: + attr: The attribute to test. + valid: The expected validity of the attribute. + """ + assert Tag.is_valid_attr(attr) == valid + + +def test_add_attrs(): + """Test that the attributes are added.""" + tag = Tag().add_attrs(key="value", key2=42, invalid=None, invalid2={}) + assert tag.attrs["key"] == Var.create("value") + assert tag.attrs["key2"] == Var.create(42) + assert "invalid" not in tag.attrs + assert "invalid2" not in tag.attrs + + +@pytest.mark.parametrize( + "tag,expected", + [ + (Tag(), ""), + (Tag(name="br"), "
"), + (Tag(contents="hello"), "<>hello"), + (Tag(name="h1", contents="hello"), "

hello

"), + ( + Tag(name="box", attrs={"color": "red", "textAlign": "center"}), + '', + ), + ( + Tag( + name="box", + attrs={"color": "red", "textAlign": "center"}, + contents="text", + ), + 'text', + ), + ( + Tag( + name="h1", + contents="hello", + cond=BaseVar(name="logged_in", type_=bool), + ), + '{logged_in ?

hello

: ""}', + ), + ], +) +def test_format_tag(tag: Tag, expected: str): + """Test that the formatted tag is correct. + + Args: + tag: The tag to test. + expected: The expected formatted tag. + """ + assert str(tag) == expected + + +def test_format_cond_tag(): + """Test that the formatted cond tag is correct.""" + tag = CondTag( + true_value=str(Tag(name="h1", contents="True content")), + false_value=str(Tag(name="h2", contents="False content")), + cond=BaseVar(name="logged_in", type_=bool), + ) + assert str(tag) == "{logged_in ?

True content

:

False content

}" + + +def test_format_iter_tag(): + """Test that the formatted iter tag is correct.""" + # def render_todo(todo: str): + # return Tag(name="Text", contents=todo) + + # tag = IterTag( + # iterable=BaseVar(name="todos", type_=list), + # render_fn=render_todo + # ) + # assert str(tag) == '{state.todos.map(render_todo)}' diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 000000000..84ea47a2a --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,81 @@ +import pytest + +from pynecone.base import Base +from pynecone.app import App, DefaultState +from pynecone.middleware import HydrateMiddleware +from pynecone.components import Box + + +@pytest.fixture +def app() -> App: + """A base app. + + Returns: + The app. + """ + return App() + + +@pytest.fixture +def index_page(): + """An index page.""" + + def index(): + return Box.create("Index") + + return index + + +@pytest.fixture +def about_page(): + """An index page.""" + + def about(): + return Box.create("About") + + return about + + +def test_default_state(app: App) -> None: + """Test creating an app with no state. + + Args: + app: The app to test. + """ + assert app.state() == DefaultState() + + +def test_default_middleware(app: App) -> None: + """Test creating an app with no middleware. + + Args: + app: The app to test. + """ + assert app.middleware == [HydrateMiddleware()] + + +def test_add_page_default_route(app: App, index_page, about_page) -> None: + """Test adding a page to an app. + + Args: + app: The app to test. + index_page: The index page. + about_page: The about page. + """ + assert app.pages == {} + app.add_page(index_page) + assert set(app.pages.keys()) == {"index"} + app.add_page(about_page) + assert set(app.pages.keys()) == {"index", "about"} + + +def test_add_page_set_route(app: App, index_page) -> None: + """Test adding a page to an app. + + Args: + app: The app to test. + index_page: The index page. + """ + assert app.pages == {} + app.add_page(index_page, path="/test") + assert set(app.pages.keys()) == {"test"} diff --git a/tests/test_event.py b/tests/test_event.py new file mode 100644 index 000000000..005da6e5b --- /dev/null +++ b/tests/test_event.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from pynecone.event import Event + + +# def test_event_default_date(): +# """Test that that the default date is set.""" +# t1 = datetime.now() + +# e1 = Event(token="t", name="e1") +# e2 = Event(token="t", name="e2") + +# t2 = datetime.now() + +# assert t1 < e1.date < e2.date < t2 diff --git a/tests/test_pynecone.py b/tests/test_pynecone.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 000000000..815f882fc --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,705 @@ +from typing import Dict, List + +import pytest + +from pynecone.base import Base +from pynecone.event import Event +from pynecone.state import State +from pynecone.var import BaseVar, ComputedVar + + +@pytest.fixture +def TestObject(): + class TestObject(Base): + """A test object fixture.""" + + prop1: int = 42 + prop2: str = "hello" + + return TestObject + + +@pytest.fixture +def TestState(TestObject): + class TestState(State): + """A test state.""" + + num1: int + num2: float = 3.14 + key: str + array: List[float] = [1, 2, 3.14] + mapping: Dict[str, List[int]] = {"a": [1, 2, 3], "b": [4, 5, 6]} + obj: TestObject = TestObject() + complex: Dict[int, TestObject] = {1: TestObject(), 2: TestObject()} + + @ComputedVar + def sum(self) -> float: + """Dynamically sum the numbers. + + Returns: + The sum of the numbers. + """ + return self.num1 + self.num2 + + @ComputedVar + def upper(self) -> str: + """Uppercase the key. + + Returns: + The uppercased key. + """ + return self.key.upper() + + def do_something(self): + """Do something.""" + pass + + return TestState + + +@pytest.fixture +def ChildState(TestState): + class ChildState(TestState): + """A child state fixture.""" + + value: str + count: int = 23 + + def change_both(self, value: str, count: int): + """Change both the value and count. + + Args: + value: The new value. + count: The new count. + """ + self.value = value.upper() + self.count = count * 2 + + return ChildState + + +@pytest.fixture +def ChildState2(TestState): + class ChildState2(TestState): + """A child state fixture.""" + + value: str + + return ChildState2 + + +@pytest.fixture +def GrandchildState(ChildState): + class GrandchildState(ChildState): + """A grandchild state fixture.""" + + value2: str + + return GrandchildState + + +@pytest.fixture +def state(TestState) -> State: + """A state. + + Args: + TestState: The state class. + + Returns: + A test state. + """ + return TestState() # type: ignore + + +def test_base_class_vars(state): + """Test that the class vars are set correctly. + + Args: + state: A state. + """ + fields = state.get_fields() + cls = type(state) + + for field in fields: + if field in ( + "parent_state", + "substates", + "dirty_vars", + "dirty_substates", + ): + continue + prop = getattr(cls, field) + assert isinstance(prop, BaseVar) + assert prop.name == field + + assert cls.num1.type_ == int + assert cls.num2.type_ == float + assert cls.key.type_ == str + + +def test_computed_class_var(state): + """Test that the class computed vars are set correctly. + + Args: + state: A state. + """ + cls = type(state) + vars = [(prop.name, prop.type_) for prop in cls.computed_vars.values()] + assert ("sum", float) in vars + assert ("upper", str) in vars + + +def test_class_vars(state): + """Test that the class vars are set correctly. + + Args: + state: A state. + """ + cls = type(state) + assert set(cls.vars.keys()) == { + "num1", + "num2", + "key", + "array", + "mapping", + "obj", + "complex", + "sum", + "upper", + } + + +def test_default_value(state): + """Test that the default value of a var is correct. + + Args: + state: A state. + """ + assert state.num1 == 0 + assert state.num2 == 3.14 + assert state.key == "" + assert state.sum == 3.14 + assert state.upper == "" + + +def test_computed_vars(state): + """Test that the computed var is computed correctly. + + Args: + state: A state. + """ + state.num1 = 1 + state.num2 = 4 + assert state.sum == 5 + state.key = "hello world" + assert state.upper == "HELLO WORLD" + + +def test_dict(state): + """Test that the dict representation of a state is correct. + + Args: + state: A state. + """ + assert set(state.dict().keys()) == set(state.vars.keys()) + assert set(state.dict(include_computed=False).keys()) == set(state.base_vars) + + +def test_default_setters(TestState): + """Test that we can set default values. + + Args: + TestState: The state class. + """ + state = TestState() + for prop_name in state.base_vars: + # Each base var should have a default setter. + assert hasattr(state, f"set_{prop_name}") + + +def test_class_indexing_with_vars(TestState): + """Test that we can index into a state var with another var. + + Args: + TestState: The state class. + """ + prop = TestState.array[TestState.num1] + assert str(prop) == "{test_state.array[test_state.num1]}" + + prop = TestState.mapping["a"][TestState.num1] + assert str(prop) == '{test_state.mapping["a"][test_state.num1]}' + + +def test_class_attributes(TestState): + """Test that we can get class attributes. + + Args: + TestState: The state class. + """ + prop = TestState.obj.prop1 + assert str(prop) == "{test_state.obj.prop1}" + + prop = TestState.complex[1].prop1 + assert str(prop) == "{test_state.complex[1].prop1}" + + +def test_get_parent_state(TestState, ChildState, ChildState2, GrandchildState): + """Test getting the parent state. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + assert TestState.get_parent_state() is None + assert ChildState.get_parent_state() == TestState + assert ChildState2.get_parent_state() == TestState + assert GrandchildState.get_parent_state() == ChildState + + +def test_get_substates(TestState, ChildState, ChildState2, GrandchildState): + """Test getting the substates. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + assert TestState.get_substates() == {ChildState, ChildState2} + assert ChildState.get_substates() == {GrandchildState} + assert ChildState2.get_substates() == set() + assert GrandchildState.get_substates() == set() + + +def test_get_name(TestState, ChildState, ChildState2, GrandchildState): + """Test getting the name of a state. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + assert TestState.get_name() == "test_state" + assert ChildState.get_name() == "child_state" + assert ChildState2.get_name() == "child_state2" + assert GrandchildState.get_name() == "grandchild_state" + + +def test_get_full_name(TestState, ChildState, ChildState2, GrandchildState): + """Test getting the full name. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + assert TestState.get_full_name() == "test_state" + assert ChildState.get_full_name() == "test_state.child_state" + assert ChildState2.get_full_name() == "test_state.child_state2" + assert GrandchildState.get_full_name() == "test_state.child_state.grandchild_state" + + +def test_get_class_substate(TestState, ChildState, ChildState2, GrandchildState): + """Test getting the substate of a class. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + assert TestState.get_class_substate(("child_state",)) == ChildState + assert TestState.get_class_substate(("child_state2",)) == ChildState2 + assert ChildState.get_class_substate(("grandchild_state",)) == GrandchildState + assert ( + TestState.get_class_substate(("child_state", "grandchild_state")) + == GrandchildState + ) + with pytest.raises(ValueError): + TestState.get_class_substate(("invalid_child",)) + with pytest.raises(ValueError): + TestState.get_class_substate( + ( + "child_state", + "invalid_child", + ) + ) + + +def test_get_class_var(TestState, ChildState, ChildState2, GrandchildState): + """Test getting the var of a class. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + assert TestState.get_class_var(("num1",)) == TestState.num1 + assert TestState.get_class_var(("num2",)) == TestState.num2 + assert ChildState.get_class_var(("value",)) == ChildState.value + assert GrandchildState.get_class_var(("value2",)) == GrandchildState.value2 + assert TestState.get_class_var(("child_state", "value")) == ChildState.value + assert ( + TestState.get_class_var(("child_state", "grandchild_state", "value2")) + == GrandchildState.value2 + ) + assert ( + ChildState.get_class_var(("grandchild_state", "value2")) + == GrandchildState.value2 + ) + with pytest.raises(ValueError): + TestState.get_class_var(("invalid_var",)) + with pytest.raises(ValueError): + TestState.get_class_var( + ( + "child_state", + "invalid_var", + ) + ) + + +def test_set_class_var(TestState): + """Test setting the var of a class. + + Args: + TestState: The state class. + """ + with pytest.raises(AttributeError): + TestState.num3 + TestState._set_var(BaseVar(name="num3", type_=int).set_state(TestState)) + var = TestState.num3 + assert var.name == "num3" + assert var.type_ == int + assert var.state == TestState.get_full_name() + + +def test_set_parent_and_substates(TestState, ChildState, ChildState2, GrandchildState): + """Test setting the parent and substates. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + test_state = TestState() + assert len(test_state.substates) == 2 + assert set(test_state.substates) == {"child_state", "child_state2"} + + child_state = test_state.substates["child_state"] + assert child_state.parent_state == test_state + assert len(child_state.substates) == 1 + assert set(child_state.substates) == {"grandchild_state"} + + grandchild_state = child_state.substates["grandchild_state"] + assert grandchild_state.parent_state == child_state + assert len(grandchild_state.substates) == 0 + + +def test_get_child_attribute(TestState, ChildState, ChildState2, GrandchildState): + """Test getting the attribute of a state. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + test_state = TestState() + + assert test_state.num1 == 0 + assert test_state.child_state.value == "" + assert test_state.child_state2.value == "" + assert test_state.child_state.count == 23 + assert test_state.child_state.grandchild_state.value2 == "" + with pytest.raises(AttributeError): + test_state.invalid + with pytest.raises(AttributeError): + test_state.child_state.invalid + with pytest.raises(AttributeError): + test_state.child_state.grandchild_state.invalid + + +def test_set_child_attribute(TestState, ChildState, ChildState2, GrandchildState): + """Test setting the attribute of a state. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + test_state = TestState() + child_state = test_state.child_state + grandchild_state = child_state.grandchild_state + + test_state.num1 = 10 + assert test_state.num1 == 10 + test_state.child_state.value = "test" + assert test_state.child_state.value == "test" + assert child_state.value == "test" + + test_state.child_state.grandchild_state.value2 = "test2" + assert test_state.child_state.grandchild_state.value2 == "test2" + assert child_state.grandchild_state.value2 == "test2" + assert grandchild_state.value2 == "test2" + + +def test_get_parent_attribute(TestState, ChildState, ChildState2, GrandchildState): + """Test setting the attribute of a state. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + test_state = TestState() + child_state = test_state.child_state + grandchild_state = child_state.grandchild_state + + assert test_state.num1 == 0 + assert child_state.num1 == 0 + assert grandchild_state.num1 == 0 + + # Changing the parent var should change the child var. + test_state.num1 = 1 + assert test_state.num1 == 1 + assert child_state.num1 == 1 + assert grandchild_state.num1 == 1 + + child_state.value = "test" + assert test_state.child_state.value == "test" + assert child_state.value == "test" + assert grandchild_state.value == "test" + + +def test_set_parent_attribute(TestState, ChildState, ChildState2, GrandchildState): + """Test setting the attribute of a state. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + test_state = TestState() + child_state = test_state.child_state + grandchild_state = child_state.grandchild_state + + # Changing the child var should not change the parent var. + child_state.num1 = 2 + assert child_state.num1 == 2 + assert test_state.num1 == 2 + assert grandchild_state.num1 == 2 + + grandchild_state.num1 = 3 + assert grandchild_state.num1 == 3 + assert child_state.num1 == 3 + assert test_state.num1 == 3 + + grandchild_state.value = "test2" + assert grandchild_state.value == "test2" + assert child_state.value == "test2" + + +def test_get_substate(TestState, ChildState, ChildState2, GrandchildState): + """Test getting the substate of a state. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + test_state = TestState() + child_state = test_state.child_state + grandchild_state = child_state.grandchild_state + + assert test_state.get_substate(("child_state",)) == child_state + assert test_state.get_substate(("child_state2",)) == test_state.child_state2 + assert ( + test_state.get_substate(("child_state", "grandchild_state")) == grandchild_state + ) + assert child_state.get_substate(("grandchild_state",)) == grandchild_state + with pytest.raises(ValueError): + test_state.get_substate(("invalid",)) + with pytest.raises(ValueError): + test_state.get_substate(("child_state", "invalid")) + with pytest.raises(ValueError): + test_state.get_substate(("child_state", "grandchild_state", "invalid")) + + +def test_set_dirty_var(TestState): + """Test changing state vars marks the value as dirty. + + Args: + TestState: The state class. + """ + test_state = TestState() + + # Initially there should be no dirty vars. + assert test_state.dirty_vars == set() + + # Setting a var should mark it as dirty. + test_state.num1 = 1 + assert test_state.dirty_vars == {"num1"} + + # Setting another var should mark it as dirty. + test_state.num2 = 2 + assert test_state.dirty_vars == {"num1", "num2"} + + # Cleaning the state should remove all dirty vars. + test_state.clean() + assert test_state.dirty_vars == set() + + +def test_set_dirty_substate(TestState, ChildState, ChildState2, GrandchildState): + """Test changing substate vars marks the value as dirty. + + Args: + TestState: The state class. + ChildState: The child state class. + ChildState2: The child state class. + GrandchildState: The grandchild state class. + """ + test_state = TestState() + child_state = test_state.child_state + child_state2 = test_state.child_state2 + grandchild_state = child_state.grandchild_state + + # Initially there should be no dirty vars. + assert test_state.dirty_vars == set() + assert child_state.dirty_vars == set() + assert child_state2.dirty_vars == set() + assert grandchild_state.dirty_vars == set() + + # Setting a var should mark it as dirty. + child_state.value = "test" + assert child_state.dirty_vars == {"value"} + assert test_state.dirty_substates == {"child_state"} + assert child_state.dirty_substates == set() + + # Cleaning the parent state should remove the dirty substate. + test_state.clean() + assert test_state.dirty_substates == set() + assert child_state.dirty_vars == set() + + # Setting a var on the grandchild should bubble up. + grandchild_state.value2 = "test2" + assert child_state.dirty_substates == {"grandchild_state"} + assert test_state.dirty_substates == {"child_state"} + + # Cleaning the middle state should keep the parent state dirty. + child_state.clean() + assert test_state.dirty_substates == {"child_state"} + assert child_state.dirty_substates == set() + assert grandchild_state.dirty_vars == set() + + +def test_reset(TestState, ChildState): + """Test resetting the state. + + Args: + TestState: The state class. + ChildState: The child state class. + """ + test_state = TestState() + child_state = test_state.child_state + + # Set some values. + test_state.num1 = 1 + test_state.num2 = 2 + child_state.value = "test" + + # Reset the state. + test_state.reset() + + # The values should be reset. + assert test_state.num1 == 0 + assert test_state.num2 == 3.14 + assert child_state.value == "" + + # The dirty vars should be reset. + assert test_state.dirty_vars == set() + assert child_state.dirty_vars == set() + + # The dirty substates should be reset. + assert test_state.dirty_substates == set() + + +@pytest.mark.asyncio +async def test_process_event_simple(TestState): + """Test processing an event. + + Args: + TestState: The state class. + """ + test_state = TestState() + assert test_state.num1 == 0 + + event = Event(token="t", name="set_num1", payload={"num1": 69}) + delta = await test_state.process(event) + + # The event should update the value. + assert test_state.num1 == 69 + + # The delta should contain the changes, including computed vars. + assert delta == {"test_state": {"num1": 69, "sum": 72.14, "upper": ""}} + + +@pytest.mark.asyncio +async def test_process_event_substate(TestState, ChildState, GrandchildState): + """Test processing an event on a substate. + + Args: + TestState: The state class. + ChildState: The child state class. + GrandchildState: The grandchild state class. + """ + test_state = TestState() + child_state = test_state.child_state + grandchild_state = child_state.grandchild_state + + # Events should bubble down to the substate. + assert child_state.value == "" + assert child_state.count == 23 + event = Event( + token="t", name="child_state.change_both", payload={"value": "hi", "count": 12} + ) + delta = await test_state.process(event) + assert child_state.value == "HI" + assert child_state.count == 24 + assert delta == {"test_state.child_state": {"value": "HI", "count": 24}} + test_state.clean() + + # Test with the granchild state. + assert grandchild_state.value2 == "" + event = Event( + token="t", + name="child_state.grandchild_state.set_value2", + payload={"value2": "new"}, + ) + delta = await test_state.process(event) + assert grandchild_state.value2 == "new" + assert delta == {"test_state.child_state.grandchild_state": {"value2": "new"}} + + +@pytest.mark.asyncio +async def test_process_event_substate_set_parent_state(TestState, ChildState): + """Test setting the parent state on a substate. + + Args: + TestState: The state class. + ChildState: The child state class. + """ + test_state = TestState() + event = Event(token="t", name="child_state.set_num1", payload={"num1": 69}) + delta = await test_state.process(event) + assert test_state.num1 == 69 + assert delta == {"test_state": {"num1": 69, "sum": 72.14, "upper": ""}} diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 000000000..dc8e7d152 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,171 @@ +import pytest + +from pynecone import utils + + +@pytest.mark.parametrize( + "input,output", + [ + ("", ""), + ("hello", "hello"), + ("Hello", "hello"), + ("camelCase", "camel_case"), + ("camelTwoHumps", "camel_two_humps"), + ], +) +def test_to_snake_case(input: str, output: str): + """Test converting strings to snake case. + + Args: + input: The input string. + output: The expected output string. + """ + assert utils.to_snake_case(input) == output + + +@pytest.mark.parametrize( + "input,output", + [ + ("", ""), + ("hello", "hello"), + ("Hello", "Hello"), + ("snake_case", "snakeCase"), + ("snake_case_two", "snakeCaseTwo"), + ], +) +def test_to_camel_case(input: str, output: str): + """Test converting strings to camel case. + + Args: + input: The input string. + output: The expected output string. + """ + assert utils.to_camel_case(input) == output + + +@pytest.mark.parametrize( + "input,output", + [ + ("", ""), + ("hello", "Hello"), + ("Hello", "Hello"), + ("snake_case", "Snake Case"), + ("snake_case_two", "Snake Case Two"), + ], +) +def test_to_title(input: str, output: str): + """Test converting strings to title case. + + Args: + input: The input string. + output: The expected output string. + """ + assert utils.to_title(input) == output + + +@pytest.mark.parametrize( + "input,output", + [ + ("{", "}"), + ("(", ")"), + ("[", "]"), + ("<", ">"), + ('"', '"'), + ("'", "'"), + ], +) +def test_get_close_char(input: str, output: str): + """Test getting the close character for a given open character. + + Args: + input: The open character. + output: The expected close character. + """ + assert utils.get_close_char(input) == output + + +@pytest.mark.parametrize( + "text,open,expected", + [ + ("", "{", False), + ("{wrap}", "{", True), + ("{wrap", "{", False), + ("{wrap}", "(", False), + ("(wrap)", "(", True), + ], +) +def test_is_wrapped(text: str, open: str, expected: bool): + """Test checking if a string is wrapped in the given open and close characters. + + Args: + text: The text to check. + open: The open character. + expected: Whether the text is wrapped. + """ + assert utils.is_wrapped(text, open) == expected + + +@pytest.mark.parametrize( + "text,open,check_first,num,expected", + [ + ("", "{", True, 1, "{}"), + ("wrap", "{", True, 1, "{wrap}"), + ("wrap", "(", True, 1, "(wrap)"), + ("wrap", "(", True, 2, "((wrap))"), + ("(wrap)", "(", True, 1, "(wrap)"), + ("{wrap}", "{", True, 2, "{wrap}"), + ("(wrap)", "{", True, 1, "{(wrap)}"), + ("(wrap)", "(", False, 1, "((wrap))"), + ], +) +def test_wrap(text: str, open: str, expected: str, check_first: bool, num: int): + """Test wrapping a string. + + Args: + text: The text to wrap. + open: The open character. + expected: The expected output string. + check_first: Whether to check if the text is already wrapped. + num: The number of times to wrap the text. + """ + assert utils.wrap(text, open, check_first=check_first, num=num) == expected + + +@pytest.mark.parametrize( + "text,indent_level,expected", + [ + ("", 2, ""), + ("hello", 2, "hello"), + ("hello\nworld", 2, " hello\n world\n"), + ("hello\nworld", 4, " hello\n world\n"), + (" hello\n world", 2, " hello\n world\n"), + ], +) +def test_indent(text: str, indent_level: int, expected: str): + """Test indenting a string. + + Args: + text: The text to indent. + indent_level: The number of spaces to indent by. + expected: The expected output string. + """ + assert utils.indent(text, indent_level) == expected + + +@pytest.mark.parametrize( + "condition,true_value,false_value,expected", + [ + ("cond", "", '""', '{cond ? : ""}'), + ("cond", "", "", "{cond ? : }"), + ], +) +def test_format_cond(condition: str, true_value: str, false_value: str, expected: str): + """Test formatting a cond. + + Args: + condition: The condition to check. + true_value: The value to return if the condition is true. + false_value: The value to return if the condition is false. + expected: The expected output string. + """ + assert utils.format_cond(condition, true_value, false_value) == expected