flux-sockets 0.6.4__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.
flux/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .server import Server, ServerClient, Room
2
+ from .client import Client
3
+
4
+ __all__ = ["Server", "ServerClient", "Room", "Client"]
5
+ __version__ = "0.1.0"
flux/client.py ADDED
@@ -0,0 +1,115 @@
1
+ import socket
2
+ import threading
3
+
4
+ from .events import Events
5
+ from .protocol import ConnectionClosed, Reader, TCPTransport, decode, encode
6
+
7
+
8
+ class Client(Events):
9
+ """Python client. Emits made before the connection is up are queued and sent on connect."""
10
+
11
+ def __init__(self, host="localhost", port=5000, token=None, reconnect=True,
12
+ reconnect_delay=1.0, max_delay=15.0, connect_timeout=10.0):
13
+ super().__init__()
14
+ self.host, self.port, self.token = host, port, token
15
+ self.reconnect = reconnect
16
+ self.reconnect_delay, self.max_delay = reconnect_delay, max_delay
17
+ self.connect_timeout = connect_timeout
18
+ self.id = None
19
+ self.connected = False
20
+ self._transport = None
21
+ self._pending = []
22
+ self._lock = threading.RLock()
23
+ self._stop = threading.Event()
24
+ self._ready = threading.Event()
25
+ self._thread = None
26
+
27
+ # public API
28
+ def emit(self, event, data=None):
29
+ text = encode(event, data)
30
+ with self._lock:
31
+ if self.connected and self._transport:
32
+ try:
33
+ self._transport.send(text)
34
+ return True
35
+ except OSError:
36
+ pass
37
+ if len(self._pending) < 1000:
38
+ self._pending.append(text)
39
+ return False
40
+
41
+ def run(self):
42
+ """Block, connecting (and reconnecting) until close() is called."""
43
+ self._stop.clear()
44
+ delay = self.reconnect_delay
45
+ while not self._stop.is_set():
46
+ try:
47
+ sock = socket.create_connection((self.host, self.port), self.connect_timeout)
48
+ except OSError as e:
49
+ if not self.reconnect:
50
+ raise ConnectionError(f"could not connect to {self.host}:{self.port}") from e
51
+ if self._stop.wait(delay):
52
+ break
53
+ delay = min(delay * 2, self.max_delay)
54
+ continue
55
+ delay = self.reconnect_delay
56
+ sock.settimeout(None)
57
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
58
+ transport = TCPTransport(sock, Reader(sock))
59
+ self._transport = transport
60
+ try:
61
+ transport.send(encode("__hello"))
62
+ if self.token is not None:
63
+ transport.send(encode("auth", self.token))
64
+ while True:
65
+ event, data = decode(transport.recv())
66
+ self._handle(event, data)
67
+ except (ConnectionClosed, OSError):
68
+ pass
69
+ finally:
70
+ with self._lock:
71
+ was, self.connected = self.connected, False
72
+ self._transport = None
73
+ self._ready.clear()
74
+ transport.close()
75
+ if was:
76
+ self._fire("disconnect")
77
+ if not self.reconnect or self._stop.wait(delay):
78
+ break
79
+
80
+ def start(self):
81
+ """Run in a background thread."""
82
+ self._thread = threading.Thread(target=self.run, daemon=True)
83
+ self._thread.start()
84
+ return self
85
+
86
+ def wait_connected(self, timeout=None):
87
+ return self._ready.wait(timeout)
88
+
89
+ def close(self):
90
+ self._stop.set()
91
+ t = self._transport
92
+ if t:
93
+ t.close()
94
+
95
+ # internals
96
+ def _handle(self, event, data):
97
+ if event == "__ping":
98
+ try:
99
+ self._transport.send(encode("__pong"))
100
+ except OSError:
101
+ pass
102
+ return
103
+ if event == "welcome":
104
+ self.id = data.get("id") if isinstance(data, dict) else None
105
+ with self._lock:
106
+ while self._pending:
107
+ self._transport.send(self._pending[0])
108
+ self._pending.pop(0)
109
+ self.connected = True
110
+ self._ready.set()
111
+ self._fire("connect")
112
+ elif event == "auth_failed":
113
+ self._stop.set()
114
+ self._fire("*", event, data)
115
+ self._fire(event, data)
flux/events.py ADDED
@@ -0,0 +1,36 @@
1
+ import inspect
2
+ import traceback
3
+
4
+
5
+ def arity(fn):
6
+ """How many positional args a handler accepts, so handlers can take fewer args."""
7
+ try:
8
+ params = inspect.signature(fn).parameters.values()
9
+ except (TypeError, ValueError):
10
+ return 99
11
+ n = 0
12
+ for p in params:
13
+ if p.kind == p.VAR_POSITIONAL:
14
+ return 99
15
+ if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD):
16
+ n += 1
17
+ return n
18
+
19
+
20
+ class Events:
21
+ def __init__(self):
22
+ self._handlers = {}
23
+
24
+ def on(self, event, fn=None):
25
+ """Use as @obj.on("event") or obj.on("event", fn)."""
26
+ def register(f):
27
+ self._handlers.setdefault(event, []).append((f, arity(f)))
28
+ return f
29
+ return register(fn) if fn else register
30
+
31
+ def _fire(self, event, *args):
32
+ for fn, n in list(self._handlers.get(event, ())):
33
+ try:
34
+ fn(*args[:n])
35
+ except Exception:
36
+ traceback.print_exc()
flux/protocol.py ADDED
@@ -0,0 +1,187 @@
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import socket
5
+ import struct
6
+ import threading
7
+ import time
8
+
9
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
10
+ MAX_MESSAGE = 1 << 20 # 1 MiB
11
+
12
+
13
+ class ConnectionClosed(Exception):
14
+ pass
15
+
16
+
17
+ def encode(event, data=None):
18
+ return json.dumps(
19
+ {"event": event, "data": data}, separators=(",", ":"), default=str
20
+ )
21
+
22
+
23
+ def decode(text):
24
+ try:
25
+ obj = json.loads(text)
26
+ except ValueError:
27
+ return "message", text
28
+ if isinstance(obj, dict) and isinstance(obj.get("event"), str):
29
+ return obj["event"], obj.get("data")
30
+ return "message", obj
31
+
32
+
33
+ class Reader:
34
+ def __init__(self, sock):
35
+ self.sock = sock
36
+ self.buf = bytearray()
37
+ self.last_activity = time.monotonic()
38
+
39
+ def _fill(self):
40
+ try:
41
+ chunk = self.sock.recv(65536)
42
+ except OSError as e:
43
+ raise ConnectionClosed(str(e))
44
+ if not chunk:
45
+ raise ConnectionClosed()
46
+ self.last_activity = time.monotonic()
47
+ self.buf += chunk
48
+
49
+ def read_exact(self, n):
50
+ while len(self.buf) < n:
51
+ self._fill()
52
+ out = bytes(self.buf[:n])
53
+ del self.buf[:n]
54
+ return out
55
+
56
+ def read_until(self, delim, limit=MAX_MESSAGE):
57
+ while True:
58
+ i = self.buf.find(delim)
59
+ if i != -1:
60
+ out = bytes(self.buf[:i])
61
+ del self.buf[: i + len(delim)]
62
+ return out
63
+ if len(self.buf) > limit:
64
+ raise ConnectionClosed("message too large")
65
+ self._fill()
66
+
67
+
68
+ class _Transport:
69
+ def __init__(self, sock, reader):
70
+ self.sock = sock
71
+ self.reader = reader
72
+ self._wlock = threading.Lock()
73
+
74
+ def _write(self, data):
75
+ with self._wlock:
76
+ self.sock.sendall(data)
77
+
78
+ def close(self):
79
+ for fn in (lambda: self.sock.shutdown(socket.SHUT_RDWR), self.sock.close):
80
+ try:
81
+ fn()
82
+ except OSError:
83
+ pass
84
+
85
+
86
+ class TCPTransport(_Transport):
87
+ kind = "tcp"
88
+
89
+ def recv(self):
90
+ while True:
91
+ line = self.reader.read_until(b"\n").strip()
92
+ if line:
93
+ return line.decode("utf-8", "replace")
94
+
95
+ def send(self, text):
96
+ self._write(text.encode() + b"\n")
97
+
98
+ def ping(self):
99
+ self.send(encode("__ping"))
100
+
101
+
102
+ def _unmask(data, mask):
103
+ if not data:
104
+ return data
105
+ m = (mask * (len(data) // 4 + 1))[: len(data)]
106
+ return (int.from_bytes(data, "big") ^ int.from_bytes(m, "big")).to_bytes(
107
+ len(data), "big"
108
+ )
109
+
110
+
111
+ class WSTransport(_Transport):
112
+ kind = "websocket"
113
+
114
+ @staticmethod
115
+ def _frame(opcode, payload=b""):
116
+ n = len(payload)
117
+ head = bytes([0x80 | opcode])
118
+ if n < 126:
119
+ head += bytes([n])
120
+ elif n < 65536:
121
+ head += b"\x7e" + struct.pack(">H", n)
122
+ else:
123
+ head += b"\x7f" + struct.pack(">Q", n)
124
+ return head + payload
125
+
126
+ def send(self, text):
127
+ self._write(self._frame(1, text.encode()))
128
+
129
+ def ping(self):
130
+ self._write(self._frame(9))
131
+
132
+ def recv(self):
133
+ r = self.reader
134
+ message = bytearray()
135
+ while True:
136
+ b1, b2 = r.read_exact(2)
137
+ fin, op = b1 & 0x80, b1 & 0x0F
138
+ n = b2 & 0x7F
139
+ if n == 126:
140
+ n = struct.unpack(">H", r.read_exact(2))[0]
141
+ elif n == 127:
142
+ n = struct.unpack(">Q", r.read_exact(8))[0]
143
+ if n > MAX_MESSAGE:
144
+ raise ConnectionClosed("frame too large")
145
+ mask = r.read_exact(4) if b2 & 0x80 else None
146
+ payload = r.read_exact(n)
147
+ if mask:
148
+ payload = _unmask(payload, mask)
149
+ if op == 8:
150
+ try:
151
+ self._write(self._frame(8, payload[:2]))
152
+ except OSError:
153
+ pass
154
+ raise ConnectionClosed()
155
+ if op == 9:
156
+ self._write(self._frame(10, payload))
157
+ continue
158
+ if op == 10:
159
+ continue
160
+ message += payload
161
+ if len(message) > MAX_MESSAGE:
162
+ raise ConnectionClosed("message too large")
163
+ if fin:
164
+ return message.decode("utf-8", "replace")
165
+
166
+
167
+ def ws_server_handshake(sock, reader):
168
+ head = reader.read_until(b"\r\n\r\n", 16384).decode("latin-1")
169
+ headers = {}
170
+ for line in head.split("\r\n")[1:]:
171
+ if ":" in line:
172
+ k, v = line.split(":", 1)
173
+ headers[k.strip().lower()] = v.strip()
174
+ key = headers.get("sec-websocket-key")
175
+ if not key:
176
+ sock.sendall(
177
+ b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nWebSocket only."
178
+ )
179
+ raise ConnectionClosed("not a websocket request")
180
+ accept = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()
181
+ sock.sendall(
182
+ (
183
+ "HTTP/1.1 101 Switching Protocols\r\n"
184
+ "Upgrade: websocket\r\nConnection: Upgrade\r\n"
185
+ f"Sec-WebSocket-Accept: {accept}\r\n\r\n"
186
+ ).encode()
187
+ )
flux/server.py ADDED
@@ -0,0 +1,287 @@
1
+ import socket
2
+ import threading
3
+ import time
4
+ import traceback
5
+ import uuid
6
+
7
+ from .events import Events, arity
8
+ from .protocol import (ConnectionClosed, Reader, TCPTransport, WSTransport,
9
+ decode, encode, ws_server_handshake)
10
+
11
+ _MISSING = object()
12
+
13
+
14
+ class ServerClient:
15
+ """One connected peer (TCP or WebSocket) as seen by the server."""
16
+
17
+ def __init__(self, server, transport, address):
18
+ self.server = server
19
+ self.transport = transport
20
+ self.address = address
21
+ self.id = uuid.uuid4().hex[:8]
22
+ self.name = "client-" + self.id
23
+ self.data = {}
24
+ self.rooms = set()
25
+ self.connected = True
26
+ self.authenticated = False
27
+ self.connected_at = time.monotonic()
28
+
29
+ @property
30
+ def kind(self):
31
+ return self.transport.kind
32
+
33
+ @property
34
+ def last_seen(self):
35
+ return self.transport.reader.last_activity
36
+
37
+ def send(self, event, data=_MISSING):
38
+ """client.send(data) -> "message" event, or client.send("event", data)."""
39
+ if data is _MISSING:
40
+ event, data = "message", event
41
+ return self._send_text(encode(event, data))
42
+
43
+ def emit(self, event, data=None):
44
+ return self._send_text(encode(event, data))
45
+
46
+ def _send_text(self, text):
47
+ if not self.connected:
48
+ return False
49
+ try:
50
+ self.transport.send(text)
51
+ return True
52
+ except OSError:
53
+ self.close()
54
+ return False
55
+
56
+ def join(self, room):
57
+ return self.server.room(room).join(self)
58
+
59
+ def leave(self, room):
60
+ r = self.server._rooms.get(room)
61
+ if r:
62
+ r.leave(self)
63
+
64
+ def close(self):
65
+ self.connected = False
66
+ self.transport.close()
67
+
68
+ def __repr__(self):
69
+ return f"<Client {self.id} {self.kind} {self.address[0]}>"
70
+
71
+
72
+ class Room:
73
+ def __init__(self, server, name):
74
+ self.server = server
75
+ self.name = name
76
+ self._members = {}
77
+
78
+ @property
79
+ def clients(self):
80
+ with self.server._lock:
81
+ return list(self._members.values())
82
+
83
+ def join(self, client):
84
+ with self.server._lock:
85
+ self._members[client.id] = client
86
+ client.rooms.add(self.name)
87
+ self.server._rooms[self.name] = self
88
+ return self
89
+
90
+ def leave(self, client):
91
+ with self.server._lock:
92
+ self._members.pop(client.id, None)
93
+ client.rooms.discard(self.name)
94
+ if not self._members and self.server._rooms.get(self.name) is self:
95
+ del self.server._rooms[self.name]
96
+
97
+ def emit(self, event, data=None, exclude=None):
98
+ text = encode(event, data)
99
+ for c in self.clients:
100
+ if exclude is None or c is not exclude:
101
+ c._send_text(text)
102
+
103
+ def __len__(self):
104
+ return len(self._members)
105
+
106
+ def __contains__(self, client):
107
+ return client.id in self._members
108
+
109
+ def __repr__(self):
110
+ return f"<Room {self.name!r} ({len(self)})>"
111
+
112
+
113
+ class Server(Events):
114
+ def __init__(self, port=5000, host="0.0.0.0", heartbeat=15, timeout=45,
115
+ sniff_timeout=0.5, auth_timeout=10, quiet=False):
116
+ super().__init__()
117
+ self.host, self.port = host, port
118
+ self.heartbeat, self.timeout = heartbeat, timeout
119
+ self.sniff_timeout, self.auth_timeout = sniff_timeout, auth_timeout
120
+ self.quiet = quiet
121
+ self.clients = {}
122
+ self._rooms = {}
123
+ self._lock = threading.RLock()
124
+ self._stop = threading.Event()
125
+ self._listener = None
126
+ self._auth = None
127
+
128
+ # public API
129
+ def auth(self, fn):
130
+ """@server.auth def check(client, token): return True/False"""
131
+ self._auth = (fn, arity(fn))
132
+ return fn
133
+
134
+ def room(self, name):
135
+ with self._lock:
136
+ return self._rooms.get(name) or Room(self, name)
137
+
138
+ def broadcast(self, event, data=None, exclude=None):
139
+ text = encode(event, data)
140
+ with self._lock:
141
+ targets = list(self.clients.values())
142
+ for c in targets:
143
+ if c.authenticated and (exclude is None or c is not exclude):
144
+ c._send_text(text)
145
+
146
+ def run(self):
147
+ """Block and serve until Ctrl+C."""
148
+ self._bind()
149
+ self._log(f"flux listening on {self.host}:{self.port} (tcp + websocket)")
150
+ threading.Thread(target=self._monitor, daemon=True).start()
151
+ try:
152
+ self._accept_loop()
153
+ except KeyboardInterrupt:
154
+ pass
155
+ finally:
156
+ self.stop()
157
+
158
+ def start(self):
159
+ """Serve in a background thread and return immediately."""
160
+ self._bind()
161
+ threading.Thread(target=self._monitor, daemon=True).start()
162
+ threading.Thread(target=self._accept_loop, daemon=True).start()
163
+ return self
164
+
165
+ def stop(self):
166
+ self._stop.set()
167
+ if self._listener:
168
+ try:
169
+ self._listener.close()
170
+ except OSError:
171
+ pass
172
+ for c in list(self.clients.values()):
173
+ c.close()
174
+
175
+ # internals
176
+ def _log(self, *a):
177
+ if not self.quiet:
178
+ print(*a)
179
+
180
+ def _bind(self):
181
+ s = socket.socket()
182
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
183
+ s.bind((self.host, self.port))
184
+ s.listen(128)
185
+ s.settimeout(0.5)
186
+ self.port = s.getsockname()[1]
187
+ self._listener = s
188
+ self._stop.clear()
189
+
190
+ def _accept_loop(self):
191
+ while not self._stop.is_set():
192
+ try:
193
+ sock, addr = self._listener.accept()
194
+ except socket.timeout:
195
+ continue
196
+ except OSError:
197
+ break
198
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
199
+ threading.Thread(target=self._serve, args=(sock, addr), daemon=True).start()
200
+
201
+ def _monitor(self):
202
+ while not self._stop.wait(self.heartbeat):
203
+ now = time.monotonic()
204
+ for c in list(self.clients.values()):
205
+ if now - c.last_seen > self.timeout:
206
+ c.close()
207
+ elif not c.authenticated and now - c.connected_at > self.auth_timeout:
208
+ c.close()
209
+ else:
210
+ try:
211
+ c.transport.ping()
212
+ except OSError:
213
+ c.close()
214
+
215
+ def _negotiate(self, sock):
216
+ reader = Reader(sock)
217
+ sock.settimeout(self.sniff_timeout)
218
+ try:
219
+ first = sock.recv(4, socket.MSG_PEEK)
220
+ if not first:
221
+ raise ConnectionClosed()
222
+ except socket.timeout:
223
+ first = b""
224
+ except OSError:
225
+ raise ConnectionClosed()
226
+ sock.settimeout(10)
227
+ if first[:3] == b"GET":
228
+ ws_server_handshake(sock, reader)
229
+ sock.settimeout(None)
230
+ return WSTransport(sock, reader)
231
+ sock.settimeout(None)
232
+ return TCPTransport(sock, reader)
233
+
234
+ def _admit(self, client):
235
+ client.authenticated = True
236
+ client.emit("welcome", {"id": client.id})
237
+ self._fire("connect", client)
238
+ return True
239
+
240
+ def _serve(self, sock, address):
241
+ try:
242
+ transport = self._negotiate(sock)
243
+ except (ConnectionClosed, OSError):
244
+ sock.close()
245
+ return
246
+ client = ServerClient(self, transport, address)
247
+ with self._lock:
248
+ self.clients[client.id] = client
249
+ announced = False
250
+ try:
251
+ if self._auth is None:
252
+ announced = self._admit(client)
253
+ else:
254
+ client.emit("auth_required")
255
+ while True:
256
+ event, data = decode(transport.recv())
257
+ if event in ("__pong", "__hello"):
258
+ continue
259
+ if not client.authenticated:
260
+ if event != "auth":
261
+ continue
262
+ fn, n = self._auth
263
+ ok = False
264
+ try:
265
+ ok = bool(fn(*(client, data)[:n]))
266
+ except Exception:
267
+ traceback.print_exc()
268
+ if not ok:
269
+ client.emit("auth_failed")
270
+ break
271
+ announced = self._admit(client)
272
+ continue
273
+ self._fire("*", client, event, data)
274
+ self._fire(event, client, data)
275
+ except ConnectionClosed:
276
+ pass
277
+ except Exception:
278
+ traceback.print_exc()
279
+ finally:
280
+ client.connected = False
281
+ for r in list(client.rooms):
282
+ client.leave(r)
283
+ with self._lock:
284
+ self.clients.pop(client.id, None)
285
+ transport.close()
286
+ if announced:
287
+ self._fire("disconnect", client)
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: flux-sockets
3
+ Version: 0.6.4
4
+ Summary: Dead-simple sockets: events, rooms, JSON and WebSockets in a few lines.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/TheOrangeCow/flux
7
+ Project-URL: Repository, https://github.com/TheOrangeCow/flux
8
+ Project-URL: Issues, https://github.com/TheOrangeCow/flux/issues
9
+ Keywords: sockets,networking,websockets,events,multiplayer,realtime
10
+ Requires-Python: >=3.8
11
+ License-File: LICENSE
12
+ Dynamic: license-file
@@ -0,0 +1,10 @@
1
+ flux/__init__.py,sha256=slUgf5Krb7wzVIXpx0OWDXj0FkrsMqOwtGFMzlM8vWM,152
2
+ flux/client.py,sha256=xGnhk2-YW60lG-WUg86umBj3Ns_IsD0Ag9xey2wwkss,4088
3
+ flux/events.py,sha256=KCWVXLshzwsKfwWQ0vsH4i2z2qgnJTHjsbr0aoKZf_Y,988
4
+ flux/protocol.py,sha256=7UGXY-YX3eSJA3J8myj-a31ddhEEWmaCcR6G5L6njhw,5131
5
+ flux/server.py,sha256=BMkBDO6DP77Pz5jALIZemUVvyCDkoN0ujam9VkNYLCY,8895
6
+ flux_sockets-0.6.4.dist-info/licenses/LICENSE,sha256=v6-ByutdA9HMF3T0mHfLOHSqi5TsddF6tahFwjNIiLo,1090
7
+ flux_sockets-0.6.4.dist-info/METADATA,sha256=5kVzbaGRa0vnlF6tsjfLaIbsPPhfxL6Bo8fFopCnT9Q,495
8
+ flux_sockets-0.6.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ flux_sockets-0.6.4.dist-info/top_level.txt,sha256=9txNc7btJudkur68qMJ7nT64gBMfFJm2dFvfYLaFBc4,5
10
+ flux_sockets-0.6.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TheOrangeCow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ flux