diff --git a/.github/workflows/ploomber-cloud.yaml b/.github/workflows/ploomber-cloud.yaml new file mode 100644 index 0000000..3a09b7a --- /dev/null +++ b/.github/workflows/ploomber-cloud.yaml @@ -0,0 +1,31 @@ +name: Ploomber Cloud + +on: + push: + branches: + # only deploy from the ploomber branch + - ploomber + +jobs: + deploy-to-ploomber-cloud: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ploomber-cloud wheel + mkdir -p ploomber/wheels + (hatch build && cp dist/*.whl ploomber/wheels) + + - name: Deploy + env: + PLOOMBER_CLOUD_KEY: ${{ secrets.PLOOMBER_CLOUD_KEY }} + run: | + (cd ploomber && ploomber-cloud deploy) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4be276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# developer specific ignores should go to ~/.gitignore like editor specific files +.DS_Store +__pycache__ +**/*.pyc +dist/ +build/ +ploomber/wheels diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a244140 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 widgetti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b45d24a --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ + + +``` +pip install -e . +``` + +# Deploy manually + +See https://docs.cloud.ploomber.io/en/latest/user-guide/cli.html for more details + +``` +$ pip install ploomber-cloud +$ mkdir -p ploomber/wheels +$ ploomber-cloud key YOURKEY +$ (cd ploomber && ploomber-cloud init) +(type y) +# build the wheel +$ (hatch build && cp dist/*.whl ploomber/wheels) +$ (cd ploomber && ploomber-cloud deploy) +``` + +# Deploy via Github Actions + +[Get your Ploomber API key](https://docs.cloud.ploomber.io/en/latest/quickstart/apikey.html) and set it as `PLOOMBER_CLOUD_KEY` in GitHub (under Settings->Secrets and Variables->Actions, and click "New repository secret") + + +## Run only once +``` +$ ploomber-cloud key YOURKEY +$ (cd ploomber && ploomber-cloud init) +(add to git and commit) +$ git add ploomber/ploomber-cloud.yaml +$ git commit -m "ci: set ploomber id" +$ git push +``` + + +## Run to deploy a new version +``` +$ git push master:ploomber +``` diff --git a/ploomber/Dockerfile b/ploomber/Dockerfile new file mode 100644 index 0000000..9097788 --- /dev/null +++ b/ploomber/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11 + +RUN pip install --no-cache-dir --upgrade pip +RUN mkdir wheels +COPY dist/*.whl wheels +RUN pip install wheels/*.whl + +ENTRYPOINT ["solara", "run", "solarathon.pages", "--host=0.0.0.0", "--port=80"] diff --git a/ploomber/ploomber-cloud.json b/ploomber/ploomber-cloud.json new file mode 100644 index 0000000..183f4fd --- /dev/null +++ b/ploomber/ploomber-cloud.json @@ -0,0 +1,4 @@ +{ + "id": "raspy-term-0543", + "type": "docker" +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c637662 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "solarathon" +description = "Template project for Solarathon 2023" +version = "0.0.1" +dependencies = [ + "solara==1.23.0", +] + +[project.optional-dependencies] +dev = [ + "mypy", +] + +[tool.ruff] +ignore-init-module-imports = true +fix = true +exclude = [ + '.git', + 'dist', + '.eggs', +] +ignore = [ + "E501", # line too long | Black take care of it +] +line-length = 160 +select = ["E", "W", "F", "Q", "I"] diff --git a/solarathon/assets/custom.css b/solarathon/assets/custom.css new file mode 100644 index 0000000..89f8566 --- /dev/null +++ b/solarathon/assets/custom.css @@ -0,0 +1,10 @@ +/* Example of how to change the color of the label in a v-input */ + +.v-input label.v-label { + color: pink; + } + + /* workaround for an issue in solara */ + .solara-autorouter-content { + height: 100%; + } diff --git a/solarathon/components/__init__.py b/solarathon/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solarathon/components/chat.py b/solarathon/components/chat.py new file mode 100644 index 0000000..8c2bca7 --- /dev/null +++ b/solarathon/components/chat.py @@ -0,0 +1,153 @@ +import uuid +from typing import Callable, List, Literal, Optional, Union + +import solara +from solara.components.input import use_change + + +@solara.component +def ChatMessage( + children: Union[List[solara.Element], str], + user: bool = False, + avatar: Union[solara.Element, str, Literal[False], None] = None, + name: Optional[str] = None, + color: Optional[str] = "rgba(0,0,0,.06)", + avatar_background_color: Optional[str] = None, + border_radius: Optional[str] = None, + notch: bool = False, +): + msg_uuid = solara.use_memo(lambda: str(uuid.uuid4()), dependencies=[]) + with solara.Row( + justify="end" if user else "start", + style={"flex-direction": "row-reverse" if user else "row", "padding": "5px"}, + ): + if avatar is not False: + with solara.v.Avatar(color=avatar_background_color if avatar_background_color is not None else color): + if avatar is None and name is not None: + initials = "".join([word[:1] for word in name.split(" ")]) + solara.HTML(tag="span", unsafe_innerHTML=initials, classes=["headline"]) + elif isinstance(avatar, solara.Element): + solara.display(avatar) + elif isinstance(avatar, str) and avatar.startswith("mdi-"): + solara.v.Icon(children=[avatar]) + else: + solara.HTML(tag="img", attributes={"src": avatar, "width": "100%"}) + with solara.Column( + classes=["chat-message-" + msg_uuid, "right" if user else "left"], + gap=0, + style="border-radius: " + + (border_radius if border_radius is not None else "") + + "; border-top-" + + ("right" if user else "left") + + "-radius: 0; padding: .5em 1.5em;", + ): + if name is not None: + solara.Text(name, style="font-weight: bold;", classes=["message-name", "right" if user else "left"]) + solara.display(*children) + solara.Style( + ".chat-message-" + + msg_uuid + + "{" + + "--color:" + + color + + ";" + + """ + max-width: 75%; + position: relative; + }""" + + ".chat-message-" + + msg_uuid + + """.left{ + border-top-left-radius: 0; + background-color:var(--color); + }""" + + ".chat-message-" + + msg_uuid + + """.right{ + border-top-right-radius: 0; + background-color:var(--color); + }""" + ) + if notch: + solara.Style( + ".chat-message-" + + msg_uuid + + """.right{ + margin-right: 10px !important; + } + .chat-message-""" + + msg_uuid + + """.left{ + margin-left: 10px !important; + } + .chat-message-""" + + msg_uuid + + """:before{ + content: ''; + position: absolute; + width: 0; + height: 0; + border: 6px solid; + top: 0; + }""" + + ".chat-message-" + + msg_uuid + + """.left:before{ + left: -12px; + border-color: var(--color) var(--color) transparent transparent; + }""" + + ".chat-message-" + + msg_uuid + + """.right:before{ + right: -12px; + border-color: var(--color) transparent transparent var(--color); + } + """ + ) + + +@solara.component +def ChatBox(children: List[solara.Element] = []): + children_with_key = [] + for i, child in enumerate(children): + children_with_key.append(children[i].key("chat-message-" + str(i))) + solara.Column(style={"flex-grow": "1", "flex-direction": "column-reverse", "overflow-y": "auto"}, classes=["chat-box"], children=list(reversed(children_with_key))) + + +@solara.component +def ChatInfo(children: List[solara.Element] = []): + with solara.Row(style={"min-height": "1em"}): + if children != []: + solara.display(*children) + + +@solara.component +def ChatInput( + send_callback: Optional[Callable] = None, + disabled: bool = False, +): + message, set_message = solara.use_state("") # type: ignore + + with solara.Row(style={"align-items": "center"}): + + def send(*ignore_args): + if message != "" and send_callback is not None: + send_callback(message) + set_message("") + + message_input = solara.v.TextField( + label="Type a message...", + v_model=message, + on_v_model=set_message, + rounded=True, + filled=True, + hide_details=True, + style_="flex-grow: 1;", + disabled=disabled, + ) + + use_change(message_input, send, update_events=["keyup.enter"]) + + button = solara.v.Btn(color="primary", icon=True, children=[solara.v.Icon(children=["mdi-send"])], disabled=message == "") + + use_change(button, send, update_events=["click"]) diff --git a/solarathon/pages/__init__.py b/solarathon/pages/__init__.py new file mode 100644 index 0000000..2056f06 --- /dev/null +++ b/solarathon/pages/__init__.py @@ -0,0 +1,28 @@ +import solara + +# Declare reactive variables at the top level. Components using these variables +# will be re-executed when their values change. +sentence = solara.reactive("Solara makes our team more productive.") +word_limit = solara.reactive(10) + + +# in case you want to override the default order of the tabs +route_order = ["/", "settings", "chat", "clickbutton"] + +@solara.component +def Page(): + with solara.Column(style={"padding-top": "30px"}): + solara.Title("Solarathon example project") + # Calculate word_count within the component to ensure re-execution when reactive variables change. + word_count = len(sentence.value.split()) + + solara.SliderInt("Word limit", value=word_limit, min=2, max=20) + solara.InputText(label="Your sentence", value=sentence, continuous_update=True) + + # Display messages based on the current word count and word limit. + if word_count >= int(word_limit.value): + solara.Error(f"With {word_count} words, you passed the word limit of {word_limit.value}.") + elif word_count >= int(0.8 * word_limit.value): + solara.Warning(f"With {word_count} words, you are close to the word limit of {word_limit.value}.") + else: + solara.Success("Great short writing!") diff --git a/solarathon/pages/chat.py b/solarathon/pages/chat.py new file mode 100644 index 0000000..44301e3 --- /dev/null +++ b/solarathon/pages/chat.py @@ -0,0 +1,53 @@ +import solara +import time +from solarathon.components import chat +import typing + +class Message(typing.TypedDict): + user: bool + name: str + message: str + +messages = solara.reactive([]) +name = solara.reactive("User") + +@solara.component +def Page(): + def add_message(new_message): + messages.set([ + *messages.value, + {"user": True, "name": name.value, "message": new_message,}, + ]) + + def bot_response(): + # only respond if the last message was from the user + if len(messages.value) == 0: + print("no messages") + return + if not messages.value[-1]["user"]: + print("I don't reply to myself") + return + time.sleep(2) + messages.set([ + *messages.value, + {"user": False, "name": "Bot", "message": "Hello, " + name.value + " I cannot help you.",}, + ]) + + print(messages.value) + thread_result = solara.use_thread(bot_response, dependencies=[messages.value]) + with solara.Column(style={"height": "100%"}): + # Note that we make this title component a child of the column, so that it does not interfere + # with the height 100% flow + solara.Title("Chat with a bot") + solara.InputText("username", value=name) + with chat.ChatBox(): + for item in messages.value: + with chat.ChatMessage( + user=item["user"], + name=item["name"], + ): + solara.Markdown(item["message"]) + chat.ChatInput(send_callback=add_message) + solara.ProgressLinear(thread_result.state == solara.ResultState.RUNNING) + if thread_result.error: + solara.Error(str(thread_result.error)) diff --git a/solarathon/pages/clickbutton.py b/solarathon/pages/clickbutton.py new file mode 100644 index 0000000..e9582e3 --- /dev/null +++ b/solarathon/pages/clickbutton.py @@ -0,0 +1,16 @@ +import solara + + + +@solara.component +def Page(): + clicks = solara.use_reactive(0) + color = "green" + if clicks.value >= 5: + color = "red" + + def increment(): + clicks.value += 1 + print("clicks", clicks) # noqa + + solara.Button(label=f"Clicked: {clicks}", on_click=increment, color=color) diff --git a/solarathon/pages/settings.py b/solarathon/pages/settings.py new file mode 100644 index 0000000..ab2a7d4 --- /dev/null +++ b/solarathon/pages/settings.py @@ -0,0 +1,9 @@ +import solara +import solarathon.pages + + +@solara.component +def Page(): + # make the slider not go under the tabs + with solara.Column(style={"padding-top": "30px"}): + solara.SliderInt("Word limit", value=solarathon.pages.word_limit, min=2, max=20)