pyweft 0.2.1__tar.gz → 0.3.0__tar.gz

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 (63) hide show
  1. {pyweft-0.2.1 → pyweft-0.3.0}/.gitignore +1 -0
  2. {pyweft-0.2.1 → pyweft-0.3.0}/CHANGELOG.md +16 -0
  3. {pyweft-0.2.1 → pyweft-0.3.0}/PKG-INFO +23 -3
  4. {pyweft-0.2.1 → pyweft-0.3.0}/README.md +21 -1
  5. {pyweft-0.2.1 → pyweft-0.3.0}/docs/getting-started.md +112 -0
  6. {pyweft-0.2.1 → pyweft-0.3.0}/pyproject.toml +9 -1
  7. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/__init__.py +4 -2
  8. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/cli.py +21 -0
  9. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/desktop.py +36 -7
  10. pyweft-0.3.0/src/pyweft/http.py +225 -0
  11. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/static/shell.html +6 -0
  12. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/static/worker.js +3 -0
  13. pyweft-0.3.0/src/pyweft/storage.py +164 -0
  14. pyweft-0.3.0/src/pyweft/webhost.py +88 -0
  15. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_desktop.py +45 -0
  16. pyweft-0.3.0/tests/test_http.py +185 -0
  17. pyweft-0.3.0/tests/test_storage.py +125 -0
  18. {pyweft-0.2.1 → pyweft-0.3.0}/LICENSE +0 -0
  19. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/__main__.py +0 -0
  20. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/background.py +0 -0
  21. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/binding.py +0 -0
  22. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/component.py +0 -0
  23. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/dialogs.py +0 -0
  24. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/differ.py +0 -0
  25. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/dommodel.py +0 -0
  26. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/effects.py +0 -0
  27. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/events.py +0 -0
  28. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/expressions.py +0 -0
  29. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/hotreload.py +0 -0
  30. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/packaging.py +0 -0
  31. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/parser.py +0 -0
  32. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/prerender.py +0 -0
  33. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/renderer.py +0 -0
  34. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/routing.py +0 -0
  35. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/static/loading.html +0 -0
  36. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/static/runtime.js +0 -0
  37. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/store.py +0 -0
  38. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/styles.py +0 -0
  39. {pyweft-0.2.1 → pyweft-0.3.0}/src/pyweft/vdom.py +0 -0
  40. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_app_settings.py +0 -0
  41. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_async.py +0 -0
  42. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_background.py +0 -0
  43. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_cli.py +0 -0
  44. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_components_kit.py +0 -0
  45. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_compose.py +0 -0
  46. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_dialogs.py +0 -0
  47. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_differ.py +0 -0
  48. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_dynamic.py +0 -0
  49. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_effects.py +0 -0
  50. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_events.py +0 -0
  51. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_focus.py +0 -0
  52. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_folders.py +0 -0
  53. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_hotreload.py +0 -0
  54. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_inline_handlers.py +0 -0
  55. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_invalidate.py +0 -0
  56. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_libraries.py +0 -0
  57. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_packaging.py +0 -0
  58. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_parser.py +0 -0
  59. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_prerender.py +0 -0
  60. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_renderer.py +0 -0
  61. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_routing.py +0 -0
  62. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_store.py +0 -0
  63. {pyweft-0.2.1 → pyweft-0.3.0}/tests/test_styles.py +0 -0
@@ -8,3 +8,4 @@ dist-desktop/
8
8
  .venv/
9
9
  CLAUDE.md
10
10
  PROMPTS.md
11
+ website/_build/
@@ -8,6 +8,22 @@ releasing moves them under the new version (see [RELEASING.md](RELEASING.md)).
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.3.0
12
+
13
+ - **`pyweft.http`**: call web APIs and load the app's data files, with the
14
+ same code in the browser (the page's fetch) and on the desktop (the
15
+ standard library). `get_json`, `get`/`post`/`put`/`patch`/`delete`,
16
+ `request`; JSON, form and text bodies, query parameters, timeouts. A
17
+ relative URL is a file the app ships (`assets/prices.json`).
18
+ - **`pyweft.storage`**: keep data between visits: `get`, `set`, `delete`,
19
+ `has`, `keys`, `clear`, for any JSON value. The browser's local storage,
20
+ kept apart per app; on the desktop, a JSON file in the user's data
21
+ folder; in tests, memory.
22
+ - The todo example remembers its list, and the tutorial has a step for it.
23
+ - The todo and shop examples are restyled with Tailwind CSS (built with
24
+ its CLI), in light and dark, and work at phone width. The shop has a
25
+ dashboard, an orders table and a reusable status badge.
26
+
11
27
  ## 0.2.1
12
28
 
13
29
  - The PyPI page installs from PyPI, and its links to the guide and the
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyweft
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: A UI framework for Python: XML templates, Python code-behind, one render core for web, desktop and mobile.
5
5
  Project-URL: Homepage, https://github.com/prassanthn/PyWeft
6
- Project-URL: Documentation, https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md
6
+ Project-URL: Documentation, https://prassanthn.github.io/PyWeft/
7
7
  Project-URL: Issues, https://github.com/prassanthn/PyWeft/issues
8
8
  Author: Prassanth Nicholas
9
9
  License-Expression: MIT
@@ -36,6 +36,12 @@ running on your machine. No JavaScript to write, no build step.
36
36
 
37
37
  *PyWeft: the weft is the thread woven through the warp to make fabric.*
38
38
 
39
+ **[Website](https://prassanthn.github.io/PyWeft/)**: a step-by-step
40
+ [tutorial](https://prassanthn.github.io/PyWeft/tutorial/), the
41
+ [guide](https://prassanthn.github.io/PyWeft/guide/), and the
42
+ [controls](https://prassanthn.github.io/PyWeft/live/gallery/) and
43
+ [examples](https://prassanthn.github.io/PyWeft/examples/) running live.
44
+
39
45
  ```xml
40
46
  <template>
41
47
  <div>
@@ -208,6 +214,9 @@ To see every control live, clone this repository and run
208
214
  - **Files and dialogs:** `pyweft.dialogs` opens and saves files and asks for
209
215
  confirmation with the same code on both targets: the browser's picker and
210
216
  downloads on the web, the system's own dialogs on the desktop.
217
+ - **Data in and out:** `pyweft.http` calls web APIs and loads your app's
218
+ data files, and `pyweft.storage` keeps settings and lists between visits,
219
+ with the same code in the browser and on the desktop.
211
220
  - **A fast dev loop:** hot reload that keeps your app's state, and readable
212
221
  errors that point to your `.weft` file and line.
213
222
  - **Fast first load:** your app's first screen is rendered ahead of time and
@@ -229,6 +238,7 @@ To see every control live, clone this repository and run
229
238
  | Run it as a desktop app | [A desktop app](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#a-desktop-app-pyweft-run---desktop) |
230
239
  | Hand out a desktop app (`.exe`, `.app`) | [Handing out a desktop app](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#handing-out-a-desktop-app-pyweft-build---desktop) |
231
240
  | Open and save files, ask for confirmation | [Files and dialogs](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#files-and-dialogs) |
241
+ | Call web APIs, save data between visits | [Loading data](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#loading-data-pywefthttp), [saving data](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#saving-data-between-visits-pyweftstorage) |
232
242
  | Name the app, give it an icon | [The app's title and icon](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#the-apps-title-and-icon) |
233
243
  | Test components without a browser | [Using the engine from plain Python](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#6-using-the-engine-from-plain-python) |
234
244
 
@@ -282,12 +292,22 @@ With pip: `pip install -e ".[dev]"` and `pip install -e packages/pyweft-ui`.
282
292
  Releases go to PyPI by pushing a version tag; [RELEASING.md](https://github.com/prassanthn/PyWeft/blob/main/RELEASING.md)
283
293
  has the steps, and [CHANGELOG.md](https://github.com/prassanthn/PyWeft/blob/main/CHANGELOG.md) what each release changed.
284
294
 
295
+ The website is rebuilt and published on every push to `main`. To build it
296
+ locally:
297
+
298
+ ```bash
299
+ uv sync --group website
300
+ uv run python website/build.py
301
+ uv run python -m http.server -d website/_build/site # http://127.0.0.1:8000/
302
+ ```
303
+
285
304
  ```
286
305
  src/pyweft/ the framework (zero runtime dependencies)
287
306
  static/ the browser side: runtime.js, the page shell, the worker
288
307
  packages/pyweft-ui/ the control toolkit, released as its own package
289
- docs/ the guide
308
+ docs/ the guide (the website splits it into pages)
290
309
  examples/ a headless demo, the todo app and the shop (pages)
310
+ website/ the website: home page, tutorial, build script
291
311
  tests/ the framework's tests
292
312
  ```
293
313
 
@@ -7,6 +7,12 @@ running on your machine. No JavaScript to write, no build step.
7
7
 
8
8
  *PyWeft: the weft is the thread woven through the warp to make fabric.*
9
9
 
10
+ **[Website](https://prassanthn.github.io/PyWeft/)**: a step-by-step
11
+ [tutorial](https://prassanthn.github.io/PyWeft/tutorial/), the
12
+ [guide](https://prassanthn.github.io/PyWeft/guide/), and the
13
+ [controls](https://prassanthn.github.io/PyWeft/live/gallery/) and
14
+ [examples](https://prassanthn.github.io/PyWeft/examples/) running live.
15
+
10
16
  ```xml
11
17
  <template>
12
18
  <div>
@@ -179,6 +185,9 @@ To see every control live, clone this repository and run
179
185
  - **Files and dialogs:** `pyweft.dialogs` opens and saves files and asks for
180
186
  confirmation with the same code on both targets: the browser's picker and
181
187
  downloads on the web, the system's own dialogs on the desktop.
188
+ - **Data in and out:** `pyweft.http` calls web APIs and loads your app's
189
+ data files, and `pyweft.storage` keeps settings and lists between visits,
190
+ with the same code in the browser and on the desktop.
182
191
  - **A fast dev loop:** hot reload that keeps your app's state, and readable
183
192
  errors that point to your `.weft` file and line.
184
193
  - **Fast first load:** your app's first screen is rendered ahead of time and
@@ -200,6 +209,7 @@ To see every control live, clone this repository and run
200
209
  | Run it as a desktop app | [A desktop app](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#a-desktop-app-pyweft-run---desktop) |
201
210
  | Hand out a desktop app (`.exe`, `.app`) | [Handing out a desktop app](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#handing-out-a-desktop-app-pyweft-build---desktop) |
202
211
  | Open and save files, ask for confirmation | [Files and dialogs](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#files-and-dialogs) |
212
+ | Call web APIs, save data between visits | [Loading data](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#loading-data-pywefthttp), [saving data](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#saving-data-between-visits-pyweftstorage) |
203
213
  | Name the app, give it an icon | [The app's title and icon](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#the-apps-title-and-icon) |
204
214
  | Test components without a browser | [Using the engine from plain Python](https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md#6-using-the-engine-from-plain-python) |
205
215
 
@@ -253,12 +263,22 @@ With pip: `pip install -e ".[dev]"` and `pip install -e packages/pyweft-ui`.
253
263
  Releases go to PyPI by pushing a version tag; [RELEASING.md](https://github.com/prassanthn/PyWeft/blob/main/RELEASING.md)
254
264
  has the steps, and [CHANGELOG.md](https://github.com/prassanthn/PyWeft/blob/main/CHANGELOG.md) what each release changed.
255
265
 
266
+ The website is rebuilt and published on every push to `main`. To build it
267
+ locally:
268
+
269
+ ```bash
270
+ uv sync --group website
271
+ uv run python website/build.py
272
+ uv run python -m http.server -d website/_build/site # http://127.0.0.1:8000/
273
+ ```
274
+
256
275
  ```
257
276
  src/pyweft/ the framework (zero runtime dependencies)
258
277
  static/ the browser side: runtime.js, the page shell, the worker
259
278
  packages/pyweft-ui/ the control toolkit, released as its own package
260
- docs/ the guide
279
+ docs/ the guide (the website splits it into pages)
261
280
  examples/ a headless demo, the todo app and the shop (pages)
281
+ website/ the website: home page, tutorial, build script
262
282
  tests/ the framework's tests
263
283
  ```
264
284
 
@@ -51,6 +51,8 @@ later mobile: only the transport differs.
51
51
  [PyPI packages](#using-pypi-packages),
52
52
  [calling JavaScript](#calling-javascript),
53
53
  [files and dialogs](#files-and-dialogs),
54
+ [loading data (http)](#loading-data-pywefthttp),
55
+ [saving data between visits (storage)](#saving-data-between-visits-pyweftstorage),
54
56
  [building for deployment](#building-for-deployment),
55
57
  [a desktop app](#a-desktop-app-pyweft-run---desktop),
56
58
  [handing it out](#handing-out-a-desktop-app-pyweft-build---desktop)
@@ -1694,6 +1696,21 @@ page:
1694
1696
  `@apply` works in `_tailwind/input.css`, but not in a component's `<style>`
1695
1697
  block, which Tailwind never reads.
1696
1698
 
1699
+ **Classes that depend on state.** A `:class` sets the whole attribute, so
1700
+ an element with both `class` and `:class` gets only the `:class` one. Put
1701
+ the fixed classes in the expression too:
1702
+
1703
+ ```xml
1704
+ <button @click="show = name"
1705
+ :class="'rounded-lg px-3 py-1.5 ' + ('bg-slate-900 text-white' if name == show else 'text-slate-500')">
1706
+ {{ name }}
1707
+ </button>
1708
+ ```
1709
+
1710
+ The todo and shop examples (`examples/todo`, `examples/shop`) are styled
1711
+ with Tailwind's CLI exactly as above: see each one's `_tailwind/input.css`
1712
+ for the setup and the command that rebuilds its `tailwind.css`.
1713
+
1697
1714
  ### Ready-made controls: pyweft-ui
1698
1715
 
1699
1716
  If you'd rather not write HTML and CSS at all, `pyweft-ui` gives you styled,
@@ -1869,6 +1886,101 @@ async def clear(self):
1869
1886
  - **In tests**, `dialogs.set_host()` installs a stand-in that answers
1870
1887
  instead of the user; without one, the calls raise an error saying so.
1871
1888
 
1889
+ ### Loading data: pyweft.http
1890
+
1891
+ `pyweft.http` calls web APIs and loads files, the same way in the browser
1892
+ and in a [desktop app](#a-desktop-app-pyweft-run---desktop). Requests are
1893
+ `async`, so make them in `async` handlers or an `async def on_init`:
1894
+
1895
+ ```python
1896
+ from pyweft import http
1897
+
1898
+ orders: list = []
1899
+ error: str = ""
1900
+
1901
+ async def on_init(self):
1902
+ self.orders = await http.get_json("https://api.example.com/orders")
1903
+
1904
+ async def save(self, order):
1905
+ response = await http.post("https://api.example.com/orders", json=order)
1906
+ if not response.ok:
1907
+ self.error = f"Couldn't save the order ({response.status})"
1908
+ ```
1909
+
1910
+ | Call | Returns |
1911
+ |---|---|
1912
+ | `get_json(url)` | the body parsed as JSON; `http.HTTPError` unless the status is 2xx |
1913
+ | `get(url)`, `post(url)`, `put(url)`, `patch(url)`, `delete(url)` | a `Response`, whatever its status |
1914
+ | `request(method, url)` | the same, for any method |
1915
+
1916
+ Each takes `params=` (added to the URL's query), `headers=`, `timeout=`
1917
+ (seconds, 30 by default) and a body: `json=` (any JSON value) or `data=`
1918
+ (bytes, text, or a dict sent as a form).
1919
+
1920
+ - **A `Response`** has `status`, `ok` (a 2xx status), `headers` (names in
1921
+ lower case), `data` (the bytes), `text` and `json()`.
1922
+ `raise_for_status()` raises `http.HTTPError` unless it's 2xx.
1923
+ - **A relative URL is a file your app ships.** Put data files in
1924
+ `assets/`, and `await http.get_json("assets/prices.json")` reads them in
1925
+ the browser and in a desktop window alike.
1926
+ - **Failures:** no answer at all (offline, a mistyped address) raises
1927
+ `http.NetworkError`; a slow server raises `TimeoutError`.
1928
+ - **In the browser, the browser's rules apply.** A request to another site
1929
+ only works if that server allows it (it must send CORS headers; public
1930
+ APIs usually do). There is no such rule in a desktop app.
1931
+ - **Everyone can see the requests a web app makes**, as with any web page,
1932
+ so an API key in your code isn't secret. Keep secrets on a server of
1933
+ your own and call that.
1934
+ - **In tests**, requests go out for real through the standard library.
1935
+ `http.set_host()` installs a stand-in instead: an object with `async
1936
+ request(method, url, headers, body, timeout)` returning `(status,
1937
+ headers, data, url)`.
1938
+
1939
+ ### Saving data between visits: pyweft.storage
1940
+
1941
+ `pyweft.storage` keeps small amounts of data, such as settings, a draft or
1942
+ a list, until the next visit:
1943
+
1944
+ ```python
1945
+ from pyweft import effect, storage
1946
+
1947
+ items: list = []
1948
+
1949
+ def on_init(self):
1950
+ self.items = storage.get("todos", []) # [] the first time
1951
+
1952
+ @effect
1953
+ def keep(self):
1954
+ storage.set("todos", self.items) # runs again whenever items changes
1955
+ ```
1956
+
1957
+ | Call | Does |
1958
+ |---|---|
1959
+ | `get(key, default=None)` | the stored value, or `default` |
1960
+ | `set(key, value)` | stores `value`, replacing what was there |
1961
+ | `delete(key)`, `clear()` | removes one key, or everything this app stored |
1962
+ | `has(key)`, `keys()` | whether a key has a value; every key, sorted |
1963
+
1964
+ - **Values are anything JSON can hold:** numbers, text, `True`/`False`/
1965
+ `None`, and lists and dicts of those. Anything else raises `TypeError`;
1966
+ turn dates into text first, for example.
1967
+ - **`get` returns a copy.** Changing a list you got back doesn't change
1968
+ what's stored until you `set` it again, which is what the effect above
1969
+ does.
1970
+ - **The calls aren't `async`**, so they work anywhere: in `on_init`, an
1971
+ effect, a property, or a module of your app.
1972
+ - **Where it lives:** in the browser, the browser's local storage for your
1973
+ site (about 5 MB in all, cleared if the visitor clears site data), kept
1974
+ apart per app by the `[project]` name in its `pyproject.toml`. In a
1975
+ desktop app, a JSON file in the user's data folder:
1976
+ `%APPDATA%\<name>\storage.json` on Windows,
1977
+ `~/Library/Application Support/<name>/` on macOS,
1978
+ `~/.local/share/<name>/` on Linux.
1979
+ - **Not in `run_in_background`:** the worker can't reach the page's
1980
+ storage, so read what the function needs first and pass it in.
1981
+ - **In tests** it lives in memory. `storage.set_host(storage.MemoryStorage())`
1982
+ starts a test empty; `storage.FileStorage(path)` keeps it in a file.
1983
+
1872
1984
  ### Building for deployment
1873
1985
 
1874
1986
  ```bash
@@ -35,7 +35,7 @@ package = ["pywebview>=5", "pyinstaller>=6"]
35
35
 
36
36
  [project.urls]
37
37
  Homepage = "https://github.com/prassanthn/PyWeft"
38
- Documentation = "https://github.com/prassanthn/PyWeft/blob/main/docs/getting-started.md"
38
+ Documentation = "https://prassanthn.github.io/PyWeft/"
39
39
  Issues = "https://github.com/prassanthn/PyWeft/issues"
40
40
 
41
41
  [project.scripts]
@@ -70,3 +70,11 @@ pyweft-ui = { workspace = true }
70
70
 
71
71
  [dependency-groups]
72
72
  dev = ["pyweft-ui"]
73
+ # The website (website/build.py): uv sync --group website
74
+ # and the examples' Tailwind stylesheets (examples/*/_tailwind/input.css)
75
+ website = [
76
+ "mkdocs-material>=9.8,<10; python_version >= '3.11'",
77
+ "pytailwindcss>=0.4; python_version >= '3.11'",
78
+ # the todo example's own dependency, so its first screen can be pre-rendered
79
+ "humanize",
80
+ ]
@@ -4,7 +4,7 @@ Markup in XML, logic in Python, one render core that emits transport-agnostic
4
4
  patches so desktop (webview), web (Pyodide) and mobile can share a codebase.
5
5
  """
6
6
 
7
- from . import dialogs
7
+ from . import dialogs, http, storage
8
8
  from .background import BackgroundError, run_in_background
9
9
  from .component import (
10
10
  Component,
@@ -24,7 +24,7 @@ from .store import Store
24
24
  from .styles import collect_styles, scope_css
25
25
  from .vdom import Element, Node, Text, to_dicts
26
26
 
27
- __version__ = "0.2.1"
27
+ __version__ = "0.3.0"
28
28
 
29
29
  __all__ = [
30
30
  "Component",
@@ -43,6 +43,8 @@ __all__ = [
43
43
  "Store",
44
44
  "set_title",
45
45
  "dialogs",
46
+ "http",
47
+ "storage",
46
48
  "collect_styles",
47
49
  "scope_css",
48
50
  "diff",
@@ -234,6 +234,26 @@ def app_identity(app_dir: Path) -> AppIdentity:
234
234
  return AppIdentity(title=title.strip(), icon=icon)
235
235
 
236
236
 
237
+ def app_id(app_dir: Path) -> str:
238
+ """A stable name for keeping the app's data apart (pyweft.storage): the
239
+ ``[project]`` name from its pyproject.toml, else the folder's name.
240
+ Renaming the project starts its saved data afresh."""
241
+ name = ""
242
+ pyproject = app_dir / "pyproject.toml"
243
+ if pyproject.exists():
244
+ text = pyproject.read_text(encoding="utf-8")
245
+ if tomllib is not None:
246
+ try:
247
+ name = tomllib.loads(text).get("project", {}).get("name", "")
248
+ except tomllib.TOMLDecodeError as exc:
249
+ raise CliError(f"{pyproject}: {exc}") from None
250
+ else:
251
+ name = _flat_table(text, "project").get("name", "")
252
+ if not isinstance(name, str) or not name.strip():
253
+ name = app_dir.resolve().name
254
+ return re.sub(r"[^a-z0-9._-]+", "-", name.strip().lower()).strip("-.") or "app"
255
+
256
+
237
257
  # mimetypes reads the Windows registry, which can be wrong or missing these.
238
258
  _CONTENT_TYPES = {
239
259
  ".css": "text/css",
@@ -515,6 +535,7 @@ def render_shell(
515
535
  "__PYWEFT_LIBRARIES__",
516
536
  json.dumps([{"name": lib.name, "files": lib.files} for lib in app_libraries(app_dir)]),
517
537
  )
538
+ .replace("__PYWEFT_APP_ID__", json.dumps(app_id(app_dir)))
518
539
  .replace("__PYWEFT_DEV__", "true" if dev else "false")
519
540
  .replace("__PYWEFT_SINCE__", str(since))
520
541
  .replace("__PYWEFT_CSS__", css)
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
  import asyncio
23
23
  import concurrent.futures
24
24
  import json
25
+ import os
25
26
  import re
26
27
  import sys
27
28
  import threading
@@ -32,7 +33,7 @@ from pathlib import Path
32
33
  from typing import Any, Callable
33
34
  from urllib.parse import unquote
34
35
 
35
- from . import dialogs, routing
36
+ from . import dialogs, http, routing, storage
36
37
  from .component import load_component
37
38
  from .differ import diff
38
39
  from .hotreload import format_error, hot_swap
@@ -225,9 +226,20 @@ class DesktopApp:
225
226
  """One app, one window. ``window`` is anything with ``evaluate_js``:
226
227
  a pywebview window, or a stand-in in tests."""
227
228
 
228
- def __init__(self, app_dir: Path, window: Any = None):
229
+ def __init__(
230
+ self,
231
+ app_dir: Path,
232
+ window: Any = None,
233
+ base_url: str | None = None,
234
+ storage_file: Path | None = None,
235
+ ):
229
236
  self.app_dir = Path(app_dir).resolve()
230
237
  self.window = window
238
+ # Where the window's page is served, so relative pyweft.http URLs
239
+ # (assets/data.json) work as they do in the browser.
240
+ self.base_url = base_url
241
+ # pyweft.storage's file; in memory without one (tests).
242
+ self.storage_file = storage_file
231
243
  self.here = "/"
232
244
  self.tree: list = []
233
245
  self.renderer: Renderer | None = None
@@ -281,6 +293,10 @@ class DesktopApp:
281
293
  sys.path.insert(0, str(self.app_dir)) # the app's own modules
282
294
  routing.set_host(_Location(self))
283
295
  dialogs.set_host(_Dialogs(self))
296
+ http.set_host(http.Urllib(base_url=self.base_url))
297
+ storage.set_host(
298
+ storage.FileStorage(self.storage_file) if self.storage_file else storage.MemoryStorage()
299
+ )
284
300
  self.cls = load_component(self.app_dir / "app.weft")
285
301
  self.renderer = Renderer(self.cls())
286
302
  # Already on the loop thread whenever app code changes state, except
@@ -539,10 +555,22 @@ def watch(app: DesktopApp, stop: threading.Event, interval: float = 0.3) -> None
539
555
  app.js("location.reload()") # head.html, assets: the page remounts
540
556
 
541
557
 
558
+ def data_dir(app_id: str) -> Path:
559
+ """Where a desktop app keeps its data (pyweft.storage): the user's
560
+ application data folder, in a folder named after the app."""
561
+ if sys.platform == "win32":
562
+ base = os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming"
563
+ elif sys.platform == "darwin":
564
+ base = Path.home() / "Library" / "Application Support"
565
+ else:
566
+ base = os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"
567
+ return Path(base) / app_id
568
+
569
+
542
570
  def run_desktop(app_dir: Path, dev: bool = True) -> None:
543
571
  """The app in a window until it closes. ``dev`` (``pyweft run``) adds
544
572
  hot reload and terminal messages; a packaged app runs without them."""
545
- from .cli import CliError, app_identity, app_pyweft_files
573
+ from .cli import CliError, app_id, app_identity, app_pyweft_files
546
574
 
547
575
  app_pyweft_files(app_dir) # fails fast without an app.weft
548
576
  identity = app_identity(app_dir) # and on a mistake in the settings
@@ -555,15 +583,16 @@ def run_desktop(app_dir: Path, dev: bool = True) -> None:
555
583
  'uv add "pyweft[desktop]" (or: pip install "pyweft[desktop]")'
556
584
  ) from None
557
585
 
558
- app = DesktopApp(app_dir)
586
+ server = make_server(app_dir)
587
+ threading.Thread(target=server.serve_forever, daemon=True).start()
588
+ url = f"http://127.0.0.1:{server.server_address[1]}/"
589
+ app = DesktopApp(app_dir, base_url=url, storage_file=data_dir(app_id(app_dir)) / "storage.json")
559
590
  try:
560
591
  app.start()
561
592
  except Exception as exc: # noqa: BLE001 - shown as a CLI error
562
593
  app.stop()
594
+ server.shutdown()
563
595
  raise CliError(format_error(exc, root=str(Path(app_dir).resolve()))) from None
564
- server = make_server(app_dir)
565
- threading.Thread(target=server.serve_forever, daemon=True).start()
566
- url = f"http://127.0.0.1:{server.server_address[1]}/"
567
596
  app.window = webview.create_window(identity.title, url, js_api=app.api, **options)
568
597
  stop = threading.Event()
569
598
  if dev: