wesktop 0.3.2__tar.gz → 0.4.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 (116) hide show
  1. wesktop-0.4.0/.rlsbl/changes/.validated +1 -0
  2. wesktop-0.4.0/.rlsbl/changes/0.4.0.jsonl +5 -0
  3. wesktop-0.4.0/.rlsbl/changes/0.4.0.md +6 -0
  4. wesktop-0.4.0/.rlsbl/releases/v0.4.0.toml +3 -0
  5. {wesktop-0.3.2 → wesktop-0.4.0}/.strictcli/schema.json +1 -1
  6. {wesktop-0.3.2 → wesktop-0.4.0}/CHANGELOG.md +7 -0
  7. {wesktop-0.3.2 → wesktop-0.4.0}/PKG-INFO +1 -1
  8. {wesktop-0.3.2 → wesktop-0.4.0}/pyproject.toml +1 -1
  9. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/__init__.py +29 -0
  10. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/desktop.py +2 -0
  11. wesktop-0.4.0/src/wesktop/dev.py +95 -0
  12. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_desktop.py +82 -0
  13. wesktop-0.4.0/tests/test_dev.py +279 -0
  14. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_phase4.py +7 -2
  15. {wesktop-0.3.2 → wesktop-0.4.0}/uv.lock +1 -1
  16. wesktop-0.3.2/.rlsbl/changes/.validated +0 -1
  17. {wesktop-0.3.2 → wesktop-0.4.0}/.claude/settings.json +0 -0
  18. {wesktop-0.3.2 → wesktop-0.4.0}/.github/workflows/ci.yml +0 -0
  19. {wesktop-0.3.2 → wesktop-0.4.0}/.github/workflows/publish.yml +0 -0
  20. {wesktop-0.3.2 → wesktop-0.4.0}/.gitignore +0 -0
  21. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.claude/settings.json +0 -0
  22. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  23. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  24. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.gitignore +0 -0
  25. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
  26. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.rlsbl/hooks/post-release.sh +0 -0
  27. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +0 -0
  28. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +0 -0
  29. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.rlsbl/lint/go.toml +0 -0
  30. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.rlsbl/lint/npm.toml +0 -0
  31. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  32. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/CHANGELOG.md +0 -0
  33. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/CLAUDE.md +0 -0
  34. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/bases/LICENSE +0 -0
  35. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.1.0.jsonl +0 -0
  36. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.1.0.md +0 -0
  37. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.1.1.jsonl +0 -0
  38. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.1.1.md +0 -0
  39. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.2.0.jsonl +0 -0
  40. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.2.0.md +0 -0
  41. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.2.1.jsonl +0 -0
  42. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.2.1.md +0 -0
  43. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.3.0.jsonl +0 -0
  44. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.3.0.md +0 -0
  45. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.3.1.jsonl +0 -0
  46. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.3.1.md +0 -0
  47. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.3.2.jsonl +0 -0
  48. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/0.3.2.md +0 -0
  49. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/changes/unreleased.jsonl +0 -0
  50. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/config.json +0 -0
  51. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/hashes.json +0 -0
  52. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/hooks/post-release.sh +0 -0
  53. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/hooks/pre-checks.sh +0 -0
  54. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/hooks/pre-release.sh +0 -0
  55. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/lint/go.toml +0 -0
  56. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/lint/npm.toml +0 -0
  57. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/lint/python.toml +0 -0
  58. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/releases/unreleased.toml +0 -0
  59. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/releases/v0.3.0.toml +0 -0
  60. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/releases/v0.3.1.toml +0 -0
  61. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/releases/v0.3.2.toml +0 -0
  62. {wesktop-0.3.2 → wesktop-0.4.0}/.rlsbl/version +0 -0
  63. {wesktop-0.3.2 → wesktop-0.4.0}/.selfdoc/hashes/hashes.json +0 -0
  64. {wesktop-0.3.2 → wesktop-0.4.0}/CLAUDE.md +0 -0
  65. {wesktop-0.3.2 → wesktop-0.4.0}/LICENSE +0 -0
  66. {wesktop-0.3.2 → wesktop-0.4.0}/README.md +0 -0
  67. {wesktop-0.3.2 → wesktop-0.4.0}/bin/cli.js +0 -0
  68. {wesktop-0.3.2 → wesktop-0.4.0}/docs/api.md +0 -0
  69. {wesktop-0.3.2 → wesktop-0.4.0}/docs/index.md +0 -0
  70. {wesktop-0.3.2 → wesktop-0.4.0}/package.json +0 -0
  71. {wesktop-0.3.2 → wesktop-0.4.0}/selfdoc.json +0 -0
  72. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/__main__.py +0 -0
  73. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/asgi.py +0 -0
  74. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/audit.py +0 -0
  75. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/auth.py +0 -0
  76. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/cli.py +0 -0
  77. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/config.py +0 -0
  78. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/di.py +0 -0
  79. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/entries.py +0 -0
  80. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/error_log.py +0 -0
  81. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/features.py +0 -0
  82. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/logging.py +0 -0
  83. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/mcp.py +0 -0
  84. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/mcp_tools/__init__.py +0 -0
  85. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/mcp_tools/ask_user.py +0 -0
  86. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/mcp_tools/deployment.py +0 -0
  87. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/mcp_tools/filesystem.py +0 -0
  88. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/mcp_tools/git.py +0 -0
  89. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/mcp_tools/review.py +0 -0
  90. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/mcp_tools/testing.py +0 -0
  91. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/middleware.py +0 -0
  92. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/sdui.py +0 -0
  93. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/server.py +0 -0
  94. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/sse.py +0 -0
  95. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/tasks.py +0 -0
  96. {wesktop-0.3.2 → wesktop-0.4.0}/src/wesktop/testing.py +0 -0
  97. {wesktop-0.3.2 → wesktop-0.4.0}/tests/__init__.py +0 -0
  98. {wesktop-0.3.2 → wesktop-0.4.0}/tests/conftest.py +0 -0
  99. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_asgi.py +0 -0
  100. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_cli.py +0 -0
  101. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_entries.py +0 -0
  102. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_followup.py +0 -0
  103. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_import.py +0 -0
  104. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_phase1.py +0 -0
  105. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_phase2.py +0 -0
  106. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_phase3.py +0 -0
  107. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_phase5.py +0 -0
  108. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_phase6.py +0 -0
  109. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_phase7.py +0 -0
  110. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_phase8.py +0 -0
  111. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_server.py +0 -0
  112. {wesktop-0.3.2 → wesktop-0.4.0}/tests/test_sse.py +0 -0
  113. {wesktop-0.3.2 → wesktop-0.4.0}/todo/.done/claudetimeline-migration-decisions.md +0 -0
  114. {wesktop-0.3.2 → wesktop-0.4.0}/todo/.done/codehome-ct-migration-plan.md +0 -0
  115. {wesktop-0.3.2 → wesktop-0.4.0}/todo/platform-vision.md +0 -0
  116. {wesktop-0.3.2 → wesktop-0.4.0}/todo/route-metadata-api.md +0 -0
@@ -0,0 +1 @@
1
+ cafbbdcedbfae32677c2f02fd5bddfdd082cc628
@@ -0,0 +1,5 @@
1
+ {"commits":["be870a9f1bf8e89d54b819e5bc08191a0e9bc6f5"],"user_facing":true,"description":"**New feature.** `run()` accepts a `js_api` parameter, passed through to pywebview's `create_window()` for exposing Python methods to JavaScript.","type":"feature"}
2
+ {"commits":["6b2f343b0a419b86a68bbe1774243b803ffa7ffc"],"user_facing":false}
3
+ {"commits":["81c19888eb4ab2945245f841802b0a537fd3f90d"],"user_facing":false}
4
+ {"commits":["a9108962c657aec782b52a67fc6a45cd2dfb346f"],"user_facing":true,"description":"**New feature.** `dev()` starts a Vite dev server alongside the wesktop server in a single command, with ViteDevProxy for unified port access and automatic Vite lifecycle management.","type":"feature"}
5
+ {"commits":["e16c8adee976582c1a68d94c16089598eb13ffaf"],"user_facing":false}
@@ -0,0 +1,6 @@
1
+ ## 0.4.0
2
+
3
+ ### Features
4
+
5
+ - **New feature.** `run()` accepts a `js_api` parameter, passed through to pywebview's `create_window()` for exposing Python methods to JavaScript.
6
+ - **New feature.** `dev()` starts a Vite dev server alongside the wesktop server in a single command, with ViteDevProxy for unified port access and automatic Vite lifecycle management.
@@ -0,0 +1,3 @@
1
+ bump = "minor"
2
+ include = ["pypi"]
3
+ exclude = []
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wesktop",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "help": "A Python framework for building web-based desktop applications",
5
5
  "env_prefix": null,
6
6
  "config": true,
@@ -2,6 +2,13 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## 0.4.0
6
+
7
+ ### Features
8
+
9
+ - **New feature.** `run()` accepts a `js_api` parameter, passed through to pywebview's `create_window()` for exposing Python methods to JavaScript.
10
+ - **New feature.** `dev()` starts a Vite dev server alongside the wesktop server in a single command, with ViteDevProxy for unified port access and automatic Vite lifecycle management.
11
+
5
12
  ## 0.3.2
6
13
 
7
14
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wesktop
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A Python framework for building web-based desktop applications
5
5
  Author-email: smm-h <smmh72@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wesktop"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "A Python framework for building web-based desktop applications"
5
5
  requires-python = ">=3.11"
6
6
  license = "MIT"
@@ -192,6 +192,7 @@ __all__ = [
192
192
  "status",
193
193
  "ServerStatus",
194
194
  "run",
195
+ "dev",
195
196
  # features
196
197
  "FeatureFlags",
197
198
  # audit
@@ -275,6 +276,7 @@ def run(
275
276
  name: str = "WESKTOP",
276
277
  pre_serve: Callable[[], None] | None = None,
277
278
  reload: bool = False,
279
+ js_api: object | None = None,
278
280
  ) -> None:
279
281
  """Start server + native desktop window."""
280
282
  from wesktop.desktop import run as _run
@@ -291,6 +293,7 @@ def run(
291
293
  name=name,
292
294
  pre_serve=pre_serve,
293
295
  reload=reload,
296
+ js_api=js_api,
294
297
  )
295
298
 
296
299
 
@@ -332,3 +335,29 @@ def status(pid_path: Path, health_url: str | None = None) -> ServerStatus:
332
335
  from wesktop.server import status as _status
333
336
 
334
337
  return _status(pid_path, health_url=health_url)
338
+
339
+
340
+ def dev(
341
+ target: str | Callable,
342
+ *,
343
+ vite_command: str = "npm run dev",
344
+ vite_port: int = 5173,
345
+ host: str | None = None,
346
+ port: int | None = None,
347
+ pid_path: Path | None = None,
348
+ name: str = "WESKTOP",
349
+ pre_serve: Callable[[], None] | None = None,
350
+ ) -> None:
351
+ """Development mode: Vite + server. See :func:`wesktop.dev.dev`."""
352
+ from wesktop.dev import dev as _dev
353
+
354
+ _dev(
355
+ target,
356
+ vite_command=vite_command,
357
+ vite_port=vite_port,
358
+ host=host,
359
+ port=port,
360
+ pid_path=pid_path,
361
+ name=name,
362
+ pre_serve=pre_serve,
363
+ )
@@ -19,6 +19,7 @@ def run(
19
19
  name: str = "WESKTOP",
20
20
  pre_serve: Callable[[], None] | None = None,
21
21
  reload: bool = False,
22
+ js_api: object | None = None,
22
23
  ) -> None:
23
24
  """Start server + open native desktop window. Blocks until window closes."""
24
25
  from wesktop.server import serve
@@ -42,6 +43,7 @@ def run(
42
43
  url=url,
43
44
  width=width,
44
45
  height=height,
46
+ js_api=js_api,
45
47
  )
46
48
 
47
49
  webview.start(icon=icon)
@@ -0,0 +1,95 @@
1
+ """Development mode: Vite + wesktop server in a single command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import logging
7
+ import socket
8
+ import subprocess
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Callable
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def dev(
17
+ target: str | Callable,
18
+ *,
19
+ vite_command: str = "npm run dev",
20
+ vite_port: int = 5173,
21
+ host: str | None = None,
22
+ port: int | None = None,
23
+ pid_path: Path | None = None,
24
+ name: str = "WESKTOP",
25
+ pre_serve: Callable[[], None] | None = None,
26
+ ) -> None:
27
+ """Start Vite dev server + wesktop ASGI server for development.
28
+
29
+ Spawns Vite as a subprocess, waits for it to be ready, then starts
30
+ the wesktop server with ViteDevProxy middleware. All frontend
31
+ requests are proxied to Vite (with HMR), API requests are handled
32
+ by the wesktop router. Kills Vite on shutdown.
33
+ """
34
+ from wesktop.middleware import ViteDevProxy
35
+ from wesktop.server import serve
36
+
37
+ # Resolve the target to a callable ASGI app
38
+ if isinstance(target, str):
39
+ module_path, attr = target.rsplit(":", 1)
40
+ module = importlib.import_module(module_path)
41
+ app = getattr(module, attr)
42
+ else:
43
+ app = target
44
+
45
+ # Wrap with ViteDevProxy
46
+ wrapped = ViteDevProxy(app, vite_port=vite_port)
47
+
48
+ # Spawn Vite
49
+ logger.info("Starting Vite dev server: %s", vite_command)
50
+ vite_proc = subprocess.Popen(
51
+ vite_command,
52
+ shell=True,
53
+ stdout=subprocess.DEVNULL,
54
+ stderr=subprocess.PIPE,
55
+ )
56
+
57
+ # Wait for Vite to be ready
58
+ deadline = time.monotonic() + 15 # 15s timeout
59
+ ready = False
60
+ while time.monotonic() < deadline:
61
+ try:
62
+ sock = socket.create_connection(("127.0.0.1", vite_port), timeout=1)
63
+ sock.close()
64
+ ready = True
65
+ break
66
+ except (ConnectionRefusedError, OSError):
67
+ if vite_proc.poll() is not None:
68
+ stderr = vite_proc.stderr.read().decode() if vite_proc.stderr else ""
69
+ raise RuntimeError(f"Vite process exited with code {vite_proc.returncode}: {stderr}")
70
+ time.sleep(0.3)
71
+
72
+ if not ready:
73
+ vite_proc.terminate()
74
+ raise RuntimeError(f"Vite dev server did not start within 15s on port {vite_port}")
75
+
76
+ logger.info("Vite ready on port %d", vite_port)
77
+
78
+ try:
79
+ serve(
80
+ wrapped,
81
+ foreground=True,
82
+ host=host,
83
+ port=port,
84
+ pid_path=pid_path,
85
+ name=name,
86
+ pre_serve=pre_serve,
87
+ )
88
+ finally:
89
+ logger.info("Stopping Vite dev server")
90
+ vite_proc.terminate()
91
+ try:
92
+ vite_proc.wait(timeout=5)
93
+ except subprocess.TimeoutExpired:
94
+ vite_proc.kill()
95
+ vite_proc.wait()
@@ -48,6 +48,7 @@ def test_run_calls_webview(
48
48
  url=f"http://127.0.0.1:{port}",
49
49
  width=800,
50
50
  height=600,
51
+ js_api=None,
51
52
  )
52
53
 
53
54
  # webview.start() called to enter the event loop with icon=None
@@ -93,6 +94,87 @@ def test_run_without_icon(
93
94
  mock_wv_start.assert_called_once_with(icon=None)
94
95
 
95
96
 
97
+ @patch("webview.start")
98
+ @patch("webview.create_window")
99
+ @patch("wesktop.server.serve")
100
+ def test_run_with_js_api(
101
+ mock_serve: MagicMock,
102
+ mock_create_window: MagicMock,
103
+ mock_wv_start: MagicMock,
104
+ ) -> None:
105
+ """js_api object is forwarded to webview.create_window(js_api=...)."""
106
+ port = _free_port()
107
+ mock_serve.return_value = f"http://127.0.0.1:{port}"
108
+
109
+ class MyAPI:
110
+ def greet(self, name: str) -> str:
111
+ return f"Hello, {name}"
112
+
113
+ api = MyAPI()
114
+
115
+ from wesktop.desktop import run
116
+
117
+ run("myapp:app", host="127.0.0.1", port=port, js_api=api)
118
+
119
+ mock_create_window.assert_called_once_with(
120
+ title="wesktop",
121
+ url=f"http://127.0.0.1:{port}",
122
+ width=1280,
123
+ height=800,
124
+ js_api=api,
125
+ )
126
+
127
+
128
+ @patch("webview.start")
129
+ @patch("webview.create_window")
130
+ @patch("wesktop.server.serve")
131
+ def test_run_without_js_api(
132
+ mock_serve: MagicMock,
133
+ mock_create_window: MagicMock,
134
+ mock_wv_start: MagicMock,
135
+ ) -> None:
136
+ """When no js_api is provided, webview.create_window(js_api=None) is called."""
137
+ port = _free_port()
138
+ mock_serve.return_value = f"http://127.0.0.1:{port}"
139
+
140
+ from wesktop.desktop import run
141
+
142
+ run("myapp:app", host="127.0.0.1", port=port)
143
+
144
+ mock_create_window.assert_called_once_with(
145
+ title="wesktop",
146
+ url=f"http://127.0.0.1:{port}",
147
+ width=1280,
148
+ height=800,
149
+ js_api=None,
150
+ )
151
+
152
+
153
+ @patch("webview.start")
154
+ @patch("webview.create_window")
155
+ @patch("wesktop.server.serve")
156
+ def test_run_js_api_via_wrapper(
157
+ mock_serve: MagicMock,
158
+ mock_create_window: MagicMock,
159
+ mock_wv_start: MagicMock,
160
+ ) -> None:
161
+ """js_api passes through the wesktop.run() wrapper to desktop.run()."""
162
+ port = _free_port()
163
+ mock_serve.return_value = f"http://127.0.0.1:{port}"
164
+
165
+ class BridgeAPI:
166
+ def ping(self) -> str:
167
+ return "pong"
168
+
169
+ api = BridgeAPI()
170
+
171
+ wesktop.run("myapp:app", host="127.0.0.1", port=port, js_api=api)
172
+
173
+ mock_create_window.assert_called_once()
174
+ call_kwargs = mock_create_window.call_args[1]
175
+ assert call_kwargs["js_api"] is api
176
+
177
+
96
178
  @patch("wesktop.server.Granian")
97
179
  def test_serve_calls_granian(mock_granian_cls: MagicMock) -> None:
98
180
  """wesktop.serve() delegates to the server module with correct params."""
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import subprocess
5
+ from unittest.mock import MagicMock, call, patch
6
+
7
+ import pytest
8
+
9
+
10
+ def _fake_app(scope, receive, send):
11
+ """Dummy ASGI app for testing."""
12
+ pass
13
+
14
+
15
+ class TestDevSpawnsVite:
16
+ """dev() spawns a subprocess with the vite_command."""
17
+
18
+ @patch("wesktop.server.serve")
19
+ @patch("socket.create_connection")
20
+ @patch("subprocess.Popen")
21
+ def test_spawns_vite_subprocess(
22
+ self,
23
+ mock_popen: MagicMock,
24
+ mock_connect: MagicMock,
25
+ mock_serve: MagicMock,
26
+ ) -> None:
27
+ proc = MagicMock()
28
+ proc.poll.return_value = None
29
+ mock_popen.return_value = proc
30
+
31
+ # create_connection succeeds immediately (Vite is "ready")
32
+ mock_sock = MagicMock()
33
+ mock_connect.return_value = mock_sock
34
+
35
+ from wesktop.dev import dev
36
+
37
+ dev(_fake_app, host="127.0.0.1", port=8000)
38
+
39
+ mock_popen.assert_called_once_with(
40
+ "npm run dev",
41
+ shell=True,
42
+ stdout=subprocess.DEVNULL,
43
+ stderr=subprocess.PIPE,
44
+ )
45
+
46
+ @patch("wesktop.server.serve")
47
+ @patch("socket.create_connection")
48
+ @patch("subprocess.Popen")
49
+ def test_custom_vite_command(
50
+ self,
51
+ mock_popen: MagicMock,
52
+ mock_connect: MagicMock,
53
+ mock_serve: MagicMock,
54
+ ) -> None:
55
+ proc = MagicMock()
56
+ proc.poll.return_value = None
57
+ mock_popen.return_value = proc
58
+ mock_connect.return_value = MagicMock()
59
+
60
+ from wesktop.dev import dev
61
+
62
+ dev(_fake_app, vite_command="pnpm dev", host="127.0.0.1", port=8000)
63
+
64
+ mock_popen.assert_called_once_with(
65
+ "pnpm dev",
66
+ shell=True,
67
+ stdout=subprocess.DEVNULL,
68
+ stderr=subprocess.PIPE,
69
+ )
70
+
71
+
72
+ class TestDevWrapsWithViteDevProxy:
73
+ """dev() wraps the app with ViteDevProxy before passing to serve()."""
74
+
75
+ @patch("wesktop.server.serve")
76
+ @patch("socket.create_connection")
77
+ @patch("subprocess.Popen")
78
+ def test_wraps_app_with_proxy(
79
+ self,
80
+ mock_popen: MagicMock,
81
+ mock_connect: MagicMock,
82
+ mock_serve: MagicMock,
83
+ ) -> None:
84
+ proc = MagicMock()
85
+ proc.poll.return_value = None
86
+ mock_popen.return_value = proc
87
+ mock_connect.return_value = MagicMock()
88
+
89
+ from wesktop.dev import dev
90
+ from wesktop.middleware import ViteDevProxy
91
+
92
+ dev(_fake_app, vite_port=3000, host="127.0.0.1", port=8000)
93
+
94
+ # serve() was called with a ViteDevProxy wrapping the original app
95
+ args, kwargs = mock_serve.call_args
96
+ wrapped = args[0]
97
+ assert isinstance(wrapped, ViteDevProxy)
98
+ assert wrapped.app is _fake_app
99
+ assert wrapped.vite_port == 3000
100
+
101
+
102
+ class TestDevCallsServe:
103
+ """dev() calls serve() with the wrapped app and foreground=True."""
104
+
105
+ @patch("wesktop.server.serve")
106
+ @patch("socket.create_connection")
107
+ @patch("subprocess.Popen")
108
+ def test_calls_serve_foreground(
109
+ self,
110
+ mock_popen: MagicMock,
111
+ mock_connect: MagicMock,
112
+ mock_serve: MagicMock,
113
+ ) -> None:
114
+ proc = MagicMock()
115
+ proc.poll.return_value = None
116
+ mock_popen.return_value = proc
117
+ mock_connect.return_value = MagicMock()
118
+
119
+ from wesktop.dev import dev
120
+
121
+ dev(_fake_app, host="127.0.0.1", port=9000, name="MYAPP")
122
+
123
+ mock_serve.assert_called_once()
124
+ _, kwargs = mock_serve.call_args
125
+ assert kwargs["foreground"] is True
126
+ assert kwargs["host"] == "127.0.0.1"
127
+ assert kwargs["port"] == 9000
128
+ assert kwargs["name"] == "MYAPP"
129
+ assert kwargs["pid_path"] is None
130
+ assert kwargs["pre_serve"] is None
131
+
132
+
133
+ class TestDevTerminatesVite:
134
+ """dev() terminates the Vite process on shutdown."""
135
+
136
+ @patch("wesktop.server.serve")
137
+ @patch("socket.create_connection")
138
+ @patch("subprocess.Popen")
139
+ def test_terminates_vite_on_normal_exit(
140
+ self,
141
+ mock_popen: MagicMock,
142
+ mock_connect: MagicMock,
143
+ mock_serve: MagicMock,
144
+ ) -> None:
145
+ proc = MagicMock()
146
+ proc.poll.return_value = None
147
+ mock_popen.return_value = proc
148
+ mock_connect.return_value = MagicMock()
149
+
150
+ from wesktop.dev import dev
151
+
152
+ dev(_fake_app, host="127.0.0.1", port=8000)
153
+
154
+ proc.terminate.assert_called_once()
155
+ proc.wait.assert_called_once_with(timeout=5)
156
+
157
+ @patch("wesktop.server.serve", side_effect=KeyboardInterrupt)
158
+ @patch("socket.create_connection")
159
+ @patch("subprocess.Popen")
160
+ def test_terminates_vite_on_exception(
161
+ self,
162
+ mock_popen: MagicMock,
163
+ mock_connect: MagicMock,
164
+ mock_serve: MagicMock,
165
+ ) -> None:
166
+ proc = MagicMock()
167
+ proc.poll.return_value = None
168
+ mock_popen.return_value = proc
169
+ mock_connect.return_value = MagicMock()
170
+
171
+ from wesktop.dev import dev
172
+
173
+ with pytest.raises(KeyboardInterrupt):
174
+ dev(_fake_app, host="127.0.0.1", port=8000)
175
+
176
+ proc.terminate.assert_called_once()
177
+
178
+ @patch("wesktop.server.serve")
179
+ @patch("socket.create_connection")
180
+ @patch("subprocess.Popen")
181
+ def test_kills_vite_if_terminate_times_out(
182
+ self,
183
+ mock_popen: MagicMock,
184
+ mock_connect: MagicMock,
185
+ mock_serve: MagicMock,
186
+ ) -> None:
187
+ proc = MagicMock()
188
+ proc.poll.return_value = None
189
+ proc.wait.side_effect = [subprocess.TimeoutExpired(cmd="npm", timeout=5), None]
190
+ mock_popen.return_value = proc
191
+ mock_connect.return_value = MagicMock()
192
+
193
+ from wesktop.dev import dev
194
+
195
+ dev(_fake_app, host="127.0.0.1", port=8000)
196
+
197
+ proc.terminate.assert_called_once()
198
+ proc.kill.assert_called_once()
199
+ # wait called twice: once with timeout (raises), once after kill
200
+ assert proc.wait.call_count == 2
201
+
202
+
203
+ class TestDevViteFailsToStart:
204
+ """dev() raises RuntimeError if Vite fails to start."""
205
+
206
+ @patch("socket.create_connection", side_effect=ConnectionRefusedError)
207
+ @patch("subprocess.Popen")
208
+ def test_raises_if_vite_exits_immediately(
209
+ self,
210
+ mock_popen: MagicMock,
211
+ mock_connect: MagicMock,
212
+ ) -> None:
213
+ proc = MagicMock()
214
+ proc.poll.return_value = 1 # process exited
215
+ proc.returncode = 1
216
+ proc.stderr = MagicMock()
217
+ proc.stderr.read.return_value = b"vite: command not found"
218
+ mock_popen.return_value = proc
219
+
220
+ from wesktop.dev import dev
221
+
222
+ with pytest.raises(RuntimeError, match="Vite process exited with code 1"):
223
+ dev(_fake_app, host="127.0.0.1", port=8000)
224
+
225
+ @patch("time.monotonic")
226
+ @patch("time.sleep")
227
+ @patch("socket.create_connection", side_effect=ConnectionRefusedError)
228
+ @patch("subprocess.Popen")
229
+ def test_raises_if_vite_never_ready(
230
+ self,
231
+ mock_popen: MagicMock,
232
+ mock_connect: MagicMock,
233
+ mock_sleep: MagicMock,
234
+ mock_monotonic: MagicMock,
235
+ ) -> None:
236
+ proc = MagicMock()
237
+ proc.poll.return_value = None # process still running but never ready
238
+ mock_popen.return_value = proc
239
+
240
+ # Simulate time passing beyond the 15s deadline
241
+ mock_monotonic.side_effect = [0.0, 0.0, 16.0]
242
+
243
+ from wesktop.dev import dev
244
+
245
+ with pytest.raises(RuntimeError, match="did not start within 15s"):
246
+ dev(_fake_app, host="127.0.0.1", port=8000)
247
+
248
+ proc.terminate.assert_called_once()
249
+
250
+
251
+ class TestDevStringTarget:
252
+ """dev() resolves string targets via importlib."""
253
+
254
+ @patch("wesktop.server.serve")
255
+ @patch("socket.create_connection")
256
+ @patch("subprocess.Popen")
257
+ def test_resolves_string_target(
258
+ self,
259
+ mock_popen: MagicMock,
260
+ mock_connect: MagicMock,
261
+ mock_serve: MagicMock,
262
+ ) -> None:
263
+ proc = MagicMock()
264
+ proc.poll.return_value = None
265
+ mock_popen.return_value = proc
266
+ mock_connect.return_value = MagicMock()
267
+
268
+ from wesktop.dev import dev
269
+ from wesktop.middleware import ViteDevProxy
270
+
271
+ # Use a real importable module:attribute
272
+ dev("wesktop.asgi:create_app", host="127.0.0.1", port=8000)
273
+
274
+ args, kwargs = mock_serve.call_args
275
+ wrapped = args[0]
276
+ assert isinstance(wrapped, ViteDevProxy)
277
+ # The resolved app should be the actual create_app function
278
+ from wesktop.asgi import create_app
279
+ assert wrapped.app is create_app
@@ -70,8 +70,13 @@ class TestJWTTokens:
70
70
  def test_tampered_token_returns_none(self):
71
71
  secret = "test-secret-key-that-is-at-least-thirty-two-bytes-long"
72
72
  token = create_token("alice", "admin", secret)
73
- # Flip a character in the signature
74
- tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
73
+ # Flip a character in the middle of the signature (not the end,
74
+ # where base64url padding bits can absorb the change)
75
+ parts = token.split(".")
76
+ sig = list(parts[2])
77
+ mid = len(sig) // 2
78
+ sig[mid] = "X" if sig[mid] != "X" else "Y"
79
+ tampered = parts[0] + "." + parts[1] + "." + "".join(sig)
75
80
  assert verify_token(tampered, secret) is None
76
81
 
77
82
  def test_wrong_secret_returns_none(self):
@@ -826,7 +826,7 @@ wheels = [
826
826
 
827
827
  [[package]]
828
828
  name = "wesktop"
829
- version = "0.3.2"
829
+ version = "0.4.0"
830
830
  source = { editable = "." }
831
831
  dependencies = [
832
832
  { name = "bcrypt" },
@@ -1 +0,0 @@
1
- 1cf0504c7c65adf18fb01877fd2cc105334f46f5
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes