artystd 0.0.2__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.
artystd-0.0.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arty
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.
artystd-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.5
2
+ Name: artystd
3
+ Version: 0.0.2
4
+ Summary: Arty's standard library
5
+ Project-URL: Homepage, https://github.com/Artytuihj/Artys_STD_LIB
6
+ Project-URL: Issues, https://github.com/Artytuihj/Artys_STD_LIB/issues/
7
+ Author: Artytuihj
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: stdlib
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Provides-Extra: all
15
+ Requires-Dist: artys-std[servernet]; extra == 'all'
16
+ Provides-Extra: clientnet
17
+ Requires-Dist: cryptography>=42; extra == 'clientnet'
18
+ Requires-Dist: requests>=2.31; extra == 'clientnet'
19
+ Provides-Extra: servernet
20
+ Requires-Dist: artys-std[clientnet]; extra == 'servernet'
21
+ Requires-Dist: fastapi>=0.110; extra == 'servernet'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Arty's STD LIB
25
+
26
+ A collection of commonly used modules shared across all of My projects.
27
+
28
+ ## Modules
29
+
30
+ | Module | Full name | What it does |
31
+ |--------|-----------|--------------|
32
+ | **ASHTP** | Arty's Secure HTTP Transport Protocol | Custom HTTP wrapper for environments where TLS and HTTPS aren't available |
33
+ | **Events Manager** | Arty's Events Manager | Subscribe to and invoke events across threads and scripts |
34
+ | **CAP** | Command and Argument Parser | Define commands along with their arguments and flags |
35
+
36
+ ---
37
+
38
+ ### ASHTP
39
+ Custom HTTP wrapper for use when TLS and HTTPS aren't available.
40
+
41
+ ### Events Manager
42
+ Lets events be subscribed to and invoked from different threads and scripts.
43
+
44
+ ### CAP
45
+ Lets you define commands and their arguments and flags.
@@ -0,0 +1,22 @@
1
+ # Arty's STD LIB
2
+
3
+ A collection of commonly used modules shared across all of My projects.
4
+
5
+ ## Modules
6
+
7
+ | Module | Full name | What it does |
8
+ |--------|-----------|--------------|
9
+ | **ASHTP** | Arty's Secure HTTP Transport Protocol | Custom HTTP wrapper for environments where TLS and HTTPS aren't available |
10
+ | **Events Manager** | Arty's Events Manager | Subscribe to and invoke events across threads and scripts |
11
+ | **CAP** | Command and Argument Parser | Define commands along with their arguments and flags |
12
+
13
+ ---
14
+
15
+ ### ASHTP
16
+ Custom HTTP wrapper for use when TLS and HTTPS aren't available.
17
+
18
+ ### Events Manager
19
+ Lets events be subscribed to and invoked from different threads and scripts.
20
+
21
+ ### CAP
22
+ Lets you define commands and their arguments and flags.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling >= 1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "artystd"
7
+ version = "0.0.2"
8
+ authors = [{ name = "Artytuihj" }]
9
+ description = "Arty's standard library"
10
+
11
+ requires-python = ">=3.10"
12
+ keywords = ["stdlib"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ license = "MIT"
18
+ license-files = ["LICEN[CS]E*"]
19
+
20
+ readme = "README.md"
21
+
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ clientnet = ["requests>=2.31", "cryptography>=42"]
26
+ servernet = ["artys-std[clientnet]", "fastapi>=0.110"]
27
+ all = ["artys-std[servernet]"]
28
+
29
+ [tool.hatch.build.targets.sdist]
30
+ include = [
31
+ "/src"
32
+ ]
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["/src/artystd"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/Artytuihj/Artys_STD_LIB"
39
+ Issues = "https://github.com/Artytuihj/Artys_STD_LIB/issues/"
@@ -0,0 +1,533 @@
1
+ #std
2
+ import json
3
+ import secrets
4
+ import tempfile
5
+ import time
6
+ import zipfile
7
+ from email import generator
8
+ from pathlib import Path
9
+ import asyncio
10
+ import logging
11
+ import base64
12
+ import hashlib
13
+ from typing import Iterable, Iterator
14
+
15
+ #pypi
16
+ import requests
17
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
18
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
19
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
20
+ from cryptography.hazmat.primitives import hashes, serialization
21
+ from cryptography.exceptions import InvalidTag
22
+ from requests import Response
23
+
24
+ #in module
25
+ from src.artystd.ashtp import PROTOCOL_VERSION
26
+ from src.artystd.utilities.ArtysEventManager import ArtysEventManager
27
+
28
+
29
+ class HandshakeError(Exception):
30
+ pass
31
+
32
+ class ParsingError(Exception):
33
+ pass
34
+
35
+ class ReplayError(Exception):
36
+ pass
37
+
38
+ class ASHTPGeneralException(Exception):
39
+ pass
40
+
41
+
42
+ def _DeriveKeys(secret:bytes, transcrypt:bytes, length:int=64)-> tuple[bytes,bytes]:
43
+ symKey = HKDF(algorithm=hashes.SHA256(), length=length, salt=None,
44
+ info=b"ashtp sym sesscion key" + transcrypt).derive(secret)
45
+ return symKey[32:], symKey[:32]
46
+
47
+ def _aad(sid: bytes, method: str, endpoint: str, ref: int | None = None) -> bytes:
48
+ if not endpoint.startswith("/"):
49
+ endpoint = "/" + endpoint
50
+ a = sid + method.upper().encode() + b" " + endpoint.encode()
51
+ if ref is not None:
52
+ a += b" " + ref.to_bytes(8, "little") # fixed width at the end, so unambiguous
53
+ return a
54
+
55
+ class Session():
56
+ def __init__(self, send_key:bytes, recv_key:bytes):
57
+ self.send = ChaCha20Poly1305(send_key)
58
+ self.recv = ChaCha20Poly1305(recv_key)
59
+ self.sendN = 0
60
+ self.recvN = -1
61
+
62
+ @staticmethod
63
+ def _nonce(n: int) -> bytes:
64
+ return b"\0" * 4 + n.to_bytes(8, "little") # generates a 12 byte number ( 0 0 0 0 )just filed with zeros (0 0 0 0 0 0 0 0) a simple counter little means less significant comes first
65
+
66
+ def Encrypt(self, data:bytes, tag:bytes = b""):
67
+ n = self.sendN # save the number used to build the nonce
68
+ self.sendN += 1 # increment
69
+ return n, self.send.encrypt(self._nonce(n), data, tag) # here we encrypt data with the send key and tag (ct stands for "cyphertext")
70
+
71
+ def Decrypt(self, data:bytes,n ,tag:bytes = b""):
72
+ if not ( self.recvN < n < 2**64): # make sure we are not being replayed and below the limit
73
+ raise ReplayError("The message is being replayed or key is stale. rekey! ")
74
+ dr = self.recv.decrypt(self._nonce(n), data, tag) # do the same as encrypt but backwards ("dr" stands for decryption result)
75
+ self.recvN = n
76
+ return dr
77
+
78
+ class ASHTPClient:
79
+ def __init__(self,
80
+ server_public_encryption_const: bytes,
81
+ server_ip: str,
82
+ server_port: int | None = None,
83
+ url_prefix: str = "http://",
84
+ url_health: str = "/health",
85
+ url_handshake: str = "/privet",
86
+ url_auth: str = "/auth",
87
+ timeout_time: int = 5,
88
+ max_reties: int = 5,
89
+ health_poll_rate: int = 5):
90
+
91
+ # ----------------------------------------------
92
+ # ||| Arty's Secure HTTP Transport Protocol |||
93
+ # ----------------------------------------------
94
+
95
+ # ---------- protocol ----------
96
+ self._version: str = PROTOCOL_VERSION
97
+
98
+ # ---------- server address ----------
99
+ self.serverIp: str = server_ip
100
+ self.serverPort: int | None = server_port
101
+ self.serverPrefix: str = url_prefix
102
+ self.serverHealthEndpoint: str = url_health
103
+ self.serverHandshakeEndpoint: str = url_handshake
104
+ self.serverAuthEndpoint: str = url_auth
105
+ self.serverUrl: str = f"{self.serverPrefix}{self.serverIp}{f":{self.serverPort}" if self.serverPort else ""}"
106
+
107
+ # ---------- request behaviour ----------
108
+ self.timeoutTime: int = timeout_time
109
+ self.maxRequestRetries: int = max_reties
110
+
111
+ # ---------- health polling settings ----------
112
+ self.healthCheckRate: int = health_poll_rate
113
+
114
+ # ---------- file transfer ----------
115
+ self._tmp = Path(tempfile.gettempdir()) / "ashtp" / "C"
116
+ self._tmp.mkdir(parents=True, exist_ok=True)
117
+
118
+ # ---------- integrations (optional) ----------
119
+ self.logger = logging.getLogger(__name__)
120
+
121
+ # ---------- crypto / handshake ----------
122
+ self.Sb: bytes = server_public_encryption_const # server's constant public key
123
+ self.HELLO_REPLY_FORMAT = {
124
+ "F": {"type": bytes, "len": 32, "required": True}, # servers temp public key to make the symertric one
125
+ "session_id": {"type": bytes, "len": 16, "required": True}, # just a random id
126
+ "ok": {"type": bytes, "len": 23, "required": True}, # ts sends "ok" and a 16 bit tag to detect tampering or corruption
127
+ }
128
+ self.AUTH_REPLY_FORMAT = {
129
+ "ok": {"type": str, "required": True}
130
+ }
131
+ self.secureTunnelActive = False
132
+ self.session:Session = None
133
+ self.sessionId:bytes = b""
134
+ self.apiKey: str = ""
135
+ self._connecting: bool = False
136
+ self._lastConnect: float = 0.0
137
+
138
+ # ---------- runtime state ----------
139
+ self.isOnline: bool = False
140
+ self.failedRequests: int = 0
141
+
142
+ # ---------- background tasks (keep last, they use everything above) ----------
143
+ self._healthTask = None
144
+
145
+ def Log(self, message:str):
146
+ self.logger.info(message)
147
+
148
+ def Error(self, message:str):
149
+ self.logger.error(message)
150
+
151
+ def emit(self, event:str ,*args, **kwargs):
152
+ ArtysEventManager.EmitEvent(event, *args, **kwargs)
153
+
154
+
155
+ def Parse(self, msg: dict, fmt: dict)-> dict:
156
+ """
157
+ takes one dict and compares it to a template
158
+ :param msg:
159
+ :param fmt:
160
+ :return:
161
+ """
162
+ if not isinstance(msg, dict) or set(msg) - set(fmt):
163
+ raise ParsingError("msg inst a dict or has forbidden fields")
164
+ output = {}
165
+
166
+ for name , spec in fmt.items():
167
+ if name not in msg:
168
+ if spec["required"]:
169
+ raise ParsingError("msg is missing keys defined in template")
170
+ continue
171
+ value = msg[name]
172
+ if spec["type"] is bytes:
173
+ try:
174
+ value = base64.b64decode(value, validate=True)
175
+ except Exception as e:
176
+ raise ParsingError(f"{name} isn't bytes or the field is corrupted")
177
+
178
+ if len(value) != spec["len"]:
179
+ raise ParsingError(f"{name} dosen't match length target")
180
+ elif not isinstance(value, spec["type"]):
181
+ raise ParsingError(f"{name} dosen't match type")
182
+ output[name] = value
183
+ return output
184
+
185
+ def Seal(self, msg:bytes, method:str, path:str,session:Session|None = None, sessionId:bytes|None = None, ref:int|None = None) -> tuple[bytes, int]:
186
+ """ Turns unencrypted JSON into request ready data
187
+
188
+ :param ref:
189
+ :param sessionId:
190
+ :param session:
191
+ :param msg:
192
+ :param method:
193
+ :param path:
194
+ :return:
195
+ """
196
+
197
+ sec = session if session is not None else self.session
198
+ secId = sessionId if sessionId is not None else self.sessionId
199
+
200
+ plain = json.dumps(msg).encode()
201
+ tag = _aad(secId, method, path, ref=ref)
202
+ n, data = sec.Encrypt(plain, tag)
203
+
204
+ return data, n
205
+
206
+ def SealFileStream(self, meta:dict, filePath:Path, method:str, path:str, chunkSize:int=64*1024,
207
+ session:Session|None = None, sessionId:bytes|None = None, ref:int|None = None) -> Iterator[tuple[bytes, int]]:
208
+ """ Turns an unencrypted File into request ready byte stream
209
+
210
+ :param chunkSize:
211
+ :param filePath:
212
+ :param meta:
213
+ :param method:
214
+ :param path:
215
+ :param sessionId:
216
+ :param session:
217
+ :param ref:
218
+ :return:
219
+ """
220
+
221
+ sec = session if session is not None else self.session
222
+ secId = sessionId if sessionId is not None else self.sessionId
223
+ initialRequest = json.dumps( meta | {"chunk_size": chunkSize} ).encode()
224
+ with tempfile.TemporaryDirectory(dir=self._tmp) as td: # niche thing i like
225
+
226
+ zipPath = Path(td) / f"{filePath.name}.zip"
227
+ with zipfile.ZipFile(zipPath, "w", zipfile.ZIP_DEFLATED) as zf:
228
+ for file in filePath.rglob("*"):
229
+ if file.is_dir():
230
+ zf.write(file, arcname=str(file.relative_to(filePath)) + "/")
231
+ else:
232
+ zf.write(file, arcname=file.relative_to(filePath))
233
+
234
+ data, n = self.Seal(initialRequest, method, path, sec, secId, ref)
235
+ yield data, n
236
+
237
+ with zipPath.open("rb") as f:
238
+ while chunk := f.read(chunkSize):
239
+ data, n = self.Seal(chunk, method, path, sec, secId, ref)
240
+ yield data, n
241
+
242
+ def TearOpen(self, msg:bytes, method:str, path:str, serverCount:int, session:Session|None = None, sessionId:bytes|None = None, ref:int|None = None) -> dict:
243
+ """
244
+ Turns encrypt byte data into (hopefuly) a readable JSON
245
+
246
+ :param ref:
247
+ :param serverCount:
248
+ :param msg:
249
+ :param method:
250
+ :param path:
251
+ :param session:
252
+ :param sessionId:
253
+ :return:
254
+ """
255
+ sec = session if session is not None else self.session
256
+ secId = sessionId if sessionId is not None else self.sessionId
257
+
258
+ if sec is None or secId is None:
259
+ raise HandshakeError("no active session")
260
+
261
+ path = path if path.startswith("/") else "/" + path
262
+ n = serverCount
263
+ tag = _aad(secId, method, path, ref=ref)
264
+ plain = sec.Decrypt(msg, n, tag)
265
+
266
+ return json.loads(plain.decode())
267
+
268
+ async def Init(self,api_key:str) -> bool:
269
+ """
270
+ Initializes the connection with the server and creates the encrypted authenticated channel.
271
+ This function HAS to be used at the start of comunication with any server running the "ashtp" protocol.
272
+ :param api_key:
273
+ :return:
274
+ """
275
+ self.apiKey = api_key
276
+ key_id = hashlib.blake2b(self.Sb, digest_size=8).digest()
277
+ proto = self._version.encode("utf-8")
278
+
279
+ for i in range(3):
280
+
281
+ e = X25519PrivateKey.generate() # clients private key
282
+ E = e.public_key() # clients public key
283
+ Eb = E.public_bytes( # clients public key but converted to bytes
284
+ encoding=serialization.Encoding.Raw,
285
+ format=serialization.PublicFormat.Raw
286
+ )
287
+
288
+ eS = e.exchange(X25519PublicKey.from_public_bytes(self.Sb)) # mix our private with servers const public
289
+
290
+ #handshake package
291
+ hello = {
292
+ "protocol": self._version,
293
+ "spk_hash": base64.b64encode( key_id ).decode(),
294
+ "E": base64.b64encode( Eb ).decode(),
295
+ }
296
+
297
+ #send of the handshake
298
+ try:
299
+ resp = await asyncio.to_thread(requests.post, f"{self.serverUrl}{self.serverHandshakeEndpoint}",
300
+ json = hello, timeout = self.timeoutTime)
301
+ resp.raise_for_status() # neat thing it raises when bad status arrives like 500 or 404
302
+ except Exception as err:
303
+ self.failedRequests += 1
304
+ self.Error(str(err))
305
+ continue
306
+
307
+ try:
308
+ reply = self.Parse(resp.json(), self.HELLO_REPLY_FORMAT)# check that the handshake package is actualy valid
309
+ F = reply["F"]
310
+ eF = e.exchange(X25519PublicKey.from_public_bytes(F)) # exchange keys to get the secret number (mix our private with servers public)
311
+
312
+ c2s, s2c = _DeriveKeys(eS + eF, proto + key_id + Eb + F + self.Sb + reply["session_id"])
313
+ session = Session(c2s, s2c)
314
+
315
+ if session.Decrypt(reply["ok"],0, b"") != b"lets go":
316
+ raise HandshakeError("<ok> didnt match the expected! keys/ package are either malformed or where tampered with")
317
+
318
+ except (HandshakeError, ParsingError, InvalidTag, ValueError) as err:
319
+ self.Error(f"Handshake Failed: {err}")
320
+ return False
321
+ try:
322
+ authPackage = json.dumps({ "api_key":api_key }).encode()
323
+
324
+ authBytes, n = self.Seal(authPackage, "POST", self.serverAuthEndpoint, session, reply["session_id"])
325
+ headers = {
326
+ "ashtp-Session": base64.b64encode(reply["session_id"]).decode(), # take session id make it into base64 and turn it into words
327
+ "ashtp-Counter": str(n), # the counter needed for encryption/decryption
328
+ "ashtp-Content": "json", # content type
329
+ }
330
+ resp = await asyncio.to_thread(requests.post, f"{self.serverUrl}{self.serverAuthEndpoint}",
331
+ data=authBytes, headers=headers, timeout=self.timeoutTime)
332
+ resp.raise_for_status()
333
+ serverCounter= int(resp.headers["ashtp-Counter"])
334
+ resp = self.TearOpen(resp.content, "POST", self.serverAuthEndpoint, serverCounter ,session, reply["session_id"], ref=n)
335
+
336
+ resp = self.Parse(resp, self.AUTH_REPLY_FORMAT)
337
+ if not resp.get("ok") == "ok": raise HandshakeError("couldnt log in")
338
+
339
+ except Exception as err:
340
+ self.Error(f"Handshake Failed: {err}")
341
+ return False
342
+
343
+
344
+ #lets go we connected
345
+
346
+ self.session = session
347
+ self.sessionId = reply["session_id"]
348
+ self.secureTunnelActive = True
349
+ self.isOnline = True
350
+ self.failedRequests = 0
351
+ self.emit("ashtp_server_status_changed", True)
352
+ return True
353
+ return False
354
+
355
+ async def CheckHealth(self):
356
+ combinedUrl = f"{self.serverUrl}{self.serverHealthEndpoint}"
357
+
358
+ while True:
359
+ try:
360
+ resp = await asyncio.to_thread(requests.get, combinedUrl,timeout=self.timeoutTime)
361
+ resp.raise_for_status()
362
+ if resp.json():
363
+ self.failedRequests = 0
364
+ else:
365
+ self.failedRequests += 1
366
+
367
+ except Exception as e:
368
+ print(e)
369
+ self.failedRequests += 1
370
+
371
+ prev = self.isOnline
372
+ if self.failedRequests > self.maxRequestRetries:
373
+ self.isOnline = False
374
+ self.secureTunnelActive = False
375
+ elif self.failedRequests == 0:
376
+ self.isOnline = True
377
+ if self.isOnline != prev:
378
+ self.Log(f"status -> {self.isOnline}")
379
+ self.emit("ashtp_server_status_changed", self.isOnline)
380
+
381
+ # rebuild the tunnel whenever the server is up but we have no working session
382
+ if (self.isOnline and not self.secureTunnelActive and self.apiKey
383
+ and not self._connecting and time.monotonic() - self._lastConnect > 15):
384
+ self._connecting = True
385
+ self._lastConnect = time.monotonic()
386
+ try:
387
+ await self.Init(self.apiKey)
388
+ except Exception as err:
389
+ self.Error(f"reconnect failed: {err}")
390
+ finally:
391
+ self._connecting = False
392
+
393
+ await asyncio.sleep(self.healthCheckRate)
394
+
395
+ async def _request(self, method: str, path: str, contentType:str, request: Iterable[bytes], timeout, usrHeaders:dict|None = None, sendBody: bool = True) -> dict:
396
+ if not self.isOnline:
397
+ raise ASHTPGeneralException("Server is offline")
398
+ if not self.secureTunnelActive:
399
+ raise ASHTPGeneralException("No active tunnel")
400
+
401
+ timeout = self.timeoutTime if timeout is None else timeout
402
+ path = path if path.startswith("/") else "/" + path
403
+ url = f"{self.serverUrl}{path}"
404
+
405
+ response:Response = Response()
406
+
407
+ if contentType == "json":
408
+ payload = next(iter(request)) if request is not None else json.dumps({"placeholder": secrets.randbits(64)}).encode()
409
+ last_error = ""
410
+ for attempt in range(self.maxRequestRetries):
411
+ data, n = self.Seal(payload, method, path)
412
+
413
+ headers = {
414
+ "ashtp-Session": base64.b64encode(self.sessionId).decode(), # take session id make it into base64 and turn it into words
415
+ "ashtp-Counter": str(n), # the counter needed for encryption/decryption
416
+ "ashtp-Content": "json", # content type
417
+ }
418
+
419
+ kwargs = {"headers": (usrHeaders or {}) | headers, "timeout": timeout}
420
+ if sendBody:
421
+ kwargs["data"] = data
422
+ if usrHeaders is not None:
423
+ body = kwargs.setdefault("data", {})
424
+ if not isinstance(body, dict):
425
+ raise TypeError("user headers can only be attached to dict bodies")
426
+ uh = body.setdefault("user_headers", {})
427
+ for key, header in usrHeaders.items():
428
+ if isinstance(header, str):
429
+ header = header.encode()
430
+ uh[key] = base64.b64encode(header).decode()
431
+ try:
432
+ resp = await asyncio.to_thread(requests.request, method, url, **kwargs)
433
+ resp.raise_for_status()
434
+ except requests.exceptions.HTTPError as e:
435
+ self.failedRequests += 1
436
+ last_error = str(e)
437
+ if e.response is not None and e.response.status_code == 401:
438
+ self.secureTunnelActive = False
439
+ if e.response is not None and e.response.status_code < 500:
440
+ raise ASHTPGeneralException(last_error) from e
441
+ except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
442
+ self.failedRequests += 1
443
+ last_error = str(e)
444
+ else:
445
+ response = resp
446
+
447
+ await asyncio.sleep(
448
+ min(0.25 * 2 ** attempt, 5)) # wait longer each time we fail to not hammer the server
449
+ raise ASHTPGeneralException(last_error)
450
+ elif contentType == "file":
451
+ payload = request
452
+
453
+ if (contentType := response.headers.get("ashtp-Content")) is not None:
454
+ if type == "json":
455
+ ...
456
+ elif type == "file":
457
+ ...
458
+ raise ASHTPGeneralException("welp...")
459
+
460
+
461
+ async def GetData(self, path:str, request:dict=None, timeout:float=None, usrHeaders:dict=None) -> dict:
462
+ return await self._request("GET", path, request, timeout, usrHeaders, sendBody=True)
463
+
464
+ async def PostData(self, path:str, request:dict, timeout:float=None, usrHeaders:dict=None) -> dict:
465
+ return await self._request("POST", path, request, timeout, usrHeaders)
466
+
467
+
468
+ async def GetFile(self, path:str, request:dict=None, timeout:float=None, usrHeaders:dict = None) -> Path:
469
+ """
470
+ Streams a file from the server.
471
+ Can only be used to request files!
472
+ If the file are larger than 1GB they will be put in tmp.
473
+
474
+ :param path:
475
+ :param usrHeaders:
476
+ :param timeout:
477
+ :param request:
478
+ :return: path to downlaoded file
479
+ """
480
+
481
+ if not self.isOnline:
482
+ raise ASHTPGeneralException("Server is offline")
483
+ if not self.secureTunnelActive:
484
+ raise ASHTPGeneralException("No active tunnel")
485
+
486
+ timeout = self.timeoutTime if timeout is None else timeout
487
+ path = path if path.startswith("/") else "/" + path
488
+ url = f"{self.serverUrl}{path}"
489
+ payload = request if request is not None else {"placeholder": secrets.randbits(64)}
490
+
491
+ data, headers, n = self.Seal(payload, "GET", url)
492
+ kwargs = {"headers": (usrHeaders or {}) | headers, "timeout": timeout}
493
+
494
+
495
+
496
+
497
+
498
+ async def PostFile(self, endPoint: str, file:Path,headers:dict = {}) -> dict:
499
+ """
500
+ Posts file to the server.
501
+ Can only be used to upload files
502
+
503
+ :param endPoint:
504
+ :param file:
505
+ :return: response
506
+ """
507
+
508
+ if not self.isOnline:
509
+ return {"status": "failed", "code": "400", "message": "server is offline"}
510
+
511
+ if not self.isOnline:
512
+ return {"status": "failed", "code": "400", "message": "a secure connection to the server was not established"}
513
+
514
+ combinedUrl = f"{self.serverUrl}{endPoint if endPoint.startswith("/") else "/" + endPoint}"
515
+ kwargs = {"headers": self.persistentHeaders | headers}
516
+ last_error = ""
517
+
518
+ with open(file, "rb") as f:
519
+ kwargs["headers"]["Content-Name"] = f.name
520
+ kwargs["files"] = {"file": f}
521
+
522
+ for i in range(0, self.maxRequestRetries):
523
+ f.seek(0)
524
+ try:
525
+ resp = await asyncio.to_thread(requests.post, combinedUrl, **kwargs)
526
+ resp.raise_for_status()
527
+ response = resp.json()
528
+ self.failedRequests = 0
529
+ return response
530
+ except Exception as e:
531
+ last_error = e
532
+
533
+ return {"status": "failed", "code": "000", "message": str(last_error)}
@@ -0,0 +1,425 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import tempfile
6
+ import time
7
+ from collections import deque
8
+ from pathlib import Path
9
+
10
+ from cryptography.exceptions import InvalidTag
11
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
12
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
13
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
14
+ from cryptography.hazmat.primitives import hashes, serialization
15
+ import base64
16
+ import hashlib
17
+ from fastapi import FastAPI, Request, Response
18
+ import logging
19
+ import inspect
20
+
21
+ from dataclasses import dataclass, field
22
+ import secrets
23
+
24
+ from src.artystd.ashtp import PROTOCOL_VERSION, SUPPORTED_SERVER_VERSIONS
25
+ from src.artystd.ashtp.IKeyAuthority import IKeyAuthority as ika
26
+
27
+
28
+ class HandshakeError(Exception):
29
+ pass
30
+ class ParsingError(Exception):
31
+ pass
32
+ class ReplayError(Exception):
33
+ pass
34
+ class BodyTooLarge(Exception):
35
+ pass
36
+ class ErrorHandlingError(Exception):
37
+ pass
38
+ class ClientTooOldError(Exception):
39
+ ...
40
+ class KeyLacksPermissionError(Exception):
41
+ ...
42
+
43
+ def _DeriveKeys(secret:bytes, transcrypt:bytes, length:int=64)-> tuple[bytes,bytes]:
44
+ symKey = HKDF(algorithm=hashes.SHA256(), length=length, salt=None,
45
+ info=b"ashtp sym sesscion key" + transcrypt).derive(secret)
46
+ return symKey[32:], symKey[:32]
47
+
48
+ def _aad(sid: bytes, method: str, endpoint: str, ref: int | None = None) -> bytes:
49
+ if not endpoint.startswith("/"):
50
+ endpoint = "/" + endpoint
51
+ a = sid + method.upper().encode() + b" " + endpoint.encode()
52
+ if ref is not None:
53
+ a += b" " + ref.to_bytes(8, "little") # fixed width at the end, so unambiguous
54
+ return a
55
+
56
+ async def _ReadBody(req: Request, limit: int) -> tuple[bytes, dict]:
57
+ cl = req.headers.get("content-length")
58
+ if cl is not None and int(cl) > limit:
59
+ raise BodyTooLarge
60
+ buf = bytearray()
61
+ async for chunk in req.stream():
62
+ buf += chunk
63
+ if len(buf) > limit:
64
+ raise BodyTooLarge
65
+ return bytes(buf), dict(req.headers)
66
+
67
+ @dataclass
68
+ class ActiveSession:
69
+ session: Session
70
+ created: float = field(default_factory=time.monotonic)
71
+ permsLevel: int = -1
72
+ name: str = "None"
73
+ class Session:
74
+ def __init__(self, send_key:bytes, recv_key:bytes):
75
+ self.send = ChaCha20Poly1305(send_key)
76
+ self.recv = ChaCha20Poly1305(recv_key)
77
+ self.sendN = 0
78
+ self.recvN = -1
79
+
80
+ @staticmethod
81
+ def _nonce(n: int) -> bytes:
82
+ return b"\0" * 4 + n.to_bytes(8, "little") # generates a 12 byte number ( 0 0 0 0 )just filed with zeros (0 0 0 0 0 0 0 0) a simple counter little means less significant comes first
83
+
84
+ def Encrypt(self, data:bytes, tag:bytes = b""):
85
+ n = self.sendN # save the number used to build the nonce
86
+ self.sendN += 1 # increment
87
+ return n, self.send.encrypt(self._nonce(n), data, tag) # here we encrypt data with the send key and tag (ct stands for "cyphertext")
88
+
89
+ def Decrypt(self, data:bytes,n ,tag:bytes = b""):
90
+ if not ( self.recvN < n < 2**64): # make sure we are not being replayed and below the limit
91
+ raise ReplayError("The message is being replayed or key is stale. rekey! ")
92
+ dr = self.recv.decrypt(self._nonce(n), data, tag) # do the same as encrypt but backwards ("dr" stands for decryption result)
93
+ self.recvN = n
94
+ return dr
95
+ class RateLimiter:#neat thingy
96
+ def __init__(self, limit: int, window: float):
97
+ self.limit = limit
98
+ self.window = window
99
+ self.hits: dict[str, deque[float]] = {} # store key and a queue of req attempts
100
+
101
+ def _Trim(self, key: str) -> deque:
102
+ q = self.hits.setdefault(key, deque()) # get the queue from the dict and if dosent exist yet make it
103
+ now = time.monotonic()
104
+ while q and now - q[0] > self.window: # repeat while q is a thing and q[0] fits within the window
105
+ q.popleft()
106
+ return q
107
+
108
+ def Count(self, key: str) -> int:#remove stale requests
109
+ return len(self._Trim(key))
110
+
111
+ def Blocked(self, key: str) -> bool:
112
+ return self.Count(key) >= self.limit# call count and check if we are over the limit
113
+
114
+ def Hit(self, key: str):
115
+ self._Trim(key).append(time.monotonic())# add to the counter
116
+
117
+ class ASHTPServer:
118
+ def __init__(self,
119
+ application:type[FastAPI],
120
+ server_private_encryption_const: bytes,
121
+ key_authority: ika,
122
+ url_prefix: str = "http://",
123
+ url_health: str = "/health",
124
+ url_handshake: str = "/privet",
125
+ url_auth: str = "/auth",):
126
+
127
+ # ----------------------------------------------
128
+ # ||| Arty's Secure HTTP Transport Protocol |||
129
+ # ----------------------------------------------
130
+
131
+ # --------- protocol ----------
132
+ self._version: str = PROTOCOL_VERSION
133
+
134
+ # ------- server address --------
135
+ self.serverPrefix: str = url_prefix
136
+ self.serverHealthEndpoint: str = url_health
137
+ self.serverHandshakeEndpoint: str = url_handshake
138
+ self.serverAuthEndpoint: str = url_auth
139
+
140
+ # -------- file transfer ---------
141
+ self._tmp = Path(tempfile.gettempdir()) / "ashtp" / "S"
142
+ self._tmp.mkdir(parents=True, exist_ok=True)
143
+
144
+ # ---------- crypto / handshake -------
145
+ self.sb: bytes = server_private_encryption_const # server's constant private key
146
+ self.s = X25519PrivateKey.from_private_bytes(self.sb) # servers const private
147
+ self.S = self.s.public_key() # servers const public
148
+ self.Sb = self.S.public_bytes( # const public in bytes
149
+ encoding=serialization.Encoding.Raw,
150
+ format=serialization.PublicFormat.Raw
151
+ )
152
+
153
+ self.HELLO_FORMAT = {
154
+ "protocol": {"type": str, "required": True},
155
+ "spk_hash": {"type": bytes, "len": 8, "required": True},
156
+ "E": {"type": bytes, "len": 32, "required": True},
157
+ }
158
+ self.AUTH_FORMAT = {
159
+ "api_key": {"type": str, "required": True}
160
+ }
161
+ # ------- session management ---------
162
+ self.activeSessions: dict[bytes,ActiveSession] = {}
163
+ self.keyAuthority = key_authority
164
+
165
+ # --------- rate limits --------
166
+ self.hsLimit = RateLimiter(limit=300, window=60)
167
+ self.authFails = RateLimiter(limit=50, window=300)
168
+ self.maxSessions = 200
169
+
170
+ # -------- other ---------
171
+ self.logger = logging.getLogger(__name__)
172
+
173
+ # ------- app --------
174
+ self.app = application
175
+ self.app.add_api_route("/health", self.Health, methods=["GET"])
176
+ self.app.add_api_route("/privet", self.Handshake, methods=["POST"])
177
+ self.app.add_api_route("/auth", self.Authenticate, methods=["POST"])
178
+
179
+ # --------- tool chain --------
180
+ def Log(self, message:str):
181
+ self.logger.info(message)
182
+
183
+ def Error(self, message:str):
184
+ self.logger.error(message)
185
+
186
+ def Parse(self, msg: dict, baseModel: dict)-> dict:
187
+ """
188
+ takes one dict and compares it to a template
189
+ :param msg:
190
+ :param baseModel:
191
+ :return:
192
+ """
193
+ if not isinstance(msg, dict) or set(msg) - set(baseModel): # turn the dict into a set(storing the keys) and compare with base model
194
+ raise ParsingError("msg inst a dict or has forbidden fields")
195
+ output = {}
196
+
197
+ for name , spec in baseModel.items():
198
+ if name not in msg:
199
+ if spec["required"]:
200
+ raise ParsingError("msg is missing keys defined in template")
201
+ continue
202
+ value = msg[name]
203
+ if spec["type"] is bytes:
204
+ try:
205
+ value = base64.b64decode(value, validate=True)
206
+ except Exception as e:
207
+ raise ParsingError(f"{name} isn't bytes or the field is corrupted")
208
+
209
+ if len(value) != spec["len"]:
210
+ raise ParsingError(f"{name} dosen't match length target")
211
+ elif not isinstance(value, spec["type"]):
212
+ raise ParsingError(f"{name} dosen't match type")
213
+ output[name] = value
214
+ return output
215
+
216
+ def Expire(self, max_age: float = 10.0):
217
+ now = time.monotonic()
218
+ for sid, s in list(self.activeSessions.items()):
219
+ if s.permsLevel == -1 and now - s.created > max_age:
220
+ del self.activeSessions[sid]
221
+
222
+
223
+ def Seal(self, msg:dict, method:str, path:str, sessionId:bytes, ref:int|None = None ):
224
+ """ Turns unencrypted JSON into request ready data
225
+
226
+ :param ref:
227
+ :param sessionId:
228
+ :param session:
229
+ :param msg:
230
+ :param method:
231
+ :param path:
232
+ :return:
233
+ """
234
+
235
+ try:
236
+ sec = self.activeSessions[sessionId].session
237
+ except KeyError:
238
+ raise KeyError("[ashtp] Tried using expired session")
239
+
240
+
241
+ plain = json.dumps(msg).encode()
242
+ tag = _aad(sessionId, method, path, ref=ref)
243
+ n, data = sec.Encrypt(plain,tag)
244
+
245
+ headers = {
246
+ "ashtp-Session": base64.b64encode(sessionId).decode(), # take session id make it into base64 and turn it into words
247
+ "ashtp-Counter": str(n),# the counter needed for encryption/decryption
248
+ "ashtp-Content": "json",# content type
249
+ }
250
+
251
+ return data, headers, n
252
+
253
+ def TearOpen(self, msg:tuple[bytes,dict[str,str]], method:str, path:str, clientCount:int, sessionId:bytes, ref:int|None = None) -> tuple[dict,dict]:
254
+ """
255
+ Turns encrypt byte data into (hopefuly) a readable JSON
256
+
257
+ :param ref:
258
+ :param serverCount:
259
+ :param msg:
260
+ :param method:
261
+ :param path:
262
+ :param session:
263
+ :param sessionId:
264
+ :return:
265
+ """
266
+ sec = self.activeSessions[sessionId].session
267
+
268
+ path = path if path.startswith("/") else "/" + path
269
+ n = clientCount
270
+ tag = _aad(sessionId, method, path, ref=ref)
271
+ plain = sec.Decrypt(msg[0], n, tag)
272
+ plainJson = json.loads(plain.decode())
273
+ headers = plainJson["user_headers"] | msg[1]
274
+ body = plainJson.pop("user_headers", None)
275
+
276
+ return body , headers
277
+
278
+ def Endpoint(self, path:str, method:str, minPerms:int|None = None, baseModel:dict|None = None, maxBody:int = 16 * 1024):
279
+ method = method.upper()
280
+
281
+ def decorator(fn):
282
+ async def endpoint(req:Request):
283
+ # unpack the request
284
+ try:
285
+ sid = base64.b64decode(req.headers["ashtp-Session"])
286
+ active = self.activeSessions.get(sid)
287
+ requiredPermissionLevel = self.keyAuthority.minPermissionLevel if minPerms is None else minPerms
288
+
289
+
290
+ if active is None or not self.keyAuthority.ComparePermissions(active.permsLevel, requiredPermissionLevel):
291
+ return Response(status_code=403)# forbidden request
292
+
293
+ counter = int(req.headers["ashtp-Counter"])
294
+
295
+ contents, headers = self.TearOpen(await _ReadBody(req, maxBody), method, path, counter, sid)
296
+ if baseModel: contents = self.Parse(contents, baseModel)
297
+
298
+ except (KeyError, ValueError, ReplayError, InvalidTag) as e:
299
+ self.Error(f"{path}: {e}")
300
+ return Response(status_code=400) # broken request
301
+ except BodyTooLarge:
302
+ return Response(status_code=413) # body too large
303
+
304
+ # handle request
305
+ try:
306
+ if inspect.iscoroutinefunction(fn):
307
+ result = await fn(active, contents, headers)
308
+ else:
309
+ result = await asyncio.to_thread(fn, active, contents, headers)
310
+ except Exception as e:
311
+ self.Error(f"{path} handler failed: {e}")
312
+ return Response(status_code=500) # random error
313
+ try:
314
+ data, headers, _ = self.Seal(result, method, path, sid, ref=counter)
315
+ return Response(content=data, headers=headers, media_type="application/octet-stream")
316
+ except KeyError as e:
317
+ self.Log(str(e))
318
+ return Response(status_code=401) # Unotorised
319
+
320
+ self.app.add_api_route(path, endpoint, methods=[method])
321
+ return fn
322
+ return decorator
323
+
324
+
325
+ # ------------ endpoints ---------------
326
+
327
+ async def Health(self):
328
+ return {"code": 200, "message": "ok"}
329
+
330
+ async def Handshake(self, request: Request):
331
+
332
+ if self.hsLimit.Blocked("*"):
333
+ return Response(status_code=429)# rate limit exceeded
334
+ self.hsLimit.Hit("*")
335
+ self.Expire() # remove old sessions to prevent overflow
336
+
337
+ try:
338
+ body = await _ReadBody(request, 1024)
339
+ raw = self.Parse(json.loads(body), self.HELLO_FORMAT)
340
+
341
+
342
+ proto = raw["protocol"]
343
+ key_id = raw["spk_hash"]
344
+
345
+ if proto not in SUPPORTED_SERVER_VERSIONS: # check that the version matches
346
+ raise ClientTooOldError("Client is not supported")
347
+ if key_id != hashlib.blake2b(self.Sb, digest_size=8).digest(): # check the key matches
348
+ raise HandshakeError("Wrong/old public key")
349
+
350
+ Eb = raw["E"] # clients public bytes
351
+
352
+ sE = self.s.exchange(X25519PublicKey.from_public_bytes(Eb)) # mix our const private with Clients public
353
+
354
+ f = X25519PrivateKey.generate()
355
+ F = f.public_key()
356
+ Fb = F.public_bytes(
357
+ encoding=serialization.Encoding.Raw,
358
+ format=serialization.PublicFormat.Raw
359
+ )
360
+
361
+ sessionId = secrets.token_bytes(16) # make a session id
362
+
363
+ fE = f.exchange(X25519PublicKey.from_public_bytes(Eb))
364
+
365
+ c2s, s2c = _DeriveKeys(sE + fE, proto.encode() + key_id + Eb + Fb + self.Sb + sessionId)
366
+ session = Session(s2c, c2s)
367
+
368
+ if len(self.activeSessions) >= self.maxSessions:
369
+ unauth = [(s.created, k) for k, s in self.activeSessions.items() if s.permsLevel < 0]
370
+ if not unauth:
371
+ return Response(status_code=503) # if we have all aslots taken refuse service
372
+ del self.activeSessions[min(unauth)[1]]
373
+
374
+ self.activeSessions[sessionId] = ActiveSession(session=session)
375
+ n, ct = session.Encrypt(b"lets go", b"")
376
+ reply = {
377
+ "F": base64.b64encode( Fb ).decode(),
378
+ "session_id": base64.b64encode( sessionId ).decode(),
379
+ "ok": base64.b64encode( ct ).decode()
380
+ }
381
+ return reply
382
+ except BodyTooLarge as e:
383
+ return Response(status_code=413) #body to large
384
+ except (ValueError, ParsingError, HandshakeError) as e:
385
+ self.Error(str(e))
386
+ return Response(status_code=400) # bad request
387
+ except ClientTooOldError:
388
+ return Response(status_code=426, headers={"ashtp-Upgrade": PROTOCOL_VERSION})
389
+
390
+ async def Authenticate(self, request: Request):
391
+ try:
392
+ raw = await _ReadBody(request, 2 * 1024)
393
+
394
+ sid = base64.b64decode(request.headers["ashtp-Session"])
395
+ active = self.activeSessions.get(sid)
396
+ if active is None:
397
+ raise HandshakeError("unknown session")
398
+
399
+ count = int(request.headers["ashtp-Counter"])
400
+
401
+ req = self.TearOpen(raw, "POST", self.serverAuthEndpoint, count, sid)
402
+ req = self.Parse(req, self.AUTH_FORMAT)
403
+
404
+ apiKey = req["api_key"]
405
+ allowed, permissionLevel, name = await self.keyAuthority.CheckPerms(apiKey)
406
+ if allowed:
407
+ active.permsLevel = permissionLevel
408
+ active.name = name
409
+
410
+ data, headers, n = self.Seal({"ok": "ok"}, "POST", self.serverAuthEndpoint, sid, count)
411
+ return Response(content=data, headers=headers)
412
+ else:
413
+ self.activeSessions.pop(sid)
414
+ self.authFails.Hit("*")
415
+ await asyncio.sleep(min(1 + 0.2 * self.authFails.Count("*"), 10))
416
+ raise KeyLacksPermissionError("Key dosen't have enough permissions or dosen't match")
417
+
418
+
419
+ except BodyTooLarge:
420
+ return Response(status_code=413) # too large bish
421
+ except (KeyError, ValueError, ParsingError, HandshakeError, ReplayError, InvalidTag) as e:
422
+ self.Error(str(e))
423
+ return Response(status_code=400)# bad req
424
+ except KeyLacksPermissionError:
425
+ return Response(status_code=403)# no no perms
@@ -0,0 +1,34 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ class IKeyAuthority(ABC):
4
+ @property
5
+ @abstractmethod
6
+ def minPermissionLevel(self) -> int: ...
7
+
8
+ @abstractmethod
9
+ async def CheckPerms(self, apiKey: str) -> tuple[bool, int, str]:
10
+ """
11
+ Look up an API key.
12
+ Defined as async because DB lookup is expected.
13
+
14
+ Returns:
15
+ bool : whether the key meets minimum permission limit,
16
+ int : permission level of the key,
17
+ name : the username under which the key is registered
18
+
19
+ :param apiKey: The key of the client used to identify it
20
+ :returns:
21
+ """
22
+
23
+ @abstractmethod
24
+ def ComparePermissions(self, clientPerm: int, requiredPerm: int) -> bool:
25
+ """
26
+ Decide whether a client's permission level satisfies a required level.
27
+
28
+ What "satisfies" means depends on the application, so subclasses define it.
29
+
30
+ :param clientPerm: The permission level of the client making the request.
31
+ :param requiredPerm: The permission level the action requires.
32
+ :return: True if the client is allowed, False if not.
33
+ """
34
+
@@ -0,0 +1,19 @@
1
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
2
+
3
+ from pathlib import Path
4
+
5
+ path = Path(__file__).resolve().parent
6
+
7
+ def Generate():
8
+ """Standalone utility. Not intended to be called from production code.
9
+
10
+ Generates a fresh key pair and writes the key files next to this file.
11
+ """
12
+ priv = X25519PrivateKey.generate()
13
+ privB = priv.private_bytes_raw()
14
+ pubB = priv.public_key().public_bytes_raw()
15
+
16
+ (path / "public_key.bin").write_bytes(pubB)
17
+ (path / "private_key.bin").write_bytes(privB)
18
+
19
+ Generate()
@@ -0,0 +1,5 @@
1
+ from .ASHTPClient import ASHTPClient
2
+ from .ASHTPServer import ASHTPServer
3
+ from .IKeyAuthority import IKeyAuthority
4
+ PROTOCOL_VERSION = "ashtp/1.3"
5
+ SUPPORTED_SERVER_VERSIONS = [PROTOCOL_VERSION]
@@ -0,0 +1,30 @@
1
+ import asyncio
2
+ import inspect
3
+
4
+ class ArtysEventManager:
5
+ _events: dict[str, list] = {}
6
+ _getters: dict[str,list] = {}
7
+
8
+ @classmethod
9
+ def Subscribe(cls, eventName:str):
10
+ def decorator(func):
11
+ cls._events.setdefault(eventName, []).append(func)
12
+ return func
13
+ return decorator
14
+
15
+ @classmethod
16
+ def On(cls, eventName: str, func):
17
+ cls._events.setdefault(eventName, []).append(func)
18
+
19
+ @classmethod
20
+ def EmitEvent(cls, eventName:str, *args, **kwargs):
21
+ for func in cls._events.get(eventName, []):
22
+ if inspect.iscoroutinefunction(func):
23
+ asyncio.create_task(func(*args, **kwargs))
24
+ else:
25
+ func(*args, **kwargs)
26
+
27
+ def RegisterGetter(self, name, variable):
28
+ pass
29
+
30
+
@@ -0,0 +1,14 @@
1
+ import logging
2
+
3
+ class ArtysLogger:
4
+
5
+ logging.basicConfig(
6
+ level=logging.DEBUG,
7
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
8
+ filename="app.log"
9
+ )
10
+
11
+ @classmethod
12
+ def getLog(cls,name):
13
+ return logging.getLogger(name)
14
+
@@ -0,0 +1,4 @@
1
+ from .ArtysEventManager import ArtysEventManager
2
+ from .cap import Cap
3
+ from .ArtysLogger import ArtysLogger
4
+ __version__ = 0.1
@@ -0,0 +1,66 @@
1
+ import shlex
2
+ from dataclasses import dataclass, field
3
+ from typing import Callable, Any
4
+
5
+ @dataclass
6
+ class ParsedCommand:
7
+ name:str
8
+ args:list[str] = field(default_factory=list)
9
+ flags:dict[str,Any] = field(default_factory=dict)
10
+
11
+ @dataclass
12
+ class _Registered:
13
+ func: Callable
14
+ min_args: int = 0
15
+ help: str = ""
16
+
17
+ class CommandError(Exception):
18
+ """user-facing error (unknown cmd, bad args, ...)."""
19
+
20
+ class Cap:
21
+ def __init__(self):
22
+ self._commands: dict[str, _Registered] = {}
23
+
24
+ def command(self, name: str, min_agrs: int = 0, helpStr: str = ""):
25
+ def decorator(func: Callable):
26
+ self._commands[name] = _Registered(func, min_agrs, helpStr)
27
+ return func
28
+ return decorator
29
+
30
+ def parse(self, raw: str) -> ParsedCommand:
31
+ try:
32
+ tokens = shlex.split(raw,posix=False)
33
+ except ValueError as e:
34
+ raise CommandError(f"bad quoting: {e}")
35
+
36
+ if not tokens:
37
+ raise CommandError("empty command")
38
+
39
+ name, rest = tokens[0], tokens[1:]
40
+ args:list[str] = []
41
+ flags: dict[str, Any] = {}
42
+
43
+ i = 0
44
+ while i < len(rest):
45
+ tok = rest[i]
46
+ if tok.startswith("--"):
47
+ if "=" in tok:
48
+ k, v = tok[2:].split("=", 1)
49
+ flags[k] = v
50
+ else:
51
+ flags[tok[2:]] = True
52
+ else:
53
+ args.append(tok)
54
+ i += 1
55
+
56
+ return ParsedCommand(name, args, flags)
57
+
58
+ def run(self, command: str):
59
+ parsed = self.parse(command)
60
+ reg = self._commands.get(parsed.name)
61
+ if reg is None:
62
+ raise CommandError(f"unknown command: {parsed.name}")
63
+ if len(parsed.args) < reg.min_args:
64
+ raise CommandError(f"'{parsed.name}' needs at least {reg.min_args} arg(s)")
65
+ return reg.func(parsed.args, parsed.flags)
66
+