pyjs-examples 0.0.1__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.
- pyjs_examples/__init__.py +1 -0
- pyjs_examples/calculator/README.md +55 -0
- pyjs_examples/calculator/__init__.py +1 -0
- pyjs_examples/calculator/__main__.py +9 -0
- pyjs_examples/calculator/calculator.py +325 -0
- pyjs_examples/calculator/tests/test_browser_calculator.py +67 -0
- pyjs_examples/calculator/tests/test_calculator.py +72 -0
- pyjs_examples/catalog.py +1433 -0
- pyjs_examples/chatroom/README.md +50 -0
- pyjs_examples/chatroom/__init__.py +1 -0
- pyjs_examples/chatroom/__main__.py +12 -0
- pyjs_examples/chatroom/chat_server.py +156 -0
- pyjs_examples/chatroom/chatroom.py +704 -0
- pyjs_examples/chatroom/tests/test_browser_chatroom.py +59 -0
- pyjs_examples/chatroom/tests/test_chat_server.py +53 -0
- pyjs_examples/chatroom/visual_test.py +156 -0
- pyjs_examples/counter.py +73 -0
- pyjs_examples/kanban/README.md +31 -0
- pyjs_examples/kanban/__init__.py +1 -0
- pyjs_examples/kanban/__main__.py +8 -0
- pyjs_examples/kanban/kanban.py +581 -0
- pyjs_examples/kanban/tests/renders/kanban-board.png +0 -0
- pyjs_examples/kanban/tests/test_browser_kanban.py +150 -0
- pyjs_examples/kanban/tests/test_kanban.py +38 -0
- pyjs_examples/kanban/visual_test.py +108 -0
- pyjs_examples/nativescript_counter/README.md +31 -0
- pyjs_examples/nativescript_counter/__init__.py +5 -0
- pyjs_examples/nativescript_counter/__main__.py +17 -0
- pyjs_examples/nativescript_counter/counter.py +54 -0
- pyjs_examples/nativescript_counter/tests/test_android_counter.py +31 -0
- pyjs_examples/nativescript_counter/tests/test_counter.py +11 -0
- pyjs_examples/samples/__init__.py +1 -0
- pyjs_examples/samples/state_counter/README.md +19 -0
- pyjs_examples/samples/state_counter/__init__.py +1 -0
- pyjs_examples/samples/state_counter/__main__.py +3 -0
- pyjs_examples/samples/state_counter/state_counter.py +60 -0
- pyjs_examples/samples/state_counter/tests/test_browser_state_counter.py +18 -0
- pyjs_examples/samples/state_counter/tests/test_state_counter.py +17 -0
- pyjs_examples/samples/ui_dialog/README.md +19 -0
- pyjs_examples/samples/ui_dialog/__init__.py +1 -0
- pyjs_examples/samples/ui_dialog/__main__.py +3 -0
- pyjs_examples/samples/ui_dialog/tests/test_browser_ui_dialog.py +21 -0
- pyjs_examples/samples/ui_dialog/tests/test_ui_dialog.py +10 -0
- pyjs_examples/samples/ui_dialog/ui_dialog.py +65 -0
- pyjs_examples/space_invaders/README.md +45 -0
- pyjs_examples/space_invaders/__init__.py +1 -0
- pyjs_examples/space_invaders/__main__.py +3 -0
- pyjs_examples/space_invaders/space_invaders.py +1025 -0
- pyjs_examples/space_invaders/tests/renders/action_scene.png +0 -0
- pyjs_examples/space_invaders/tests/renders/initial_wave.png +0 -0
- pyjs_examples/space_invaders/tests/test_browser_game.py +86 -0
- pyjs_examples/space_invaders/tests/test_game.py +83 -0
- pyjs_examples/todomvc/README.md +50 -0
- pyjs_examples/todomvc/__init__.py +1 -0
- pyjs_examples/todomvc/__main__.py +12 -0
- pyjs_examples/todomvc/base.css +141 -0
- pyjs_examples/todomvc/index.css +393 -0
- pyjs_examples/todomvc/tests/test_browser_todomvc.py +145 -0
- pyjs_examples/todomvc/tests/test_todomvc.py +84 -0
- pyjs_examples/todomvc/todomvc.py +426 -0
- pyjs_examples/workspace/README.md +25 -0
- pyjs_examples/workspace/__init__.py +1 -0
- pyjs_examples/workspace/__main__.py +3 -0
- pyjs_examples/workspace/tests/test_browser_workspace.py +109 -0
- pyjs_examples/workspace/tests/test_workspace.py +27 -0
- pyjs_examples/workspace/workspace.py +749 -0
- pyjs_examples-0.0.1.dist-info/METADATA +71 -0
- pyjs_examples-0.0.1.dist-info/RECORD +71 -0
- pyjs_examples-0.0.1.dist-info/WHEEL +5 -0
- pyjs_examples-0.0.1.dist-info/entry_points.txt +11 -0
- pyjs_examples-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Chatroom Example
|
|
2
|
+
|
|
3
|
+
This example is a small Slack-like workspace built with PyJS custom elements.
|
|
4
|
+
It demonstrates:
|
|
5
|
+
|
|
6
|
+
- HTTP and WebSockets served on the same port
|
|
7
|
+
- a display name stored per browser tab with `sessionStorage`
|
|
8
|
+
- public room creation and room switching
|
|
9
|
+
- real-time messages and room presence
|
|
10
|
+
- DOM-owned scalar state declared with `state()` and rendered with focused effects
|
|
11
|
+
- message and presence collections rendered explicitly from server snapshots
|
|
12
|
+
- entirely in-memory server state that resets on restart
|
|
13
|
+
|
|
14
|
+
## Run the chatroom
|
|
15
|
+
|
|
16
|
+
From the repository root:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
.venv/bin/python -m pyjs_examples.chatroom
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Open `http://127.0.0.1:8000/` in two tabs, choose a different display name in
|
|
23
|
+
each, and join the same room.
|
|
24
|
+
|
|
25
|
+
## Run the tests
|
|
26
|
+
|
|
27
|
+
Run the Python server tests:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
.venv/bin/python -m pytest -q examples/chatroom/tests
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Run the custom-element UI tests in the browser:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
.venv/bin/python -m pytest -q examples/chatroom/tests --pyjs-target=web
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Run the visual test
|
|
40
|
+
|
|
41
|
+
The visual test starts its own chatroom server, opens two headless browser tabs
|
|
42
|
+
as Alice and Bob, has them create a room and exchange messages, and verifies
|
|
43
|
+
that presence updates when Bob leaves:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
.venv/bin/python examples/chatroom/visual_test.py
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
It writes both browser views and a JSON state report to `screenshots/`. The
|
|
50
|
+
chatroom does not need to be running beforehand.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""A multi-room WebSocket chat application with presence."""
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
from websockets.asyncio.server import ServerConnection
|
|
5
|
+
from websockets.exceptions import ConnectionClosed
|
|
6
|
+
|
|
7
|
+
MAX_MESSAGES_PER_ROOM = 200
|
|
8
|
+
MAX_ROOMS = 50
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChatClient:
|
|
12
|
+
def __init__(self, name: str, room: str):
|
|
13
|
+
self.name = name
|
|
14
|
+
self.room = room
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ChatServer:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.rooms: dict[str, list[dict[str, object]]] = {"general": []}
|
|
20
|
+
self.clients: dict[ServerConnection, ChatClient] = {}
|
|
21
|
+
self.next_message_id = 1
|
|
22
|
+
|
|
23
|
+
async def handle(self, connection: ServerConnection):
|
|
24
|
+
try:
|
|
25
|
+
async for raw_message in connection:
|
|
26
|
+
try:
|
|
27
|
+
message = json.loads(raw_message)
|
|
28
|
+
if not isinstance(message, dict):
|
|
29
|
+
raise ValueError("messages must be JSON objects")
|
|
30
|
+
await self.dispatch(connection, message)
|
|
31
|
+
except (json.JSONDecodeError, ValueError) as error:
|
|
32
|
+
await self.send_error(connection, str(error))
|
|
33
|
+
finally:
|
|
34
|
+
if connection in self.clients:
|
|
35
|
+
self.clients.pop(connection)
|
|
36
|
+
await self.broadcast_snapshots()
|
|
37
|
+
|
|
38
|
+
async def dispatch(self, connection: ServerConnection, message: dict[str, object]):
|
|
39
|
+
message_type = message.get("type")
|
|
40
|
+
if message_type == "join":
|
|
41
|
+
await self.join(connection, message)
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
client = self.clients.get(connection)
|
|
45
|
+
if client is None:
|
|
46
|
+
raise ValueError("join the chatroom before sending messages")
|
|
47
|
+
if message_type == "create_room":
|
|
48
|
+
await self.create_room(client, message)
|
|
49
|
+
elif message_type == "switch_room":
|
|
50
|
+
await self.switch_room(client, message)
|
|
51
|
+
elif message_type == "message":
|
|
52
|
+
await self.add_message(client, message)
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError("unknown chat message type")
|
|
55
|
+
|
|
56
|
+
async def join(self, connection: ServerConnection, message: dict[str, object]):
|
|
57
|
+
name = self.valid_name(message.get("name"))
|
|
58
|
+
if any(client.name == name for client in self.clients.values()):
|
|
59
|
+
raise ValueError("that display name is already in use")
|
|
60
|
+
requested_room = self.normalize_room(message.get("room", "general"))
|
|
61
|
+
room = requested_room if requested_room in self.rooms else "general"
|
|
62
|
+
self.clients[connection] = ChatClient(name, room)
|
|
63
|
+
await self.broadcast_snapshots()
|
|
64
|
+
|
|
65
|
+
async def create_room(self, client: ChatClient, message: dict[str, object]):
|
|
66
|
+
room = self.normalize_room(message.get("room"))
|
|
67
|
+
if room not in self.rooms:
|
|
68
|
+
if len(self.rooms) >= MAX_ROOMS:
|
|
69
|
+
raise ValueError("the room limit has been reached")
|
|
70
|
+
self.rooms[room] = []
|
|
71
|
+
client.room = room
|
|
72
|
+
await self.broadcast_snapshots()
|
|
73
|
+
|
|
74
|
+
async def switch_room(self, client: ChatClient, message: dict[str, object]):
|
|
75
|
+
room = self.normalize_room(message.get("room"))
|
|
76
|
+
if room not in self.rooms:
|
|
77
|
+
raise ValueError("that room does not exist")
|
|
78
|
+
client.room = room
|
|
79
|
+
await self.broadcast_snapshots()
|
|
80
|
+
|
|
81
|
+
async def add_message(self, client: ChatClient, message: dict[str, object]):
|
|
82
|
+
text = message.get("text")
|
|
83
|
+
if not isinstance(text, str):
|
|
84
|
+
raise ValueError("message text must be a string")
|
|
85
|
+
text = text.strip()
|
|
86
|
+
if not text:
|
|
87
|
+
return
|
|
88
|
+
if len(text) > 2000:
|
|
89
|
+
raise ValueError("messages cannot exceed 2000 characters")
|
|
90
|
+
room_messages = self.rooms[client.room]
|
|
91
|
+
room_messages.append(
|
|
92
|
+
{
|
|
93
|
+
"id": self.next_message_id,
|
|
94
|
+
"room": client.room,
|
|
95
|
+
"sender": client.name,
|
|
96
|
+
"text": text,
|
|
97
|
+
"time": datetime.now().strftime("%H:%M"),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
self.next_message_id += 1
|
|
101
|
+
if len(room_messages) > MAX_MESSAGES_PER_ROOM:
|
|
102
|
+
del room_messages[:-MAX_MESSAGES_PER_ROOM]
|
|
103
|
+
await self.broadcast_snapshots()
|
|
104
|
+
|
|
105
|
+
def snapshot(self, client: ChatClient):
|
|
106
|
+
users = sorted(
|
|
107
|
+
candidate.name for candidate in self.clients.values() if candidate.room == client.room
|
|
108
|
+
)
|
|
109
|
+
return {
|
|
110
|
+
"type": "snapshot",
|
|
111
|
+
"room": client.room,
|
|
112
|
+
"rooms": list(self.rooms),
|
|
113
|
+
"messages": self.rooms[client.room],
|
|
114
|
+
"users": users,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async def broadcast_snapshots(self):
|
|
118
|
+
disconnected = []
|
|
119
|
+
for connection, client in list(self.clients.items()):
|
|
120
|
+
try:
|
|
121
|
+
await connection.send(json.dumps(self.snapshot(client)))
|
|
122
|
+
except ConnectionClosed:
|
|
123
|
+
disconnected.append(connection)
|
|
124
|
+
for connection in disconnected:
|
|
125
|
+
self.clients.pop(connection, None)
|
|
126
|
+
|
|
127
|
+
if disconnected:
|
|
128
|
+
for connection, client in list(self.clients.items()):
|
|
129
|
+
try:
|
|
130
|
+
await connection.send(json.dumps(self.snapshot(client)))
|
|
131
|
+
except ConnectionClosed:
|
|
132
|
+
self.clients.pop(connection, None)
|
|
133
|
+
|
|
134
|
+
async def send_error(self, connection: ServerConnection, message: str):
|
|
135
|
+
await connection.send(json.dumps({"type": "error", "message": message}))
|
|
136
|
+
|
|
137
|
+
def valid_name(self, value: object) -> str:
|
|
138
|
+
if not isinstance(value, str):
|
|
139
|
+
raise ValueError("display name must be a string")
|
|
140
|
+
name = value.strip()
|
|
141
|
+
if not name or len(name) > 32:
|
|
142
|
+
raise ValueError("display name must contain 1 to 32 characters")
|
|
143
|
+
return name
|
|
144
|
+
|
|
145
|
+
def normalize_room(self, value: object) -> str:
|
|
146
|
+
if not isinstance(value, str):
|
|
147
|
+
raise ValueError("room name must be a string")
|
|
148
|
+
room = value.strip().lower().replace(" ", "-")
|
|
149
|
+
if not room or len(room) > 32:
|
|
150
|
+
raise ValueError("room name must contain 1 to 32 characters")
|
|
151
|
+
if not all(character.isalnum() or character in "-_" for character in room):
|
|
152
|
+
raise ValueError("room names may contain letters, numbers, hyphens, and underscores")
|
|
153
|
+
return room
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
chat_server = ChatServer()
|