solara-ui 1.42.0__py2.py3-none-any.whl → 1.44.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. solara/__init__.py +1 -1
  2. solara/__main__.py +12 -7
  3. solara/_stores.py +128 -16
  4. solara/cache.py +6 -4
  5. solara/checks.py +1 -1
  6. solara/components/__init__.py +18 -1
  7. solara/components/datatable.py +4 -4
  8. solara/components/input.py +5 -1
  9. solara/components/markdown.py +46 -10
  10. solara/components/misc.py +2 -2
  11. solara/components/select.py +1 -1
  12. solara/components/style.py +1 -1
  13. solara/hooks/use_reactive.py +16 -1
  14. solara/lab/components/__init__.py +1 -0
  15. solara/lab/components/chat.py +15 -9
  16. solara/lab/components/input_time.py +133 -0
  17. solara/lab/hooks/dataframe.py +1 -0
  18. solara/lab/utils/dataframe.py +11 -1
  19. solara/server/app.py +66 -30
  20. solara/server/flask.py +12 -2
  21. solara/server/jupyter/server_extension.py +1 -0
  22. solara/server/kernel.py +50 -3
  23. solara/server/kernel_context.py +68 -9
  24. solara/server/patch.py +28 -30
  25. solara/server/server.py +16 -6
  26. solara/server/settings.py +11 -0
  27. solara/server/shell.py +19 -1
  28. solara/server/starlette.py +72 -14
  29. solara/server/static/solara_bootstrap.py +1 -1
  30. solara/settings.py +3 -0
  31. solara/tasks.py +30 -9
  32. solara/test/pytest_plugin.py +4 -2
  33. solara/toestand.py +119 -28
  34. solara/util.py +18 -0
  35. solara/website/components/docs.py +24 -1
  36. solara/website/components/markdown.py +17 -3
  37. solara/website/pages/changelog/changelog.md +26 -1
  38. solara/website/pages/documentation/advanced/content/10-howto/20-layout.md +1 -1
  39. solara/website/pages/documentation/advanced/content/20-understanding/50-solara-server.md +10 -0
  40. solara/website/pages/documentation/advanced/content/30-enterprise/10-oauth.md +4 -2
  41. solara/website/pages/documentation/api/routing/route.py +10 -12
  42. solara/website/pages/documentation/api/routing/use_route.py +26 -30
  43. solara/website/pages/documentation/components/advanced/link.py +6 -8
  44. solara/website/pages/documentation/components/advanced/meta.py +6 -9
  45. solara/website/pages/documentation/components/advanced/style.py +7 -9
  46. solara/website/pages/documentation/components/input/file_browser.py +12 -14
  47. solara/website/pages/documentation/components/lab/input_time.py +15 -0
  48. solara/website/pages/documentation/components/lab/theming.py +6 -4
  49. solara/website/pages/documentation/components/layout/columns_responsive.py +37 -39
  50. solara/website/pages/documentation/components/layout/gridfixed.py +4 -6
  51. solara/website/pages/documentation/components/output/html.py +1 -3
  52. solara/website/pages/documentation/components/output/sql_code.py +23 -25
  53. solara/website/pages/documentation/components/page/head.py +4 -7
  54. solara/website/pages/documentation/components/page/title.py +12 -14
  55. solara/website/pages/documentation/components/status/error.py +17 -18
  56. solara/website/pages/documentation/components/status/info.py +17 -18
  57. solara/website/pages/documentation/examples/__init__.py +10 -0
  58. solara/website/pages/documentation/examples/ai/chatbot.py +62 -44
  59. solara/website/pages/documentation/examples/general/live_update.py +22 -28
  60. solara/website/pages/documentation/examples/general/pokemon_search.py +1 -1
  61. solara/website/pages/documentation/faq/content/99-faq.md +9 -0
  62. solara/website/pages/documentation/getting_started/content/00-quickstart.md +1 -1
  63. solara/website/pages/documentation/getting_started/content/04-tutorials/_jupyter_dashboard_1.ipynb +23 -19
  64. solara/website/pages/documentation/getting_started/content/07-deploying/10-self-hosted.md +2 -2
  65. solara/website/pages/roadmap/roadmap.md +3 -0
  66. {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/METADATA +2 -2
  67. {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/RECORD +71 -69
  68. {solara_ui-1.42.0.data → solara_ui-1.44.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
  69. {solara_ui-1.42.0.data → solara_ui-1.44.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
  70. {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/WHEEL +0 -0
  71. {solara_ui-1.42.0.dist-info → solara_ui-1.44.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,9 +5,10 @@ A way to create a chatbot using OpenAI's GPT-4 API, utilizing their new API, and
5
5
  """
6
6
 
7
7
  import os
8
- from typing import List
8
+ from typing import List, cast
9
9
 
10
- from openai import OpenAI
10
+ from openai import AsyncOpenAI
11
+ from openai.types.chat import ChatCompletionMessageParam
11
12
  from typing_extensions import TypedDict
12
13
 
13
14
  import solara
@@ -15,18 +16,33 @@ import solara.lab
15
16
 
16
17
 
17
18
  class MessageDict(TypedDict):
18
- role: str
19
+ role: str # "user" or "assistant"
19
20
  content: str
20
21
 
21
22
 
22
- if os.getenv("OPENAI_API_KEY") is None and "OPENAI_API_KEY" not in os.environ:
23
- openai = None
24
- else:
25
- openai = OpenAI()
26
- openai.api_key = os.getenv("OPENAI_API_KEY") # type: ignore
27
-
28
23
  messages: solara.Reactive[List[MessageDict]] = solara.reactive([])
29
24
 
25
+ try:
26
+ import pycafe
27
+
28
+ OPENAI_API_KEY = pycafe.get_secret(
29
+ "OPENAI_API_KEY",
30
+ """We need an OpenAI API key to generate text.
31
+
32
+ Go to [OpenAI](https://platform.openai.com/account/api-keys) to get one.
33
+
34
+ Or read [this](https://www.rebelmouse.com/openai-account-set-up) article for
35
+ more information.
36
+
37
+ Or read more [about secrets on PyCafe](/docs/secrets)
38
+
39
+ """,
40
+ )
41
+ except ModuleNotFoundError:
42
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
43
+
44
+ openai = AsyncOpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
45
+
30
46
 
31
47
  def no_api_key_message():
32
48
  messages.value = [
@@ -37,45 +53,44 @@ def no_api_key_message():
37
53
  ]
38
54
 
39
55
 
40
- def add_chunk_to_ai_message(chunk: str):
56
+ @solara.lab.task
57
+ async def promt_ai(message: str):
58
+ if openai is None:
59
+ no_api_key_message()
60
+ return
61
+
41
62
  messages.value = [
42
- *messages.value[:-1],
43
- {
44
- "role": "assistant",
45
- "content": messages.value[-1]["content"] + chunk,
46
- },
63
+ *messages.value,
64
+ {"role": "user", "content": message},
47
65
  ]
66
+ # The part below can be replaced with a call to your own
67
+ response = await openai.chat.completions.create(
68
+ model="gpt-4-1106-preview",
69
+ # our MessageDict is compatible with the OpenAI types
70
+ messages=cast(List[ChatCompletionMessageParam], messages.value),
71
+ stream=True,
72
+ )
73
+ # start with an empty reply message, so we render and empty message in the chat
74
+ # while the AI is thinking
75
+ messages.value = [*messages.value, {"role": "assistant", "content": ""}]
76
+ # and update it with the response
77
+ async for chunk in response:
78
+ if chunk.choices[0].finish_reason == "stop": # type: ignore
79
+ return
80
+ # replace the last message element with the appended content
81
+ delta = chunk.choices[0].delta.content
82
+ assert delta is not None
83
+ updated_message: MessageDict = {
84
+ "role": "assistant",
85
+ "content": messages.value[-1]["content"] + delta,
86
+ }
87
+ # replace the last message element with the appended content
88
+ # which will update the UI
89
+ messages.value = [*messages.value[:-1], updated_message]
48
90
 
49
91
 
50
92
  @solara.component
51
93
  def Page():
52
- user_message_count = len([m for m in messages.value if m["role"] == "user"])
53
-
54
- def send(message):
55
- messages.value = [
56
- *messages.value,
57
- {"role": "user", "content": message},
58
- ]
59
-
60
- def call_openai():
61
- if user_message_count == 0:
62
- return
63
- if openai is None:
64
- no_api_key_message()
65
- return
66
- response = openai.chat.completions.create(
67
- model="gpt-4-1106-preview",
68
- messages=messages.value, # type: ignore
69
- stream=True,
70
- )
71
- messages.value = [*messages.value, {"role": "assistant", "content": ""}]
72
- for chunk in response:
73
- if chunk.choices[0].finish_reason == "stop": # type: ignore
74
- return
75
- add_chunk_to_ai_message(chunk.choices[0].delta.content) # type: ignore
76
-
77
- task = solara.lab.use_task(call_openai, dependencies=[user_message_count]) # type: ignore
78
-
79
94
  with solara.Column(
80
95
  style={"width": "100%", "height": "50vh"},
81
96
  ):
@@ -90,6 +105,9 @@ def Page():
90
105
  border_radius="20px",
91
106
  ):
92
107
  solara.Markdown(item["content"])
93
- if task.pending:
108
+ if promt_ai.pending:
94
109
  solara.Text("I'm thinking...", style={"font-size": "1rem", "padding-left": "20px"})
95
- solara.lab.ChatInput(send_callback=send, disabled=task.pending)
110
+ solara.ProgressLinear()
111
+ # if we don't call .key(..) with a unique key, the ChatInput component will be re-created
112
+ # and we'll lose what we typed.
113
+ solara.lab.ChatInput(send_callback=promt_ai, disabled_send=promt_ai.pending, autofocus=True).key("input")
@@ -1,38 +1,32 @@
1
- from time import sleep
2
-
3
- import numpy as np
4
- from matplotlib import pyplot as plt
5
-
1
+ from typing import cast, Optional
2
+ import httpx
3
+ import asyncio
6
4
  import solara
5
+ import solara.lab
7
6
 
8
7
 
9
8
  @solara.component
10
9
  def Page():
11
- # define some state which will be updated regularly in a separate thread
12
- counter = solara.use_reactive(0)
10
+ btc = solara.use_reactive(cast(Optional[float], None))
13
11
 
14
- def render():
15
- """Infinite loop regularly mutating counter state"""
12
+ async def fetch_btc_price():
16
13
  while True:
17
- sleep(0.2)
18
- counter.value += 1
19
-
20
- # run the render loop in a separate thread
21
- result: solara.Result[bool] = solara.use_thread(render)
22
- if result.error:
23
- raise result.error
24
-
25
- # create the LiveUpdatingComponent, this component depends on the counter
26
- # value so will be redrawn whenever counter value changes
27
- LiveUpdatingComponent(counter.value)
28
-
29
-
30
- @solara.component
31
- def LiveUpdatingComponent(counter):
32
- """Component which will be redrawn whenever the counter value changes."""
33
- fig, ax = plt.subplots()
34
- ax.plot(np.arange(10), np.random.random(10))
35
- solara.FigureMatplotlib(fig)
14
+ await asyncio.sleep(1)
15
+ async with httpx.AsyncClient() as client:
16
+ url = "https://api.binance.com/api/v1/ticker/price?symbol=BTCUSDT"
17
+ response = await client.get(url)
18
+ btc.value = float(response.json()["price"])
19
+ print("btc.value", btc.value)
20
+
21
+ fetch_result = solara.lab.use_task(fetch_btc_price, dependencies=[])
22
+ # the task keeps running, so is always in the pending mode, so we combine it with the btc value being None
23
+ if fetch_result.pending and btc.value is None:
24
+ solara.Text("Fetching BTC price...")
25
+ else:
26
+ if fetch_result.error:
27
+ solara.Error(f"Error fetching BTC price: {fetch_result.exception}")
28
+ else:
29
+ solara.Text(f"BTC price: ${btc.value}")
36
30
 
37
31
 
38
32
  Page()
@@ -40,7 +40,7 @@ def Page():
40
40
  for pokemon in pokemons[:20]:
41
41
  with solara.Div():
42
42
  name = pokemon["name"]
43
- url = f'{pokemon_base_url}/{pokemon["image"]}'
43
+ url = f"{pokemon_base_url}/{pokemon['image']}"
44
44
  # TODO: how to do this with solara
45
45
  rv.Img(src=url, contain=True, max_height="200px")
46
46
  solara.Text(name)
@@ -101,3 +101,12 @@ If you upgrade from an older version to 1.30 or later, the order in which pip in
101
101
  ```bash
102
102
  $ pip install solara-server --force-reinstall
103
103
  ```
104
+
105
+ # I see an error in the browser console
106
+
107
+ If you see an error like this in the browser console:
108
+ ```
109
+ Uncaught Error: Script error for "base/js/namespace", needed by: /jupyter/nbextensions/dash/main.js
110
+ ```
111
+
112
+ See [ignoring notebook extensions](/documentation/advanced/understanding/solara-server#ignoring-notebook-extensions) for more information.
@@ -94,7 +94,7 @@ Or the more modern Jupyter lab:
94
94
  You can also run the script as a standalone app. This requires the extra packages `qtpy` and `PySide6` (or `PyQt6`) to be installed.
95
95
 
96
96
  ```bash
97
- $ pip install pip install qtpy PySide6
97
+ $ pip install qtpy PySide6
98
98
  ```
99
99
 
100
100
  Run from the command line in the same directory where you put your file (`sol.py`):
@@ -7,17 +7,19 @@
7
7
  "source": [
8
8
  "# Build your Jupyter dashboard using Solara\n",
9
9
  "\n",
10
- "Welcome to the first part of a series of tutorials that will show you how to create a dashboard in Jupyter and deploy it as a standalone web app. Importantly, there will be no need to rewrite your app in a different framework, no need to use a non-Python solution, and no need to use JavaScript or CSS.\n",
10
+ "Welcome to the first part of a series of tutorials showing you how to create a dashboard in Jupyter and deploy it as a standalone web app. Importantly, you won't need to rewrite your app in a different framework for deployment. We will use a pure Python solution with no JavaScript or CSS required.\n",
11
11
  "\n",
12
- "Jupyter notebooks are an incredible tool for data analysis, since they enable blending code, visualization and narrative into a single document.\n",
13
- "However, if the insights need to be presented to a non-technical audience, we usually do not want to show the code.\n",
12
+ "Jupyter notebooks are an incredible data analysis tool since they blend code, visualization, and narrative into a single document. However, we do not want to show the code if the insights must be presented to a non-technical audience.\n",
13
+ "\n",
14
+ "\n",
15
+ "Built on top of ipywidgets, the Solara framework integrates well into the Jupyter notebook, Jupyter lab as well as other Jupyter environments, and as we will see in a later article, can be deployed efficiently using the Solara server. This, by itself, makes Solara a perfect solution for creating dashboards or data apps.\n",
14
16
  "\n",
15
17
  "In this tutorial, we will create a simple dashboard using Solara's UI components. The final product will allow an end-user to filter,\n",
16
18
  "visualize and explore a dataset on a map.\n",
17
19
  "\n",
18
20
  "![image](https://dxhl76zpt6fap.cloudfront.net/public/docs/tutorial/jupyter-dashboard1.webp)\n",
19
21
  "\n",
20
- "## Pre-requisits \n",
22
+ "## Pre-requisites \n",
21
23
  "\n",
22
24
  "You need to install `pandas`, `matplotlib`, `folium` and `solara`. Assuming you are using pip, you can execute on your shell:\n",
23
25
  "\n",
@@ -43,7 +45,7 @@
43
45
  "id": "6cc6256a",
44
46
  "metadata": {},
45
47
  "source": [
46
- "The first thing we do when we read in the data is to print it out, to see what the dataset contains."
48
+ "The first thing we do when we read in the data is to print it out to see what the dataset contains."
47
49
  ]
48
50
  },
49
51
  {
@@ -365,7 +367,7 @@
365
367
  "id": "08a9644a",
366
368
  "metadata": {},
367
369
  "source": [
368
- "The data looks clean but since we will work with the `Category` and `PdDistrict` column data, lets convert those columns to title case."
370
+ "The data looks clean, but since we will work with the `Category` and `PdDistrict` column data, let us convert those columns to title case."
369
371
  ]
370
372
  },
371
373
  {
@@ -700,7 +702,7 @@
700
702
  "id": "b0e37cb4",
701
703
  "metadata": {},
702
704
  "source": [
703
- "Now, with our filtered dataset, we create two barcharts. We use regular pandas and matplotlib, but seaborn or plotly would also have been appropriate choices."
705
+ "Now, with our filtered dataset, we create two bar charts. We use regular Pandas and Matplotlib, but Seaborn or Plotly would also be appropriate choices."
704
706
  ]
705
707
  },
706
708
  {
@@ -750,9 +752,9 @@
750
752
  "id": "0e71ff2f",
751
753
  "metadata": {},
752
754
  "source": [
753
- "Since we do not need bi-directional communication (e.g. we do not need to receive events or data from our map), we use folium to display the locations of the committed crimes on a map. If we do need bi-directional communication, we can also decide to use [ipyleaflet](https://ipyleaflet.readthedocs.io/en/latest/usage/index.html).\n",
755
+ "Since we do not need bidirectional communication (e.g., we do not need to receive events or data from our map), we use Folium to display the locations of the committed crimes on a map. If we do need bidirectional communication, we can also decide to use [ipyleaflet](https://ipyleaflet.readthedocs.io/).\n",
754
756
  "\n",
755
- "We cannot display all the data on the map without crashing your browser, so we limit to a maximum of a 50 points."
757
+ "Since we cannot display all the data on the map without crashing your browser, we limit it to a maximum of 50 points."
756
758
  ]
757
759
  },
758
760
  {
@@ -800,9 +802,9 @@
800
802
  "source": [
801
803
  "## Making our first reactive visualization\n",
802
804
  "\n",
803
- "The above code works nicely, but if we want to explore different types of crimes, we need to manually modify and run all cells that determine out output. Would it not be much better to have a UI with controls that determine the filtering, and a view that displays the filtered data interactively?\n",
805
+ "The above code works nicely, but if we want to explore different types of crimes, we need to modify and run all cells that determine our output manually. Would it not be much better to have a UI with controls determining the filtering and a view displaying the filtered data interactively?\n",
804
806
  "\n",
805
- "Lets start by importing the solara package, and create three reactive variables."
807
+ "Let's start by importing the solara package and creating three reactive variables."
806
808
  ]
807
809
  },
808
810
  {
@@ -814,9 +816,7 @@
814
816
  "source": [
815
817
  "import solara\n",
816
818
  "\n",
817
- "districts = solara.reactive(\n",
818
- " [\"Bayview\", \"Northern\"],\n",
819
- ")\n",
819
+ "districts = solara.reactive([\"Bayview\", \"Northern\"])\n",
820
820
  "categories = solara.reactive([\"Vandalism\", \"Assault\", \"Robbery\"])\n",
821
821
  "limit = solara.reactive(100)"
822
822
  ]
@@ -826,9 +826,9 @@
826
826
  "id": "28622c20",
827
827
  "metadata": {},
828
828
  "source": [
829
- " A reactive variable is a container around a value (like a int, string or list) that allows the UI to automatically listen to changes. Any change to `your_reactive_variable.value` will be picked up by solara component that use them, so that they can automatically redraw or update itself.\n",
829
+ "A reactive variable is a container around a value (like an int, string, or list) that allows the UI to listen to changes automatically. Any change to your_reactive_variable.value will be picked up by Solara components that use them so that they can automatically redraw or update themselves.\n",
830
830
  "\n",
831
- " We now create our first component (`View`) which filters the data (based on the reactive variables), and shows the map and the charts. Solara supports the `display` mechanism of Jupyter, so we can simply use our previously defined functions."
831
+ "Let us now create our first component (View), which filters the data based on the reactive variables and shows the map and the charts. Solara supports the display mechanism of Jupyter so that we can use our previously defined functions."
832
832
  ]
833
833
  },
834
834
  {
@@ -859,7 +859,9 @@
859
859
  "id": "0b05c1db",
860
860
  "metadata": {},
861
861
  "source": [
862
- "Note that some of the code (like the warning and the charts) are conditional. Solara will automatically find out what to add, remove or update without you having to do this manually. Solara is declarative (similar to ReactJS), but also reactive. If we change the reactive variables, Solara sees that changes and notifies the component instances that use its value. After executing the next lines of code, our `View` will automatically update."
862
+ "Note that some UI parts (like the warning and the charts) are conditional. Solara will automatically find out what to add, remove, or update without you having to do this manually. Solara is declarative (similar to ReactJS) but also reactive. If we change the reactive variables, Solara will see those changes and notify the component instances that use its value.\n",
863
+ "\n",
864
+ "If we run the next lines of code in our notebook, our View will automatically update."
863
865
  ]
864
866
  },
865
867
  {
@@ -878,7 +880,9 @@
878
880
  "id": "8822d100",
879
881
  "metadata": {},
880
882
  "source": [
881
- "We can now explore out data much faster, since we don't need to re-run the cells that depended on it. "
883
+ "We can now explore our data much faster since we don't need to re-run the cells that depend on it.\n",
884
+ "\n",
885
+ "Solara's reactive and declarative nature makes it scalable to much larger applications than regular ipywidgets, where keeping the UI in sync and adding, removing, and updating widgets is a manual and bug-prone process."
882
886
  ]
883
887
  },
884
888
  {
@@ -888,7 +892,7 @@
888
892
  "source": [
889
893
  "## Adding controls\n",
890
894
  "\n",
891
- "We created a mini app in our notebook that is declarative *and* reactive, but we still need to manually modify the values by executing a code cell, while we promised a UI to control it. Luckily, all Solara input components supports reactive variables. This means that controlling a reactive variable using a UI element is often a one-liner."
895
+ "We created a declarative and reactive mini app in our notebook, but we still need to manually modify the values by executing a code cell in our Notebook. Now, let us create a UI to control it. All Solara input components support reactive variables. This means that controlling a reactive variable using a UI element is often a one-liner."
892
896
  ]
893
897
  },
894
898
  {
@@ -5,8 +5,8 @@ description: Solara is compatible with many different hosting solutions, such as
5
5
  # Self hosted deployment
6
6
 
7
7
  * [Flask](#flask)
8
- * [Starlette](#flask)
9
- * [FastAPI](#flask)
8
+ * [Starlette](#starlette)
9
+ * [FastAPI](#fastapi)
10
10
  * [Voila](#voila)
11
11
  * [Panel](#panel)
12
12
  * [Nginx](#nginx)
@@ -13,6 +13,9 @@ Exciting news! We aim to release Solara 2.0 by the end of the year. For the 2.0
13
13
 
14
14
  State mutation detection will be the default for Solara 2.0, but can be enabled in Solara > 1.41.0 by setting the environment variable `SOLARA_STORAGE_MUTATION_DETECTION=1`.
15
15
 
16
+ Using reactive variables in boolean comparisons will raise an error in Solara 2.0, but this can be used in Solara > 1.42.0 by setting the environment variable `SOLARA_ALLOW_REACTIVE_BOOLEAN=0`.
17
+
18
+ In Solara 2.0, `reacton.Fragment` will be used as the default container of any unwrapped sibling elements. This behaviour can be enabled in Solara > 1.42.0 by setting the environmental variable `SOLARA_DEFAULT_CONTAINER="Fragment"`.
16
19
 
17
20
  - [See more details in the 2.0 milestone on GitHub.](https://github.com/widgetti/solara/milestone/1)
18
21
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: solara-ui
3
- Version: 1.42.0
3
+ Version: 1.44.0
4
4
  Dynamic: Summary
5
5
  Project-URL: Home, https://www.github.com/widgetti/solara
6
6
  Project-URL: Documentation, https://solara.dev
@@ -31,7 +31,7 @@ Requires-Dist: humanize
31
31
  Requires-Dist: ipyvue>=1.9.0
32
32
  Requires-Dist: ipyvuetify>=1.6.10
33
33
  Requires-Dist: ipywidgets>=7.7
34
- Requires-Dist: reacton>=1.7.1
34
+ Requires-Dist: reacton>=1.9
35
35
  Requires-Dist: requests
36
36
  Provides-Extra: all
37
37
  Requires-Dist: cachetools; extra == 'all'