qalita 2.3.1__py3-none-any.whl → 2.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. qalita/__main__.py +213 -9
  2. qalita/commands/{agent.py → worker.py} +89 -89
  3. qalita/internal/config.py +26 -19
  4. qalita/internal/utils.py +1 -1
  5. qalita/web/app.py +97 -14
  6. qalita/web/blueprints/context.py +13 -60
  7. qalita/web/blueprints/dashboard.py +35 -76
  8. qalita/web/blueprints/helpers.py +154 -63
  9. qalita/web/blueprints/sources.py +29 -61
  10. qalita/web/blueprints/{agents.py → workers.py} +108 -185
  11. qalita-2.5.2.dist-info/METADATA +66 -0
  12. qalita-2.5.2.dist-info/RECORD +24 -0
  13. {qalita-2.3.1.dist-info → qalita-2.5.2.dist-info}/WHEEL +1 -1
  14. qalita-2.5.2.dist-info/entry_points.txt +2 -0
  15. qalita/web/blueprints/studio.py +0 -1255
  16. qalita/web/public/chatgpt.svg +0 -3
  17. qalita/web/public/claude.png +0 -0
  18. qalita/web/public/favicon.ico +0 -0
  19. qalita/web/public/gemini.png +0 -0
  20. qalita/web/public/logo-no-slogan.png +0 -0
  21. qalita/web/public/logo-white-no-slogan.svg +0 -11
  22. qalita/web/public/mistral.svg +0 -1
  23. qalita/web/public/noise.webp +0 -0
  24. qalita/web/public/ollama.png +0 -0
  25. qalita/web/public/platform.png +0 -0
  26. qalita/web/public/sources-logos/alloy-db.png +0 -0
  27. qalita/web/public/sources-logos/amazon-athena.png +0 -0
  28. qalita/web/public/sources-logos/amazon-rds.png +0 -0
  29. qalita/web/public/sources-logos/api.svg +0 -2
  30. qalita/web/public/sources-logos/avro.svg +0 -20
  31. qalita/web/public/sources-logos/azure-database-mysql.png +0 -0
  32. qalita/web/public/sources-logos/azure-database-postgresql.png +0 -0
  33. qalita/web/public/sources-logos/azure-sql-database.png +0 -0
  34. qalita/web/public/sources-logos/azure-sql-managed-instance.png +0 -0
  35. qalita/web/public/sources-logos/azure-synapse-analytics.png +0 -0
  36. qalita/web/public/sources-logos/azure_blob.svg +0 -1
  37. qalita/web/public/sources-logos/bigquery.png +0 -0
  38. qalita/web/public/sources-logos/cassandra.svg +0 -254
  39. qalita/web/public/sources-logos/clickhouse.png +0 -0
  40. qalita/web/public/sources-logos/cloud-sql.png +0 -0
  41. qalita/web/public/sources-logos/cockroach-db.png +0 -0
  42. qalita/web/public/sources-logos/csv.svg +0 -1
  43. qalita/web/public/sources-logos/database.svg +0 -3
  44. qalita/web/public/sources-logos/databricks.png +0 -0
  45. qalita/web/public/sources-logos/duckdb.png +0 -0
  46. qalita/web/public/sources-logos/elasticsearch.svg +0 -1
  47. qalita/web/public/sources-logos/excel.svg +0 -1
  48. qalita/web/public/sources-logos/file.svg +0 -1
  49. qalita/web/public/sources-logos/folder.svg +0 -6
  50. qalita/web/public/sources-logos/gcs.png +0 -0
  51. qalita/web/public/sources-logos/hdfs.svg +0 -1
  52. qalita/web/public/sources-logos/ibm-db2.png +0 -0
  53. qalita/web/public/sources-logos/json.png +0 -0
  54. qalita/web/public/sources-logos/maria-db.png +0 -0
  55. qalita/web/public/sources-logos/mongodb.svg +0 -1
  56. qalita/web/public/sources-logos/mssql.svg +0 -1
  57. qalita/web/public/sources-logos/mysql.svg +0 -7
  58. qalita/web/public/sources-logos/oracle.svg +0 -4
  59. qalita/web/public/sources-logos/parquet.svg +0 -16
  60. qalita/web/public/sources-logos/picture.png +0 -0
  61. qalita/web/public/sources-logos/postgresql.svg +0 -22
  62. qalita/web/public/sources-logos/questdb.png +0 -0
  63. qalita/web/public/sources-logos/redshift.png +0 -0
  64. qalita/web/public/sources-logos/s3.svg +0 -34
  65. qalita/web/public/sources-logos/sap-hana.png +0 -0
  66. qalita/web/public/sources-logos/sftp.png +0 -0
  67. qalita/web/public/sources-logos/single-store.png +0 -0
  68. qalita/web/public/sources-logos/snowflake.png +0 -0
  69. qalita/web/public/sources-logos/sqlite.svg +0 -104
  70. qalita/web/public/sources-logos/sqlserver.png +0 -0
  71. qalita/web/public/sources-logos/starburst.png +0 -0
  72. qalita/web/public/sources-logos/stream.png +0 -0
  73. qalita/web/public/sources-logos/teradata.png +0 -0
  74. qalita/web/public/sources-logos/timescale.png +0 -0
  75. qalita/web/public/sources-logos/xls.svg +0 -1
  76. qalita/web/public/sources-logos/xlsx.svg +0 -1
  77. qalita/web/public/sources-logos/yugabyte-db.png +0 -0
  78. qalita/web/public/studio-logo.svg +0 -10
  79. qalita/web/public/studio.css +0 -304
  80. qalita/web/public/studio.png +0 -0
  81. qalita/web/public/styles.css +0 -682
  82. qalita/web/templates/dashboard.html +0 -373
  83. qalita/web/templates/navbar.html +0 -40
  84. qalita/web/templates/sources/added.html +0 -57
  85. qalita/web/templates/sources/edit.html +0 -411
  86. qalita/web/templates/sources/select-source.html +0 -128
  87. qalita/web/templates/studio/agent-panel.html +0 -769
  88. qalita/web/templates/studio/context-panel.html +0 -300
  89. qalita/web/templates/studio/index.html +0 -79
  90. qalita/web/templates/studio/navbar.html +0 -14
  91. qalita/web/templates/studio/view-panel.html +0 -529
  92. qalita-2.3.1.dist-info/METADATA +0 -58
  93. qalita-2.3.1.dist-info/RECORD +0 -101
  94. qalita-2.3.1.dist-info/entry_points.txt +0 -3
  95. {qalita-2.3.1.dist-info → qalita-2.5.2.dist-info}/licenses/LICENSE +0 -0
@@ -7,11 +7,38 @@ import re
7
7
  import sys
8
8
  import shutil
9
9
  import subprocess
10
+ import threading
10
11
  from datetime import datetime
11
12
  from flask import current_app
12
13
 
13
14
  from qalita.internal.utils import logger
14
15
 
16
+ # Lock for .current_env file operations
17
+ _current_env_lock = threading.Lock()
18
+
19
+
20
+ def write_selected_env_atomic(pointer_path: str, env_path: str) -> bool:
21
+ """Atomically write the selected env pointer file. Uses locking to prevent race conditions.
22
+
23
+ Args:
24
+ pointer_path: Path to the .current_env pointer file
25
+ env_path: Path to the env file to write in the pointer
26
+
27
+ Returns:
28
+ True if successful, False otherwise
29
+ """
30
+ with _current_env_lock:
31
+ try:
32
+ # Use atomic write: write to temp file then rename
33
+ temp_path = pointer_path + ".tmp"
34
+ with open(temp_path, "w", encoding="utf-8") as pf:
35
+ pf.write(env_path)
36
+ os.replace(temp_path, pointer_path)
37
+ return True
38
+ except Exception as exc:
39
+ logger.warning(f"Failed to write selected env pointer: {exc}")
40
+ return False
41
+
15
42
 
16
43
  def qalita_home() -> str:
17
44
  try:
@@ -44,30 +71,58 @@ def parse_env_file(env_path: str) -> dict:
44
71
 
45
72
 
46
73
  def materialize_env_from_process_env(target_path: str) -> None:
74
+ """Update specific keys in an env file from process environment variables.
75
+
76
+ This function preserves ALL existing keys in the file and only updates
77
+ specific keys (NAME, MODE, TOKEN, URL) from the process environment.
78
+ It should only be called when creating a NEW file, not on existing files
79
+ that may contain important configuration.
80
+ """
47
81
  try:
48
82
  existing: dict[str, str] = {}
49
83
  if os.path.isfile(target_path):
50
84
  existing = parse_env_file(target_path) or {}
51
85
  env = os.environ
52
86
  key_groups = [
53
- ("QALITA_AGENT_NAME", ["QALITA_AGENT_NAME", "AGENT_NAME", "NAME"]),
54
- ("QALITA_AGENT_MODE", ["QALITA_AGENT_MODE", "AGENT_MODE", "MODE"]),
55
- ("QALITA_AGENT_TOKEN", ["QALITA_AGENT_TOKEN", "QALITA_TOKEN", "TOKEN"]),
87
+ ("QALITA_WORKER_NAME", ["QALITA_WORKER_NAME", "QALITA_AGENT_NAME", "AGENT_NAME", "NAME"]),
88
+ ("QALITA_WORKER_MODE", ["QALITA_WORKER_MODE", "QALITA_AGENT_MODE", "AGENT_MODE", "MODE"]),
89
+ ("QALITA_WORKER_TOKEN", ["QALITA_WORKER_TOKEN", "QALITA_AGENT_TOKEN", "QALITA_TOKEN", "TOKEN"]),
56
90
  (
57
- "QALITA_AGENT_ENDPOINT",
58
- ["QALITA_AGENT_ENDPOINT", "AGENT_ENDPOINT", "QALITA_URL", "URL"],
91
+ "QALITA_WORKER_ENDPOINT",
92
+ ["QALITA_WORKER_ENDPOINT", "QALITA_AGENT_ENDPOINT", "AGENT_ENDPOINT", "QALITA_URL", "URL"],
59
93
  ),
60
94
  ]
61
- updates: dict[str, str] = {}
62
- for _, aliases in key_groups:
63
- value = None
64
- for k in aliases:
65
- if k in env and env.get(k):
66
- value = env.get(k)
67
- updates[k] = value # type: ignore[assignment]
68
- break
95
+ # Start with existing keys to preserve everything
69
96
  merged = dict(existing)
70
- merged.update(updates)
97
+
98
+ # Only update specific keys from environment if they exist
99
+ # Map environment variable names to their canonical key names (use WORKER as canonical)
100
+ canonical_keys = {
101
+ "QALITA_WORKER_NAME": "QALITA_WORKER_NAME",
102
+ "QALITA_AGENT_NAME": "QALITA_WORKER_NAME",
103
+ "AGENT_NAME": "QALITA_WORKER_NAME",
104
+ "NAME": "QALITA_WORKER_NAME",
105
+ "QALITA_WORKER_MODE": "QALITA_WORKER_MODE",
106
+ "QALITA_AGENT_MODE": "QALITA_WORKER_MODE",
107
+ "AGENT_MODE": "QALITA_WORKER_MODE",
108
+ "MODE": "QALITA_WORKER_MODE",
109
+ "QALITA_WORKER_TOKEN": "QALITA_WORKER_TOKEN",
110
+ "QALITA_AGENT_TOKEN": "QALITA_WORKER_TOKEN",
111
+ "QALITA_TOKEN": "QALITA_WORKER_TOKEN",
112
+ "TOKEN": "QALITA_WORKER_TOKEN",
113
+ "QALITA_WORKER_ENDPOINT": "QALITA_WORKER_ENDPOINT",
114
+ "QALITA_AGENT_ENDPOINT": "QALITA_WORKER_ENDPOINT",
115
+ "AGENT_ENDPOINT": "QALITA_WORKER_ENDPOINT",
116
+ "QALITA_URL": "QALITA_WORKER_ENDPOINT",
117
+ "URL": "QALITA_WORKER_ENDPOINT",
118
+ }
119
+
120
+ # Update only the canonical keys from environment
121
+ for env_key, canonical_key in canonical_keys.items():
122
+ if env_key in env and env.get(env_key):
123
+ merged[canonical_key] = env.get(env_key)
124
+
125
+ # Write all keys (existing + updated)
71
126
  lines = []
72
127
  for k in sorted(merged.keys()):
73
128
  v = merged[k]
@@ -87,6 +142,17 @@ def materialize_env_from_process_env(target_path: str) -> None:
87
142
 
88
143
 
89
144
  def ensure_default_env_selected(pointer_path: str):
145
+ """Ensure a default env is selected. Uses locking to prevent race conditions."""
146
+ # Double-check: if file was created by another thread, use it
147
+ try:
148
+ if os.path.isfile(pointer_path):
149
+ with open(pointer_path, "r", encoding="utf-8") as f:
150
+ existing_path = f.read().strip()
151
+ if existing_path and os.path.isfile(existing_path):
152
+ return existing_path
153
+ except Exception:
154
+ pass
155
+
90
156
  try:
91
157
  base = qalita_home()
92
158
  env = os.environ
@@ -103,65 +169,80 @@ def ensure_default_env_selected(pointer_path: str):
103
169
  target = os.path.normpath(os.path.join(base, f".env-{safe}"))
104
170
  os.makedirs(base, exist_ok=True)
105
171
  materialize_env_from_process_env(target)
106
- try:
107
- with open(pointer_path, "w", encoding="utf-8") as pf:
108
- pf.write(target)
109
- except Exception:
110
- pass
172
+ # Use atomic write function (which handles locking)
173
+ write_selected_env_atomic(pointer_path, target)
111
174
  return target
112
175
  except Exception:
113
176
  return None
114
177
 
115
178
 
116
179
  def read_selected_env():
180
+ """Read the selected env file path. Uses locking to prevent race conditions when creating default.
181
+
182
+ NOTE: Does NOT call materialize_env_from_process_env() to avoid overwriting existing .env files.
183
+ The materialization should only happen when explicitly creating a new file, not on every read.
184
+ """
117
185
  p = selected_env_file_path()
118
186
  try:
119
- with open(p, "r", encoding="utf-8") as f:
120
- raw = f.read().strip()
121
- path = os.path.normpath(raw) if raw else None
122
- if path and os.path.isfile(path):
187
+ # Try to read the file first (read-only, no lock needed)
188
+ if os.path.isfile(p):
189
+ with open(p, "r", encoding="utf-8") as f:
190
+ raw = f.read().strip()
191
+ path = os.path.normpath(raw) if raw else None
192
+ if path and os.path.isfile(path):
193
+ # DO NOT call materialize_env_from_process_env() here - it would overwrite the file!
194
+ # Only return the path without modifying the file
195
+ return path
196
+ # Path exists but file doesn't - try to find it
123
197
  try:
124
- materialize_env_from_process_env(path)
125
- except Exception:
126
- pass
127
- return path
128
- try:
129
- base = qalita_home()
130
- if path:
131
- candidate = os.path.normpath(
132
- os.path.join(base, os.path.basename(path))
133
- )
134
- if os.path.isfile(candidate):
135
- logger.warning(
136
- f"Selected env pointer [{path}] not found. Using [{candidate}] under current QALITA_HOME."
198
+ base = qalita_home()
199
+ if path:
200
+ candidate = os.path.normpath(
201
+ os.path.join(base, os.path.basename(path))
137
202
  )
203
+ if os.path.isfile(candidate):
204
+ logger.warning(
205
+ f"Selected env pointer [{path}] not found. Using [{candidate}] under current QALITA_HOME."
206
+ )
207
+ # DO NOT call materialize_env_from_process_env() - preserve existing file content
208
+ # Use atomic write function (which handles locking)
209
+ # Double-check: if file was updated by another thread, use it
210
+ try:
211
+ if os.path.isfile(p):
212
+ with open(p, "r", encoding="utf-8") as f2:
213
+ existing = f2.read().strip()
214
+ if existing and os.path.isfile(existing):
215
+ return existing
216
+ except Exception:
217
+ pass
218
+ write_selected_env_atomic(p, candidate)
219
+ return candidate
138
220
  try:
221
+ os.makedirs(base, exist_ok=True)
222
+ # Only call materialize_env_from_process_env when creating a NEW file
223
+ # This is safe because the file doesn't exist yet
139
224
  materialize_env_from_process_env(candidate)
225
+ logger.warning(
226
+ f"Selected env pointer [{path}] not found. Created [{candidate}] under current QALITA_HOME from environment."
227
+ )
228
+ # Use atomic write function (which handles locking)
229
+ # Double-check: if file was updated by another thread, use it
230
+ try:
231
+ if os.path.isfile(p):
232
+ with open(p, "r", encoding="utf-8") as f2:
233
+ existing = f2.read().strip()
234
+ if existing and os.path.isfile(existing):
235
+ return existing
236
+ except Exception:
237
+ pass
238
+ write_selected_env_atomic(p, candidate)
239
+ return candidate
140
240
  except Exception:
141
241
  pass
142
- try:
143
- with open(p, "w", encoding="utf-8") as pf:
144
- pf.write(candidate)
145
- except Exception:
146
- pass
147
- return candidate
148
- try:
149
- os.makedirs(base, exist_ok=True)
150
- materialize_env_from_process_env(candidate)
151
- logger.warning(
152
- f"Selected env pointer [{path}] not found. Created [{candidate}] under current QALITA_HOME from environment."
153
- )
154
- try:
155
- with open(p, "w", encoding="utf-8") as pf:
156
- pf.write(candidate)
157
- except Exception:
158
- pass
159
- return candidate
160
- except Exception:
161
- pass
162
- except Exception:
163
- pass
164
- return ensure_default_env_selected(p)
242
+ except Exception:
243
+ pass
244
+ # File doesn't exist or is invalid - create default (with lock)
245
+ return ensure_default_env_selected(p)
165
246
  except Exception:
166
247
  logger.warning(f"No selected env pointer found at [{p}] or failed to read it")
167
248
  return ensure_default_env_selected(p)
@@ -283,7 +364,7 @@ def compute_agent_summary(cfg):
283
364
 
284
365
  agent_conf = {
285
366
  "name": pick(raw, "context", "remote", "name") or raw.get("name", ""),
286
- "mode": raw.get("mode", ""),
367
+ "mode": pick(raw, "context", "local", "mode") or raw.get("mode", ""),
287
368
  "url": pick(raw, "context", "local", "url") or raw.get("url", ""),
288
369
  "agent_id": pick(raw, "context", "remote", "id")
289
370
  or raw.get("agent_id", ""),
@@ -303,19 +384,22 @@ def compute_agent_summary(cfg):
303
384
  if agent_conf is None:
304
385
  agent_conf = {}
305
386
  agent_conf["name"] = (
306
- data.get("QALITA_AGENT_NAME")
387
+ data.get("QALITA_WORKER_NAME")
388
+ or data.get("QALITA_AGENT_NAME")
307
389
  or data.get("AGENT_NAME")
308
390
  or data.get("NAME")
309
391
  or agent_conf.get("name", "")
310
392
  )
311
393
  agent_conf["mode"] = (
312
- data.get("QALITA_AGENT_MODE")
394
+ data.get("QALITA_WORKER_MODE")
395
+ or data.get("QALITA_AGENT_MODE")
313
396
  or data.get("AGENT_MODE")
314
397
  or data.get("MODE")
315
398
  or agent_conf.get("mode", "")
316
399
  )
317
400
  agent_conf["url"] = (
318
- data.get("QALITA_AGENT_ENDPOINT")
401
+ data.get("QALITA_WORKER_ENDPOINT")
402
+ or data.get("QALITA_AGENT_ENDPOINT")
319
403
  or data.get("QALITA_URL")
320
404
  or data.get("URL")
321
405
  or agent_conf.get("url", "")
@@ -362,3 +446,10 @@ def compute_agent_summary(cfg):
362
446
  except Exception:
363
447
  agent_runs = []
364
448
  return agent_conf, agent_runs
449
+
450
+
451
+ # Aliases for backward compatibility (agent -> worker refactoring)
452
+ worker_pid_file_path = agent_pid_file_path
453
+ worker_log_file_path = agent_log_file_path
454
+ worker_status_payload = agent_status_payload
455
+ compute_worker_summary = compute_agent_summary
@@ -13,10 +13,7 @@ from qalita.internal.utils import logger
13
13
  from flask import (
14
14
  Blueprint,
15
15
  current_app,
16
- redirect,
17
- render_template,
18
16
  request,
19
- url_for,
20
17
  jsonify,
21
18
  )
22
19
  from qalita.internal.request import send_request
@@ -88,37 +85,10 @@ def list_sources():
88
85
  if isinstance(platform_url, str):
89
86
  platform_url = platform_url.rstrip("/")
90
87
 
91
- return render_template(
92
- "sources/list.html", sources=sources, platform_url=platform_url
93
- )
94
-
95
-
96
- @bp.get("/add")
97
- def add_source_view():
98
- # If a valid type is provided as query param, go directly to the form with that type preselected.
99
- # Otherwise, render the selection grid first.
100
- t = (request.args.get("type") or "").strip().lower()
101
- allowed = [
102
- "file",
103
- "csv",
104
- "excel",
105
- "folder",
106
- "postgresql",
107
- "mysql",
108
- "oracle",
109
- "mssql",
110
- "sqlite",
111
- "mongodb",
112
- "s3",
113
- "gcs",
114
- "azure_blob",
115
- "hdfs",
116
- ]
117
- if t and t in allowed:
118
- return render_template(
119
- "sources/edit.html", title="Add source", source=None, preselected_type=t
120
- )
121
- return render_template("sources/select-source.html")
88
+ return jsonify({
89
+ "sources": sources,
90
+ "platform_url": platform_url,
91
+ })
122
92
 
123
93
 
124
94
  @bp.post("/add")
@@ -245,12 +215,10 @@ def add_source_post():
245
215
  }
246
216
 
247
217
  if not validate_source_object(cfg, new_source, skip_connection=False):
248
- return render_template(
249
- "sources/edit.html",
250
- title="Add source",
251
- source=request.form,
252
- error="Validation failed. Check fields and connectivity.",
253
- )
218
+ return jsonify({
219
+ "ok": False,
220
+ "error": "Validation failed. Check fields and connectivity.",
221
+ }), 400
254
222
 
255
223
  cfg.config.setdefault("sources", []).append(new_source)
256
224
  cfg.save_source_config()
@@ -329,19 +297,13 @@ def add_source_post():
329
297
  except Exception:
330
298
  src_id = None
331
299
 
332
- return render_template(
333
- "sources/added.html", name=name, platform_url=platform_url, source_id=src_id
334
- )
335
-
336
-
337
- @bp.get("/edit/<name>")
338
- def edit_source_view(name):
339
- cfg = current_app.config["QALITA_CONFIG_OBJ"]
340
- cfg.load_source_config()
341
- src = next(
342
- (s for s in cfg.config.get("sources", []) if s.get("name") == name), None
343
- )
344
- return render_template("sources/edit.html", title="Edit source", source=src)
300
+ return jsonify({
301
+ "ok": True,
302
+ "name": name,
303
+ "platform_url": platform_url,
304
+ "source_id": src_id,
305
+ "message": f"Source '{name}' added successfully.",
306
+ })
345
307
 
346
308
 
347
309
  @bp.post("/edit/<name>")
@@ -482,12 +444,10 @@ def edit_source_post(name):
482
444
  if not validate_source_object(
483
445
  cfg, updated, skip_connection=False, exclude_name=name
484
446
  ):
485
- return render_template(
486
- "sources/edit.html",
487
- title="Edit source",
488
- source=request.form,
489
- error="Validation failed. Check fields and connectivity.",
490
- )
447
+ return jsonify({
448
+ "ok": False,
449
+ "error": "Validation failed. Check fields and connectivity.",
450
+ }), 400
491
451
 
492
452
  sources[i].update(updated)
493
453
  break
@@ -504,7 +464,11 @@ def edit_source_post(name):
504
464
  push_single_programmatic(cfg, new_name, approve_public=False)
505
465
  except (SystemExit, Exception):
506
466
  pass
507
- return redirect(url_for("dashboard.dashboard"))
467
+ return jsonify({
468
+ "ok": True,
469
+ "name": new_name,
470
+ "message": f"Source '{new_name}' updated successfully.",
471
+ })
508
472
 
509
473
 
510
474
  @bp.post("/delete/<name>")
@@ -515,7 +479,11 @@ def delete_source_post(name):
515
479
  s for s in cfg.config.get("sources", []) if s.get("name") != name
516
480
  ]
517
481
  cfg.save_source_config()
518
- return redirect(url_for("dashboard.dashboard"))
482
+ return jsonify({
483
+ "ok": True,
484
+ "name": name,
485
+ "message": f"Source '{name}' deleted successfully.",
486
+ })
519
487
 
520
488
 
521
489
  @bp.get("/pick-file")