containerisation and source restructure
This commit is contained in:
parent
4f01a74602
commit
45eea8a484
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -15,7 +15,8 @@
|
|||||||
"name": "Python Debugger: Main",
|
"name": "Python Debugger: Main",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${cwd}/backend_py/main.py",
|
"cwd": "${workspaceFolder}",
|
||||||
|
"module": "simple_chat_api",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"justMyCode": true
|
"justMyCode": true
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
data/data.sqlite
Normal file
BIN
data/data.sqlite
Normal file
Binary file not shown.
BIN
data/db.sqlite
BIN
data/db.sqlite
Binary file not shown.
58
docker/Dockerfile
Normal file
58
docker/Dockerfile
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
FROM debian:trixie-slim AS base
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install sys utils, dependencies and nginx
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -yq nginx npm python3 python3-pip
|
||||||
|
|
||||||
|
# Copy files
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
RUN mkdir /simple-chat
|
||||||
|
COPY . /simple-chat
|
||||||
|
|
||||||
|
# Install app dependencies
|
||||||
|
RUN pip3 install -r /simple-chat/simple_chat_api/requirements.txt --break-system-packages --ignore-installed
|
||||||
|
RUN npm install --prefix /simple-chat/frontend || true
|
||||||
|
|
||||||
|
# Building
|
||||||
|
# || true to allow failure
|
||||||
|
RUN npm run build --prefix /simple-chat/frontend || true
|
||||||
|
|
||||||
|
# Setup nginx
|
||||||
|
RUN rm /etc/nginx/nginx.conf
|
||||||
|
RUN ln -s /simple-chat/docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
RUN mkdir -p /var/nginx
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Dev stage
|
||||||
|
FROM base AS dev
|
||||||
|
ARG ROOT_PASSWD
|
||||||
|
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||||
|
ENV DEV=true
|
||||||
|
|
||||||
|
# additional dependencies for dev
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y build-essential git openssh-server
|
||||||
|
|
||||||
|
# SSH configuration
|
||||||
|
RUN if [ -n "$ROOT_PASSWD" ]; then echo "root:$ROOT_PASSWD" | chpasswd; else passwd -d root; fi && \
|
||||||
|
echo "PermitRootLogin yes" > /etc/ssh/sshd_config && \
|
||||||
|
echo "ListenAddress 0.0.0.0" >> /etc/ssh/sshd_config && \
|
||||||
|
echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config && \
|
||||||
|
echo "PermitEmptyPasswords no" >> /etc/ssh/sshd_config && \
|
||||||
|
echo "Subsystem sftp /usr/lib/ssh/sftp-server" >> /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
RUN ssh-keygen -A && \
|
||||||
|
# echo "sshd: ALL" > /etc/hosts.deny && \
|
||||||
|
echo "sshd: ALL" > /etc/hosts.allow
|
||||||
|
|
||||||
|
# Run entrypoint for dev
|
||||||
|
CMD ["bash", "/entrypoint.sh"]
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM base AS production
|
||||||
|
# Run entrypoint
|
||||||
|
CMD ["bash", "/entrypoint.sh"]
|
||||||
BIN
docker/default.sqlite
Normal file
BIN
docker/default.sqlite
Normal file
Binary file not shown.
15
docker/entrypoint.sh
Normal file
15
docker/entrypoint.sh
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
if [ "$DEV" ]; then
|
||||||
|
echo "Running in development mode"
|
||||||
|
echo "Entering endless loop"
|
||||||
|
while true; do
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running in production mode"
|
||||||
|
echo "Reachable via port 8080"
|
||||||
|
nginx -g "daemon off;" &
|
||||||
|
cd /simple-chat || exit 1
|
||||||
|
python3 -m simple_chat_api
|
||||||
46
docker/nginx.conf
Normal file
46
docker/nginx.conf
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Linked to /etc/nginx/nginx.conf
|
||||||
|
worker_processes 1;
|
||||||
|
error_log /var/nginx/error.log;
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
|
||||||
|
keepalive_timeout 65;
|
||||||
|
client_max_body_size 2000M;
|
||||||
|
gzip on;
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
listen [::]:8080;
|
||||||
|
|
||||||
|
server_name simple-chat.default.local;
|
||||||
|
|
||||||
|
root /simple-chat/frontend/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Proxy specific API endpoints
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://localhost:7000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle all other routes - serve SPA for all other paths
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -1220,13 +1220,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/plugin-kit": {
|
"node_modules/@eslint/plugin-kit": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
|
||||||
"integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==",
|
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/core": "^0.15.0",
|
"@eslint/core": "^0.15.2",
|
||||||
"levn": "^0.4.1"
|
"levn": "^0.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -1234,9 +1234,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
||||||
"version": "0.15.0",
|
"version": "0.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
|
||||||
"integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==",
|
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -5,10 +5,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "run-p type-check \"build-only {@}\" --",
|
"build-typecheck": "run-p type-check \"build-only {@}\" --",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"build-only": "vite build",
|
"build": "vite build",
|
||||||
"type-check": "vue-tsc --build",
|
"type-check": "vue-tsc --build",
|
||||||
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||||
"lint:eslint": "eslint . --fix",
|
"lint:eslint": "eslint . --fix",
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<h3>Hello World</h3>
|
|
||||||
</template>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import HelloWorld from '../HelloWorld.vue'
|
|
||||||
|
|
||||||
describe('HelloWorld', () => {})
|
|
||||||
@ -25,6 +25,15 @@ export const messageHandler = (room: string = 'general') => {
|
|||||||
},
|
},
|
||||||
}).then(async (response) => {
|
}).then(async (response) => {
|
||||||
let data = await getJsonOrError(response)
|
let data = await getJsonOrError(response)
|
||||||
|
let latest_new_msg = Math.max(...Object.keys(data).map(Number), -Infinity)
|
||||||
|
if (latest_new_msg === -Infinity) {
|
||||||
|
throw new Error('Invalid server response')
|
||||||
|
}
|
||||||
|
if (messages.value[latest_new_msg]) {
|
||||||
|
// If the latest message is already in the cache, return the cached messages
|
||||||
|
return messages.value
|
||||||
|
}
|
||||||
|
|
||||||
messages.value = { ...messages.value, ...(data as Record<number, Message>) }
|
messages.value = { ...messages.value, ...(data as Record<number, Message>) }
|
||||||
return messages.value
|
return messages.value
|
||||||
})
|
})
|
||||||
@ -49,7 +58,7 @@ export const messageHandler = (room: string = 'general') => {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
messages,
|
messages,
|
||||||
lastMsg: () => {
|
lastMsg: () => {
|
||||||
const highestKey = Math.max(...Object.keys(messages).map(Number))
|
const highestKey: number = Math.max(...Object.keys(messages.value).map(Number), -Infinity)
|
||||||
if (highestKey === -Infinity) {
|
if (highestKey === -Infinity) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
@ -59,7 +68,7 @@ export const messageHandler = (room: string = 'general') => {
|
|||||||
const keys = Object.keys(messages.value).map(Number)
|
const keys = Object.keys(messages.value).map(Number)
|
||||||
const index = keys.indexOf(Number(id))
|
const index = keys.indexOf(Number(id))
|
||||||
if (index > 0) {
|
if (index > 0) {
|
||||||
console.log('Previous message found:', messages.value[keys[index - 1]])
|
//console.log('Previous message found:', messages.value[keys[index - 1]])
|
||||||
return messages.value[keys[index - 1]]
|
return messages.value[keys[index - 1]]
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@ -42,7 +42,6 @@ onMounted(() => {
|
|||||||
document.addEventListener('keypress', handleKeyPress)
|
document.addEventListener('keypress', handleKeyPress)
|
||||||
msg.requestMessages()
|
msg.requestMessages()
|
||||||
msgTimer.value = setInterval(() => {
|
msgTimer.value = setInterval(() => {
|
||||||
console.log('Polling for messages...')
|
|
||||||
msg.requestMessages(msg.lastMsg()?.timestamp)
|
msg.requestMessages(msg.lastMsg()?.timestamp)
|
||||||
}, 3000) // 3s
|
}, 3000) // 3s
|
||||||
})
|
})
|
||||||
@ -57,7 +56,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<div class="grid grid-cols-1 grid-rows-10 boxed h-[75vh] max-w-128">
|
<div class="grid grid-cols-1 grid-rows-10 boxed h-[75vh] max-w-128 min-w-100">
|
||||||
<h1 class="border-b-2">Chat</h1>
|
<h1 class="border-b-2">Chat</h1>
|
||||||
<div class="row-span-8 flex flex-col overflow-scroll" ref="scrollContainer">
|
<div class="row-span-8 flex flex-col overflow-scroll" ref="scrollContainer">
|
||||||
<div v-if="msg" v-for="(message, id) in msg.messages.value" :key="message.id" class="mb-4">
|
<div v-if="msg" v-for="(message, id) in msg.messages.value" :key="message.id" class="mb-4">
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
"exclude": ["src/**/__tests__/*"],
|
"exclude": ["src/**/__tests__/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@ -5,10 +5,10 @@
|
|||||||
"vitest.config.*",
|
"vitest.config.*",
|
||||||
"cypress.config.*",
|
"cypress.config.*",
|
||||||
"nightwatch.conf.*",
|
"nightwatch.conf.*",
|
||||||
"playwright.config.*",
|
"playwright.config.*"
|
||||||
"eslint.config.*"
|
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
port: 8081,
|
port: 8081,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8080',
|
'/api': 'http://localhost:7000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
|
||||||
import viteConfig from './vite.config'
|
|
||||||
|
|
||||||
export default mergeConfig(
|
|
||||||
viteConfig,
|
|
||||||
defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
|
||||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
1
simple_chat_api/__init__.py
Normal file
1
simple_chat_api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from simple_chat_api.main import main
|
||||||
4
simple_chat_api/__main__.py
Normal file
4
simple_chat_api/__main__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from simple_chat_api.main import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -3,6 +3,7 @@ from sqlalchemy.orm import declarative_base, sessionmaker
|
|||||||
from sqlalchemy import Column, Integer, String
|
from sqlalchemy import Column, Integer, String
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import time
|
import time
|
||||||
|
import regex
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
@ -34,8 +35,8 @@ class DbConnector:
|
|||||||
def _create_defaults(self):
|
def _create_defaults(self):
|
||||||
try:
|
try:
|
||||||
self.add_user(name="admin", hash="$2b$12$IcUr5w7pIFaXaGVFP5yVV.b.sIYjDbETR3l2PKgWO4nkrHU.1HmFa", role="admin")
|
self.add_user(name="admin", hash="$2b$12$IcUr5w7pIFaXaGVFP5yVV.b.sIYjDbETR3l2PKgWO4nkrHU.1HmFa", role="admin")
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
print("Default admin user already exists")
|
print(f"Default admin user already exists: {e}")
|
||||||
|
|
||||||
def get_user(self, name: str) -> User | dict[User] | None:
|
def get_user(self, name: str) -> User | dict[User] | None:
|
||||||
if not name:
|
if not name:
|
||||||
@ -45,6 +46,8 @@ class DbConnector:
|
|||||||
def add_user(self, name: str, hash: str, role: str = "user"):
|
def add_user(self, name: str, hash: str, role: str = "user"):
|
||||||
if self.get_user(name):
|
if self.get_user(name):
|
||||||
raise ValueError("User already exists")
|
raise ValueError("User already exists")
|
||||||
|
if regex.match(r'^[^<>:;,?"*|/]+$', name) is None:
|
||||||
|
raise ValueError("Invalid username")
|
||||||
new_user = User()
|
new_user = User()
|
||||||
new_user.name = name
|
new_user.name = name
|
||||||
new_user.hash = hash
|
new_user.hash = hash
|
||||||
@ -1,8 +1,6 @@
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from bottle import Bottle, request, response
|
from bottle import Bottle, request, response
|
||||||
from config import JWT_SECRET
|
from simple_chat_api.config import JWT_SECRET, hash_context
|
||||||
from config import hash_context
|
|
||||||
|
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from bottle import request, response
|
from bottle import request, response
|
||||||
@ -11,7 +9,7 @@ from bottle import request, response
|
|||||||
import time
|
import time
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
|
|
||||||
from utils import read_keys_from_request
|
from simple_chat_api.utils import read_keys_from_request
|
||||||
|
|
||||||
app = Bottle()
|
app = Bottle()
|
||||||
|
|
||||||
@ -2,9 +2,9 @@
|
|||||||
from bottle import Bottle, request, response
|
from bottle import Bottle, request, response
|
||||||
from json import dumps, loads
|
from json import dumps, loads
|
||||||
|
|
||||||
from db_handler import User, Message
|
from simple_chat_api.db_handler import User, Message
|
||||||
from endpoints.auth import user_guard
|
from simple_chat_api.endpoints.auth import user_guard
|
||||||
from utils import read_keys_from_request
|
from simple_chat_api.utils import read_keys_from_request
|
||||||
app = Bottle()
|
app = Bottle()
|
||||||
|
|
||||||
def serialize_message(messages: list[Message]) -> str:
|
def serialize_message(messages: list[Message]) -> str:
|
||||||
@ -1,9 +1,9 @@
|
|||||||
from bottle import request, Bottle
|
from bottle import request, Bottle
|
||||||
|
|
||||||
from db_handler import DbConnector
|
from simple_chat_api.db_handler import DbConnector
|
||||||
|
|
||||||
import endpoints.auth as auth
|
import simple_chat_api.endpoints.auth as auth
|
||||||
import endpoints.messages as messages
|
import simple_chat_api.endpoints.messages as messages
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
# Needet because of: https://github.com/pyca/bcrypt/issues/684
|
# Needet because of: https://github.com/pyca/bcrypt/issues/684
|
||||||
@ -14,7 +14,7 @@ app = Bottle()
|
|||||||
|
|
||||||
|
|
||||||
def initialize_app():
|
def initialize_app():
|
||||||
db = DbConnector("sqlite:///./data/db.sqlite")
|
db = DbConnector("sqlite:///./data/data.sqlite")
|
||||||
|
|
||||||
@app.hook('before_request')
|
@app.hook('before_request')
|
||||||
def attach_resources():
|
def attach_resources():
|
||||||
@ -23,7 +23,7 @@ def initialize_app():
|
|||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def main():
|
||||||
initialize_app()
|
initialize_app()
|
||||||
|
|
||||||
app.mount('/user', auth.app)
|
app.mount('/user', auth.app)
|
||||||
@ -31,4 +31,4 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
root_app = Bottle()
|
root_app = Bottle()
|
||||||
root_app.mount('/api', app)
|
root_app.mount('/api', app)
|
||||||
root_app.run(host='localhost', port=8080, debug=True)
|
root_app.run(host='localhost', port=7000, debug=True)
|
||||||
11
simple_chat_api/requirements.txt
Normal file
11
simple_chat_api/requirements.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
bcrypt==4.3.0
|
||||||
|
bottle==0.13.4
|
||||||
|
cffi==1.17.1
|
||||||
|
cryptography==45.0.4
|
||||||
|
greenlet==3.2.3
|
||||||
|
passlib==1.7.4
|
||||||
|
pycparser==2.22
|
||||||
|
PyJWT==2.10.1
|
||||||
|
regex==2025.7.34
|
||||||
|
SQLAlchemy==2.0.41
|
||||||
|
typing_extensions==4.14.0
|
||||||
Loading…
x
Reference in New Issue
Block a user