bingo-framework 0.2.0__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 (50) hide show
  1. bingo/__init__.py +44 -0
  2. bingo/application.py +136 -0
  3. bingo/channel_backends.py +163 -0
  4. bingo/channels.js +145 -0
  5. bingo/channels.py +566 -0
  6. bingo/cli/__init__.py +3 -0
  7. bingo/cli/app.py +32 -0
  8. bingo/cli/generate.py +64 -0
  9. bingo/cli/helpers.py +39 -0
  10. bingo/cli/inspect.py +17 -0
  11. bingo/cli/migrate.py +26 -0
  12. bingo/cli/new.py +20 -0
  13. bingo/cli/routes.py +12 -0
  14. bingo/cli/server.py +45 -0
  15. bingo/cli/worker.py +25 -0
  16. bingo/controller.py +47 -0
  17. bingo/conventions/__init__.py +4 -0
  18. bingo/conventions/errors.py +18 -0
  19. bingo/conventions/inspector.py +773 -0
  20. bingo/db/__init__.py +7 -0
  21. bingo/db/database.py +46 -0
  22. bingo/db/fields.py +67 -0
  23. bingo/db/migration.py +171 -0
  24. bingo/db/model.py +100 -0
  25. bingo/db/naming.py +24 -0
  26. bingo/db/query.py +81 -0
  27. bingo/exceptions.py +45 -0
  28. bingo/forms/__init__.py +3 -0
  29. bingo/forms/form.py +144 -0
  30. bingo/generators/__init__.py +11 -0
  31. bingo/generators/channel.py +75 -0
  32. bingo/generators/project.py +495 -0
  33. bingo/generators/resource.py +407 -0
  34. bingo/generators/task.py +46 -0
  35. bingo/management.py +147 -0
  36. bingo/request.py +71 -0
  37. bingo/response.py +3 -0
  38. bingo/routing.py +323 -0
  39. bingo/settings.py +105 -0
  40. bingo/tasks.py +345 -0
  41. bingo/templates/__init__.py +3 -0
  42. bingo/templates/engine.py +175 -0
  43. bingo/validation/__init__.py +4 -0
  44. bingo/validation/rules.py +131 -0
  45. bingo/validation/validator.py +52 -0
  46. bingo_framework-0.2.0.dist-info/METADATA +414 -0
  47. bingo_framework-0.2.0.dist-info/RECORD +50 -0
  48. bingo_framework-0.2.0.dist-info/WHEEL +4 -0
  49. bingo_framework-0.2.0.dist-info/entry_points.txt +2 -0
  50. bingo_framework-0.2.0.dist-info/licenses/LICENSE +21 -0
bingo/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ from bingo.application import Application
2
+ from bingo.channels import Channel, Connection
3
+ from bingo.controller import Controller
4
+ from bingo.exceptions import (
5
+ BingoChannelError,
6
+ BingoChannelRejected,
7
+ BingoConventionError,
8
+ BingoDatabaseError,
9
+ BingoError,
10
+ BingoJSONViewError,
11
+ BingoNotFoundError,
12
+ BingoRouteError,
13
+ BingoTaskError,
14
+ BingoValidationError,
15
+ BingoViewError,
16
+ )
17
+ from bingo.management import BaseCommand, manage
18
+ from bingo.routing import Router
19
+ from bingo.settings import settings
20
+ from bingo.tasks import QueuedTask, Task
21
+
22
+ __all__ = [
23
+ "Application",
24
+ "BaseCommand",
25
+ "BingoChannelError",
26
+ "BingoChannelRejected",
27
+ "BingoConventionError",
28
+ "BingoDatabaseError",
29
+ "BingoError",
30
+ "BingoJSONViewError",
31
+ "BingoNotFoundError",
32
+ "BingoRouteError",
33
+ "BingoTaskError",
34
+ "BingoValidationError",
35
+ "BingoViewError",
36
+ "Channel",
37
+ "Connection",
38
+ "Controller",
39
+ "QueuedTask",
40
+ "Router",
41
+ "Task",
42
+ "manage",
43
+ "settings",
44
+ ]
bingo/application.py ADDED
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+ from urllib.parse import parse_qs
6
+
7
+ from starlette.applications import Starlette
8
+ from starlette.middleware import Middleware
9
+ from starlette.middleware.sessions import SessionMiddleware
10
+ from starlette.responses import PlainTextResponse, Response
11
+ from starlette.routing import Route, WebSocketRoute
12
+
13
+ from bingo.channels import ChannelServer, close_channel_brokers
14
+ from bingo.db.database import database
15
+ from bingo.exceptions import BingoError, BingoNotFoundError, BingoValidationError
16
+ from bingo.routing import Router
17
+ from bingo.settings import settings
18
+ from bingo.templates import TemplateEngine
19
+
20
+
21
+ @asynccontextmanager
22
+ async def application_lifespan(_application):
23
+ yield
24
+
25
+ from bingo.tasks import close_task_queues
26
+
27
+ await close_task_queues()
28
+ await close_channel_brokers()
29
+
30
+
31
+ class MethodOverrideMiddleware:
32
+ """Let ordinary HTML forms reach canonical PATCH and DELETE routes."""
33
+
34
+ def __init__(self, app) -> None:
35
+ self.app = app
36
+
37
+ async def __call__(self, scope, receive, send) -> None:
38
+ headers = dict(scope.get("headers", []))
39
+ content_type = headers.get(b"content-type", b"").decode("latin-1")
40
+ is_form_post = (
41
+ scope["type"] == "http"
42
+ and scope["method"] == "POST"
43
+ and content_type.startswith("application/x-www-form-urlencoded")
44
+ )
45
+ if not is_form_post:
46
+ await self.app(scope, receive, send)
47
+ return
48
+
49
+ body = b""
50
+ more_body = True
51
+ while more_body:
52
+ message = await receive()
53
+ body += message.get("body", b"")
54
+ more_body = message.get("more_body", False)
55
+
56
+ form = parse_qs(body.decode("utf-8"), keep_blank_values=True)
57
+ override = form.get("_method", [""])[-1].upper()
58
+ if override in {"PATCH", "PUT", "DELETE"}:
59
+ scope = {**scope, "method": override}
60
+
61
+ delivered = False
62
+
63
+ async def replay_body():
64
+ nonlocal delivered
65
+ if delivered:
66
+ return {"type": "http.request", "body": b"", "more_body": False}
67
+ delivered = True
68
+ return {"type": "http.request", "body": body, "more_body": False}
69
+
70
+ await self.app(scope, replay_body, send)
71
+
72
+
73
+ class Application:
74
+ def __init__(
75
+ self,
76
+ routes: Router | None = None,
77
+ *,
78
+ root_path: str | Path | None = None,
79
+ ) -> None:
80
+ self.root_path = Path(root_path or Path.cwd()).resolve()
81
+ self.settings = settings.load(self.root_path)
82
+ self.router = routes or Router()
83
+ self.templates = TemplateEngine(self.root_path / "app" / "views")
84
+
85
+ if self.settings.DATABASE_URL:
86
+ database.configure(self.settings.DATABASE_URL)
87
+
88
+ middleware = [
89
+ Middleware(SessionMiddleware, secret_key=self.settings.SECRET_KEY)
90
+ ]
91
+ channel_routes = self._channel_routes()
92
+ starlette = Starlette(
93
+ debug=self.settings.DEBUG,
94
+ routes=[*channel_routes, *self.router.starlette_routes(self)],
95
+ middleware=middleware,
96
+ lifespan=application_lifespan,
97
+ exception_handlers={
98
+ BingoNotFoundError: self._not_found,
99
+ BingoValidationError: self._validation_error,
100
+ BingoError: self._bingo_error,
101
+ },
102
+ )
103
+ self.asgi = MethodOverrideMiddleware(starlette)
104
+
105
+ def _channel_routes(self):
106
+ path = self.settings.CHANNEL_PATH
107
+ is_valid_path = isinstance(path, str) and path.startswith("/")
108
+ is_valid_path = is_valid_path and not path.startswith("//")
109
+ is_valid_path = is_valid_path and not path.endswith("/") and path != "/"
110
+ if not is_valid_path:
111
+ raise BingoError(
112
+ "CHANNEL_PATH must start with '/' and must not end with '/'."
113
+ )
114
+
115
+ javascript = Path(__file__).with_name("channels.js").read_text(encoding="utf-8")
116
+
117
+ async def client(_request):
118
+ return Response(javascript, media_type="application/javascript")
119
+
120
+ channel_server = ChannelServer(self.root_path)
121
+ return [
122
+ Route(f"{path}.js", client, methods=["GET"]),
123
+ WebSocketRoute(path, channel_server.handle),
124
+ ]
125
+
126
+ async def __call__(self, scope, receive, send) -> None:
127
+ await self.asgi(scope, receive, send)
128
+
129
+ async def _not_found(self, request, error: BingoNotFoundError):
130
+ return PlainTextResponse(str(error), status_code=404)
131
+
132
+ async def _validation_error(self, request, error: BingoValidationError):
133
+ return PlainTextResponse(str(error), status_code=422)
134
+
135
+ async def _bingo_error(self, request, error: BingoError):
136
+ return PlainTextResponse(str(error), status_code=500)
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ from collections.abc import AsyncIterator
7
+ from typing import Any
8
+
9
+ from bingo.exceptions import BingoChannelError
10
+
11
+
12
+ class MemorySubscription:
13
+ def __init__(
14
+ self,
15
+ backend: MemoryChannelBackend,
16
+ stream: str,
17
+ queue: asyncio.Queue,
18
+ ) -> None:
19
+ self.backend = backend
20
+ self.stream = stream
21
+ self.queue = queue
22
+
23
+ async def messages(self) -> AsyncIterator[dict[str, Any]]:
24
+ while True:
25
+ yield await self.queue.get()
26
+
27
+ async def close(self) -> None:
28
+ subscribers = self.backend.subscribers.get(self.stream, set())
29
+ subscribers.discard(self.queue)
30
+ if not subscribers:
31
+ self.backend.subscribers.pop(self.stream, None)
32
+
33
+
34
+ class MemoryChannelBackend:
35
+ def __init__(self) -> None:
36
+ self.subscribers: dict[str, set[asyncio.Queue]] = {}
37
+
38
+ async def subscribe(self, stream: str) -> MemorySubscription:
39
+ queue: asyncio.Queue = asyncio.Queue()
40
+ self.subscribers.setdefault(stream, set()).add(queue)
41
+ return MemorySubscription(self, stream, queue)
42
+
43
+ async def publish(self, stream: str, message: dict[str, Any]) -> None:
44
+ for queue in self.subscribers.get(stream, set()):
45
+ queue.put_nowait(message)
46
+
47
+ async def close(self) -> None:
48
+ self.subscribers.clear()
49
+
50
+
51
+ class RedisSubscription:
52
+ def __init__(self, pubsub, stream: str) -> None:
53
+ self.pubsub = pubsub
54
+ self.stream = stream
55
+
56
+ async def messages(self) -> AsyncIterator[dict[str, Any]]:
57
+ async for message in self.pubsub.listen():
58
+ if message["type"] != "message":
59
+ continue
60
+ try:
61
+ yield json.loads(message["data"])
62
+ except (TypeError, ValueError) as error:
63
+ raise BingoChannelError(
64
+ f"Channel stream {self.stream!r} received invalid JSON."
65
+ ) from error
66
+
67
+ async def close(self) -> None:
68
+ await self.pubsub.unsubscribe(self.stream)
69
+ await self.pubsub.aclose()
70
+
71
+
72
+ class RedisChannelBackend:
73
+ def __init__(self, url: str) -> None:
74
+ from redis.asyncio import Redis
75
+
76
+ self.client = Redis.from_url(url, decode_responses=True)
77
+
78
+ async def subscribe(self, stream: str) -> RedisSubscription:
79
+ pubsub = self.client.pubsub()
80
+ await pubsub.subscribe(stream)
81
+ return RedisSubscription(pubsub, stream)
82
+
83
+ async def publish(self, stream: str, message: dict[str, Any]) -> None:
84
+ await self.client.publish(stream, json.dumps(message, allow_nan=False))
85
+
86
+ async def close(self) -> None:
87
+ await self.client.aclose()
88
+
89
+
90
+ class PostgresSubscription:
91
+ def __init__(self, connection, channel: str, stream: str) -> None:
92
+ self.connection = connection
93
+ self.channel = channel
94
+ self.stream = stream
95
+
96
+ async def messages(self) -> AsyncIterator[dict[str, Any]]:
97
+ async for notification in self.connection.notifies():
98
+ try:
99
+ yield json.loads(notification.payload)
100
+ except (TypeError, ValueError) as error:
101
+ raise BingoChannelError(
102
+ f"Channel stream {self.stream!r} received invalid JSON."
103
+ ) from error
104
+
105
+ async def close(self) -> None:
106
+ await self.connection.close()
107
+
108
+
109
+ class PostgresChannelBackend:
110
+ def __init__(self, url: str) -> None:
111
+ self.url = url
112
+ self.publisher = None
113
+ self.publisher_lock = asyncio.Lock()
114
+
115
+ async def subscribe(self, stream: str) -> PostgresSubscription:
116
+ from psycopg import AsyncConnection, sql
117
+
118
+ connection = await AsyncConnection.connect(self.url, autocommit=True)
119
+ channel = _postgres_channel(stream)
120
+ statement = sql.SQL("LISTEN {}").format(sql.Identifier(channel))
121
+ await connection.execute(statement)
122
+ return PostgresSubscription(connection, channel, stream)
123
+
124
+ async def publish(self, stream: str, message: dict[str, Any]) -> None:
125
+ from psycopg import AsyncConnection
126
+
127
+ payload = json.dumps(message, allow_nan=False)
128
+ if len(payload.encode("utf-8")) >= 8_000:
129
+ raise BingoChannelError(
130
+ "PostgreSQL channel broadcasts must be smaller than 8,000 bytes."
131
+ )
132
+
133
+ async with self.publisher_lock:
134
+ if self.publisher is None or self.publisher.closed:
135
+ self.publisher = await AsyncConnection.connect(
136
+ self.url,
137
+ autocommit=True,
138
+ )
139
+ channel = _postgres_channel(stream)
140
+ await self.publisher.execute(
141
+ "SELECT pg_notify(%s, %s)",
142
+ (channel, payload),
143
+ )
144
+
145
+ async def close(self) -> None:
146
+ if self.publisher is not None and not self.publisher.closed:
147
+ await self.publisher.close()
148
+
149
+
150
+ def channel_backend(url: str):
151
+ scheme = url.split(":", 1)[0].lower()
152
+ if scheme == "memory":
153
+ return MemoryChannelBackend()
154
+ if scheme in {"redis", "rediss"}:
155
+ return RedisChannelBackend(url)
156
+ if scheme in {"postgres", "postgresql"}:
157
+ return PostgresChannelBackend(url)
158
+ raise BingoChannelError("CHANNEL_URL supports memory, Redis, and PostgreSQL URLs.")
159
+
160
+
161
+ def _postgres_channel(stream: str) -> str:
162
+ digest = hashlib.sha256(stream.encode("utf-8")).hexdigest()[:48]
163
+ return f"bingo_{digest}"
bingo/channels.js ADDED
@@ -0,0 +1,145 @@
1
+ (function (global) {
2
+ "use strict";
3
+
4
+ class Subscription {
5
+ constructor(consumer, identifier, channel, params, callbacks) {
6
+ this.consumer = consumer;
7
+ this.identifier = identifier;
8
+ this.channel = channel;
9
+ this.params = params;
10
+ Object.assign(this, callbacks || {});
11
+ }
12
+
13
+ send(data) {
14
+ this.consumer.send({
15
+ command: "message",
16
+ identifier: this.identifier,
17
+ data: data,
18
+ });
19
+ }
20
+
21
+ unsubscribe() {
22
+ this.consumer.unsubscribe(this.identifier);
23
+ }
24
+ }
25
+
26
+ class ChannelConsumer {
27
+ constructor(path) {
28
+ this.path = path || "/channels";
29
+ this.socket = null;
30
+ this.subscriptions = new Map();
31
+ this.nextIdentifier = 1;
32
+ this.reconnectDelay = 500;
33
+ this.reconnectTimer = null;
34
+ }
35
+
36
+ subscribe(channel, params, callbacks) {
37
+ const identifier = String(this.nextIdentifier++);
38
+ const subscription = new Subscription(
39
+ this,
40
+ identifier,
41
+ channel,
42
+ params || {},
43
+ callbacks,
44
+ );
45
+ this.subscriptions.set(identifier, subscription);
46
+ this.connect();
47
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
48
+ this.sendSubscription(subscription);
49
+ }
50
+ return subscription;
51
+ }
52
+
53
+ connect() {
54
+ const isConnecting = this.socket && (
55
+ this.socket.readyState === WebSocket.OPEN ||
56
+ this.socket.readyState === WebSocket.CONNECTING
57
+ );
58
+ if (isConnecting) return;
59
+
60
+ const protocol = global.location.protocol === "https:" ? "wss:" : "ws:";
61
+ const url = `${protocol}//${global.location.host}${this.path}`;
62
+ this.socket = new WebSocket(url);
63
+ this.socket.addEventListener("open", () => this.opened());
64
+ this.socket.addEventListener("message", (event) => this.received(event));
65
+ this.socket.addEventListener("close", () => this.closed());
66
+ }
67
+
68
+ opened() {
69
+ this.reconnectDelay = 500;
70
+ for (const subscription of this.subscriptions.values()) {
71
+ this.sendSubscription(subscription);
72
+ }
73
+ }
74
+
75
+ received(event) {
76
+ let message;
77
+ try {
78
+ message = JSON.parse(event.data);
79
+ } catch (_error) {
80
+ return;
81
+ }
82
+
83
+ const subscription = this.subscriptions.get(message.identifier);
84
+ if (!subscription) return;
85
+
86
+ if (message.type === "subscribed" && subscription.connected) {
87
+ subscription.connected();
88
+ } else if (message.type === "message" && subscription.received) {
89
+ subscription.received(message.event, message.data);
90
+ } else if (message.type === "rejected") {
91
+ this.subscriptions.delete(message.identifier);
92
+ if (subscription.rejected) subscription.rejected(message.error);
93
+ if (this.subscriptions.size === 0 && this.socket) this.socket.close();
94
+ } else if (message.type === "error" && subscription.errored) {
95
+ subscription.errored(message.error);
96
+ }
97
+ }
98
+
99
+ closed() {
100
+ this.socket = null;
101
+ for (const subscription of this.subscriptions.values()) {
102
+ if (subscription.disconnected) subscription.disconnected();
103
+ }
104
+ if (this.subscriptions.size === 0) return;
105
+
106
+ clearTimeout(this.reconnectTimer);
107
+ this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
108
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000);
109
+ }
110
+
111
+ unsubscribe(identifier) {
112
+ if (!this.subscriptions.has(identifier)) return;
113
+ this.send({ command: "unsubscribe", identifier: identifier });
114
+ this.subscriptions.delete(identifier);
115
+ if (this.subscriptions.size === 0 && this.socket) {
116
+ this.socket.close();
117
+ }
118
+ }
119
+
120
+ sendSubscription(subscription) {
121
+ this.send({
122
+ command: "subscribe",
123
+ identifier: subscription.identifier,
124
+ channel: subscription.channel,
125
+ params: subscription.params,
126
+ });
127
+ }
128
+
129
+ send(message) {
130
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
131
+ throw new Error("The Bingo channel connection is not open.");
132
+ }
133
+ this.socket.send(JSON.stringify(message));
134
+ }
135
+ }
136
+
137
+ const Bingo = global.Bingo || {};
138
+ const script = global.document.currentScript;
139
+ const defaultPath = script
140
+ ? new URL(script.src, global.location.href).pathname.replace(/\.js$/, "")
141
+ : "/channels";
142
+ Bingo.channels = new ChannelConsumer(defaultPath);
143
+ Bingo.ChannelConsumer = ChannelConsumer;
144
+ global.Bingo = Bingo;
145
+ })(window);