QCDevs commited on
Commit
19d59ff
·
verified ·
1 Parent(s): 061cc66

Add draft

Browse files
Files changed (8) hide show
  1. Dockerfile +28 -0
  2. README.md +5 -6
  3. app.py +424 -0
  4. celery_config.py +17 -0
  5. docker-compose.yml +67 -0
  6. gunicorn_config.py +33 -0
  7. prometheus.yml +20 -0
  8. requirements.txt +15 -0
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ build-essential \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy requirements first to leverage Docker cache
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy application code
15
+ COPY . .
16
+
17
+ # Create uploads directory
18
+ RUN mkdir -p uploads && chmod 777 uploads
19
+
20
+ # Create a non-root user
21
+ RUN useradd -m appuser && chown -R appuser:appuser /app
22
+ USER appuser
23
+
24
+ # # Set environment variables for the buffered output
25
+ # ENV PYTHONUNBUFFERED=1
26
+
27
+ # Default command (can be overridden in docker-compose.yml)
28
+ CMD ["gunicorn", "--config", "gunicorn_config.py", "app:app"]
README.md CHANGED
@@ -1,11 +1,10 @@
1
  ---
2
- title: Procrustes
3
- emoji: 🐠
4
- colorFrom: green
5
- colorTo: pink
6
  sdk: docker
7
  pinned: false
8
  license: gpl-3.0
 
9
  ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: procrustes
3
+ emoji: 🐳
4
+ colorFrom: blue
5
+ colorTo: blue
6
  sdk: docker
7
  pinned: false
8
  license: gpl-3.0
9
+ app_port: 7860
10
  ---
 
 
app.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # import json
2
+ import inspect
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ import threading
7
+ import uuid
8
+ import warnings
9
+ from datetime import datetime
10
+ from typing import Callable, Dict
11
+
12
+ import markdown
13
+ import numpy as np
14
+ import orjson
15
+ import pandas as pd
16
+
17
+ # originally use jsonify from flask, but it doesn't support numpy array
18
+ from flask import Flask, Response, render_template, request, send_file
19
+ from flask_status import FlaskStatus
20
+ from procrustes import (
21
+ generalized,
22
+ generic,
23
+ kopt_heuristic_double,
24
+ kopt_heuristic_single,
25
+ orthogonal,
26
+ orthogonal_2sided,
27
+ permutation,
28
+ permutation_2sided,
29
+ rotational,
30
+ softassign,
31
+ symmetric,
32
+ )
33
+ from werkzeug.utils import secure_filename
34
+
35
+ from celery_config import celery
36
+
37
+ app = Flask(__name__)
38
+ app_status = FlaskStatus(app)
39
+ app.config["MAX_CONTENT_LENGTH"] = 32 * 1024 * 1024 # 32MB max file size
40
+ app.config["UPLOAD_FOLDER"] = "uploads"
41
+ file_lock = threading.Lock()
42
+
43
+ # Ensure upload directory exists
44
+ os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
45
+
46
+ ALLOWED_EXTENSIONS = {"txt", "npz", "xlsx", "xls"}
47
+
48
+ # Map algorithm names to their functions
49
+ ALGORITHM_MAP = {
50
+ "orthogonal": orthogonal,
51
+ "rotational": rotational,
52
+ "permutation": permutation,
53
+ # "generalized": generalized,
54
+ "generic": generic,
55
+ # "kopt_heuristic_single": kopt_heuristic_single,
56
+ # "kopt_heuristic_double": kopt_heuristic_double,
57
+ "orthogonal_2sided": orthogonal_2sided,
58
+ "permutation_2sided": permutation_2sided,
59
+ "softassign": softassign,
60
+ "symmetric": symmetric,
61
+ }
62
+
63
+
64
+ def allowed_file(filename):
65
+ return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
66
+
67
+
68
+ def get_unique_upload_dir():
69
+ """Create a unique directory for each upload session."""
70
+ unique_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(uuid.uuid4()))
71
+ os.makedirs(unique_dir, exist_ok=True)
72
+ return unique_dir
73
+
74
+
75
+ def clean_upload_dir(directory):
76
+ """Safely clean up upload directory."""
77
+ try:
78
+ if os.path.exists(directory):
79
+ shutil.rmtree(directory)
80
+ except Exception as e:
81
+ print(f"Error cleaning directory {directory}: {e}")
82
+
83
+
84
+ def load_data(filepath):
85
+ """Load data from various file formats."""
86
+ try:
87
+ ext = filepath.rsplit(".", 1)[1].lower()
88
+ if ext == "npz":
89
+ with np.load(filepath) as data:
90
+ return data["arr_0"] if "arr_0" in data else next(iter(data.values()))
91
+ elif ext == "txt":
92
+ return np.loadtxt(filepath)
93
+ elif ext in ["xlsx", "xls"]:
94
+ df = pd.read_excel(filepath)
95
+ return df.to_numpy()
96
+ except Exception as e:
97
+ raise ValueError(f"Error loading file {filepath}: {str(e)}")
98
+
99
+
100
+ def save_data(data, format_type):
101
+ """Save data in the specified format."""
102
+ temp_dir = tempfile.mkdtemp()
103
+ filename = os.path.join(temp_dir, f"result.{format_type}")
104
+
105
+ if format_type == "npz":
106
+ np.savez(filename, result=data)
107
+ elif format_type == "txt":
108
+ np.savetxt(filename, data)
109
+ elif format_type in ["xlsx", "xls"]:
110
+ pd.DataFrame(data).to_excel(filename, index=False)
111
+
112
+ return filename
113
+
114
+
115
+ def create_json_response(data, status=200):
116
+ """Create a JSON response using orjson for better numpy array handling"""
117
+ return Response(
118
+ orjson.dumps(data, option=orjson.OPT_SERIALIZE_NUMPY, default=str),
119
+ status=status,
120
+ mimetype="application/json",
121
+ )
122
+
123
+
124
+ def read_markdown_file(filename):
125
+ """Read and convert markdown file to HTML."""
126
+ filepath = os.path.join(os.path.dirname(__file__), "md_files", filename)
127
+ try:
128
+ with open(filepath, "r", encoding="utf-8") as f:
129
+ content = f.read()
130
+
131
+ # Pre-process math blocks to protect them
132
+ # content = content.replace('\\\\', '\\\\\\\\') # Escape backslashes in math
133
+
134
+ # Convert markdown to HTML with math and table support
135
+ md = markdown.Markdown(extensions=["tables", "fenced_code", "codehilite", "attr_list"])
136
+
137
+ # First pass: convert markdown to HTML
138
+ html = md.convert(content)
139
+
140
+ # Post-process math blocks
141
+ # Handle display math ($$...$$)
142
+ html = html.replace("<p>$$", '<div class="math-block">$$')
143
+ html = html.replace("$$</p>", "$$</div>")
144
+
145
+ # Handle inline math ($...$)
146
+ # We don't need special handling for inline math as MathJax will handle it
147
+
148
+ return html
149
+ except Exception as e:
150
+ print(f"Error reading markdown file {filename}: {e}")
151
+ return f"<p>Error loading content: {str(e)}</p>"
152
+
153
+
154
+ def get_default_parameters(func):
155
+ """
156
+ Collect the default arguments of a given function as a dictionary.
157
+
158
+ Parameters
159
+ ----------
160
+ func : Callable
161
+ The function to inspect.
162
+
163
+ Returns
164
+ -------
165
+ Dict[str, object]
166
+ A dictionary where keys are parameter names and values are their default values.
167
+
168
+ """
169
+ signature = inspect.signature(func)
170
+ return {
171
+ name: param.default
172
+ for name, param in signature.parameters.items()
173
+ if param.default is not inspect.Parameter.empty
174
+ }
175
+
176
+
177
+ @app.route("/get_default_params/<algorithm>")
178
+ def get_default_params(algorithm):
179
+ """API endpoint to get default parameters for an algorithm."""
180
+ if algorithm not in ALGORITHM_MAP:
181
+ return create_json_response({"error": f"Unknown algorithm: {algorithm}"}, 400)
182
+
183
+ try:
184
+ func = ALGORITHM_MAP[algorithm]
185
+ return create_json_response(get_default_parameters(func))
186
+ except Exception as e:
187
+ return create_json_response({"error": f"Error getting parameters: {str(e)}"}, 500)
188
+
189
+
190
+ @app.route("/")
191
+ def home():
192
+ return render_template("index.html")
193
+
194
+
195
+ @app.route("/get_default_params/<algorithm>")
196
+ def default_params(algorithm):
197
+ # return jsonify(get_default_params(algorithm))
198
+ return create_json_response(get_default_params(algorithm))
199
+
200
+
201
+ @app.route("/md/<filename>")
202
+ def get_markdown(filename):
203
+ """Serve markdown files as HTML."""
204
+ if not filename.endswith(".md"):
205
+ filename = filename + ".md"
206
+ html = read_markdown_file(filename)
207
+ return create_json_response({"html": html})
208
+
209
+
210
+ def process_procrustes(array1, array2, algorithm, parameters):
211
+ """
212
+ Process two arrays using the specified Procrustes algorithm.
213
+
214
+ Parameters
215
+ ----------
216
+ array1 : np.ndarray
217
+ First input array
218
+ array2 : np.ndarray
219
+ Second input array
220
+ algorithm : str
221
+ Name of the Procrustes algorithm to use
222
+ parameters : dict
223
+ Parameters for the algorithm
224
+
225
+ Returns
226
+ -------
227
+ dict
228
+ Dictionary containing results and any warnings
229
+ """
230
+ warning_message = None
231
+
232
+ # Check for NaN values
233
+ if np.isnan(array1).any() or np.isnan(array2).any():
234
+ array1 = np.nan_to_num(array1)
235
+ array2 = np.nan_to_num(array2)
236
+ warning_message = "Input matrices contain NaN values. Replaced with 0."
237
+
238
+ # Apply Procrustes algorithm
239
+ if algorithm.lower() in ALGORITHM_MAP:
240
+ result = ALGORITHM_MAP[algorithm.lower()](array1, array2, **parameters)
241
+ else:
242
+ raise ValueError(f"Unknown algorithm: {algorithm}")
243
+
244
+ # Extract results
245
+ transformation = (
246
+ result.t
247
+ if hasattr(result, "t")
248
+ else result.t1 if hasattr(result, "t1") else np.eye(array1.shape[1])
249
+ )
250
+
251
+ new_array = (
252
+ result.new_array
253
+ if hasattr(result, "new_array")
254
+ else result.array_transformed if hasattr(result, "array_transformed") else array2
255
+ )
256
+
257
+ # Prepare response
258
+ response_data = {
259
+ "error": float(result.error),
260
+ "transformation": transformation,
261
+ "new_array": new_array,
262
+ }
263
+
264
+ if warning_message:
265
+ response_data["warning"] = warning_message
266
+
267
+ return response_data
268
+
269
+
270
+ @celery.task(bind=True)
271
+ def process_matrices(self, algorithm, params, matrix1_data, matrix2_data):
272
+ """Celery task for processing matrices asynchronously."""
273
+ try:
274
+ # Convert lists back to numpy arrays
275
+ matrix1 = np.asarray(matrix1_data, dtype=float)
276
+ matrix2 = np.asarray(matrix2_data, dtype=float)
277
+
278
+ if matrix1.size == 0 or matrix2.size == 0:
279
+ raise ValueError("Empty matrix received")
280
+
281
+ return process_procrustes(matrix1, matrix2, algorithm, params)
282
+
283
+ except Exception as e:
284
+ return {"error": f"Processing error: {str(e)}"}
285
+
286
+
287
+ @app.route("/upload", methods=["POST"])
288
+ def upload_file():
289
+ """Handle file upload and process matrices."""
290
+ print("Received upload request")
291
+
292
+ if "file1" not in request.files or "file2" not in request.files:
293
+ return create_json_response({"error": "Both files are required"}, 400)
294
+
295
+ file1 = request.files["file1"]
296
+ file2 = request.files["file2"]
297
+ algorithm = request.form.get("algorithm", "orthogonal")
298
+
299
+ if file1.filename == "" or file2.filename == "":
300
+ return create_json_response({"error": "No selected files"}, 400)
301
+
302
+ if not (allowed_file(file1.filename) and allowed_file(file2.filename)):
303
+ return create_json_response({"error": "Invalid file type"}, 400)
304
+
305
+ # Create a unique directory for this upload
306
+ upload_dir = get_unique_upload_dir()
307
+
308
+ try:
309
+ # Parse parameters
310
+ try:
311
+ parameters = orjson.loads(request.form.get("parameters", "{}"))
312
+ except orjson.JSONDecodeError:
313
+ parameters = get_default_parameters(algorithm)
314
+
315
+ # Save files with unique names
316
+ file1_path = os.path.join(
317
+ upload_dir, secure_filename(str(uuid.uuid4()) + "_" + file1.filename)
318
+ )
319
+ file2_path = os.path.join(
320
+ upload_dir, secure_filename(str(uuid.uuid4()) + "_" + file2.filename)
321
+ )
322
+
323
+ with file_lock:
324
+ file1.save(file1_path)
325
+ file2.save(file2_path)
326
+
327
+ # Load data
328
+ array1 = load_data(file1_path)
329
+ array2 = load_data(file2_path)
330
+ print(f"Arrays loaded - shapes: {array1.shape}, {array2.shape}")
331
+
332
+ # Process the matrices
333
+ result = process_procrustes(array1, array2, algorithm, parameters)
334
+ return create_json_response(result)
335
+
336
+ except Exception as e:
337
+ print(f"Error occurred: {str(e)}")
338
+ import traceback
339
+
340
+ print(traceback.format_exc())
341
+ return create_json_response({"error": str(e)}, 500)
342
+
343
+ finally:
344
+ # Clean up the unique upload directory
345
+ clean_upload_dir(upload_dir)
346
+
347
+
348
+ @app.route("/status/<task_id>")
349
+ def task_status(task_id):
350
+ task = process_matrices.AsyncResult(task_id)
351
+ if task.state == "PENDING":
352
+ response = {"state": task.state, "status": "Pending..."}
353
+ elif task.state != "FAILURE":
354
+ response = {
355
+ "state": task.state,
356
+ "result": task.result,
357
+ }
358
+ if task.state == "SUCCESS":
359
+ response["status"] = "Task completed!"
360
+ else:
361
+ response["status"] = "Processing..."
362
+ else:
363
+ response = {
364
+ "state": task.state,
365
+ "status": str(task.info),
366
+ }
367
+ return create_json_response(response)
368
+
369
+
370
+ @app.route("/download", methods=["POST"])
371
+ def download():
372
+ try:
373
+ data = orjson.loads(request.form["data"])
374
+ format_type = request.form["format"]
375
+
376
+ # Create temporary file
377
+ temp_dir = tempfile.mkdtemp()
378
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
379
+ filename = f"procrustes_result_{timestamp}"
380
+
381
+ if format_type == "npz":
382
+ filepath = os.path.join(temp_dir, f"{filename}.npz")
383
+ np.savez(filepath, np.array(data))
384
+ elif format_type == "xlsx":
385
+ filepath = os.path.join(temp_dir, f"{filename}.xlsx")
386
+ pd.DataFrame(data).to_excel(filepath, index=False)
387
+ else: # txt
388
+ filepath = os.path.join(temp_dir, f"{filename}.txt")
389
+ np.savetxt(filepath, np.array(data))
390
+
391
+ return send_file(filepath, as_attachment=True)
392
+ except Exception as e:
393
+ return create_json_response({"error": str(e)}, 500)
394
+
395
+
396
+ @app.route("/status")
397
+ def server_status():
398
+ """Return server status"""
399
+ status = {"status": "ok", "components": {"flask": True, "celery": False, "redis": False}}
400
+
401
+ # Check Celery
402
+ try:
403
+ celery.control.ping(timeout=1)
404
+ status["components"]["celery"] = True
405
+ except Exception as e:
406
+ print(f"Celery check failed: {e}")
407
+
408
+ # Check Redis
409
+ try:
410
+ redis_client = celery.backend.client
411
+ redis_client.ping()
412
+ status["components"]["redis"] = True
413
+ except Exception as e:
414
+ print(f"Redis check failed: {e}")
415
+
416
+ # Set overall status based on components
417
+ if not all(status["components"].values()):
418
+ status["status"] = "degraded"
419
+
420
+ return create_json_response(status)
421
+
422
+
423
+ if __name__ == "__main__":
424
+ app.run(debug=True, host="0.0.0.0", port=7860)
celery_config.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from celery import Celery
2
+
3
+ celery = Celery("procrustes_server", broker="redis://redis:6379/0", backend="redis://redis:6379/0")
4
+
5
+ celery.conf.update(
6
+ worker_max_tasks_per_child=1000,
7
+ worker_prefetch_multiplier=1,
8
+ task_acks_late=True,
9
+ task_reject_on_worker_lost=True,
10
+ broker_pool_limit=None,
11
+ broker_connection_timeout=30,
12
+ result_expires=3600, # Results expire after 1 hour
13
+ task_track_started=True,
14
+ task_time_limit=300, # 5 minutes
15
+ task_soft_time_limit=240, # 4 minutes
16
+ worker_concurrency=4, # Number of worker processes per Celery worker
17
+ )
docker-compose.yml ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ web:
5
+ build: .
6
+ command: gunicorn --config gunicorn_config.py app:app --reload
7
+ ports:
8
+ - "8000:8000"
9
+ volumes:
10
+ - .:/app
11
+ - /app/__pycache__
12
+ depends_on:
13
+ - redis
14
+ environment:
15
+ - FLASK_ENV=development
16
+ - FLASK_DEBUG=1
17
+ - REDIS_URL=redis://redis:6379/0
18
+ # build: .
19
+ # command: python -m flask run --host=0.0.0.0 --port=8000 --debug
20
+ # ports:
21
+ # - "3000:8000"
22
+ # volumes:
23
+ # - .:/app
24
+ # - /app/__pycache__
25
+ # environment:
26
+ # - FLASK_ENV=development
27
+ # - FLASK_DEBUG=1
28
+ # - REDIS_URL=redis://redis:6379/0
29
+
30
+ celery_worker:
31
+ build: .
32
+ command: celery -A app.celery worker --loglevel=info
33
+ volumes:
34
+ - .:/app
35
+ - /app/__pycache__
36
+ depends_on:
37
+ - redis
38
+ deploy:
39
+ replicas: 8
40
+ resources:
41
+ limits:
42
+ cpus: '1'
43
+ memory: 1G
44
+
45
+ celery_flower:
46
+ build: .
47
+ command: celery -A app.celery flower
48
+ ports:
49
+ - "5555:5555"
50
+ depends_on:
51
+ - redis
52
+ - celery_worker
53
+
54
+ redis:
55
+ image: redis:latest
56
+ ports:
57
+ - "6379:6379"
58
+ volumes:
59
+ - redis_data:/data
60
+ deploy:
61
+ resources:
62
+ limits:
63
+ cpus: '0.5'
64
+ memory: 256M
65
+
66
+ volumes:
67
+ redis_data:
gunicorn_config.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import multiprocessing
2
+
3
+ # Number of worker processes
4
+ workers = multiprocessing.cpu_count() * 2 + 1
5
+
6
+ # Number of threads per worker
7
+ threads = 4
8
+
9
+ # Maximum number of pending connections
10
+ backlog = 2048
11
+
12
+ # Maximum number of requests a worker will process before restarting
13
+ max_requests = 1000
14
+ max_requests_jitter = 50
15
+
16
+ # Timeout for worker processes
17
+ timeout = 300
18
+
19
+ # Keep-alive timeout
20
+ keepalive = 5
21
+
22
+ # Log level
23
+ loglevel = "info"
24
+
25
+ # Access log format
26
+ accesslog = "-"
27
+ errorlog = "-"
28
+
29
+ # Bind address
30
+ bind = "0.0.0.0:8000"
31
+
32
+ # Worker class
33
+ worker_class = "gevent"
prometheus.yml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ global:
2
+ scrape_interval: 15s
3
+ evaluation_interval: 15s
4
+
5
+ scrape_configs:
6
+ - job_name: 'prometheus'
7
+ static_configs:
8
+ - targets: ['localhost:9090']
9
+
10
+ - job_name: 'flask'
11
+ static_configs:
12
+ - targets: ['web:8000']
13
+
14
+ - job_name: 'redis'
15
+ static_configs:
16
+ - targets: ['redis-exporter:9121']
17
+
18
+ - job_name: 'flower'
19
+ static_configs:
20
+ - targets: ['flower:5555']
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ flask==3.0.0
2
+ qc-procrustes>=1.1.1
3
+ numpy==1.26.2
4
+ pandas==2.1.4
5
+ openpyxl>=3.0.9
6
+ werkzeug>=3.0.0
7
+ gunicorn==21.2.0
8
+ gevent==23.9.1
9
+ redis==5.0.1
10
+ celery==5.3.6
11
+ flower==2.0.1
12
+ orjson==3.10.12
13
+ flask_status==1.0.1
14
+ markdown>=3.5.1
15
+ Pygments>=2.17.2