lorax-arg 0.1__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.
- lorax/buffer.py +43 -0
- lorax/cache/__init__.py +43 -0
- lorax/cache/csv_tree_graph.py +59 -0
- lorax/cache/disk.py +467 -0
- lorax/cache/file_cache.py +142 -0
- lorax/cache/file_context.py +72 -0
- lorax/cache/lru.py +90 -0
- lorax/cache/tree_graph.py +293 -0
- lorax/cli.py +312 -0
- lorax/cloud/__init__.py +0 -0
- lorax/cloud/gcs_utils.py +205 -0
- lorax/constants.py +66 -0
- lorax/context.py +80 -0
- lorax/csv/__init__.py +7 -0
- lorax/csv/config.py +250 -0
- lorax/csv/layout.py +182 -0
- lorax/csv/newick_tree.py +234 -0
- lorax/handlers.py +998 -0
- lorax/lineage.py +456 -0
- lorax/loaders/__init__.py +0 -0
- lorax/loaders/csv_loader.py +10 -0
- lorax/loaders/loader.py +31 -0
- lorax/loaders/tskit_loader.py +119 -0
- lorax/lorax_app.py +75 -0
- lorax/manager.py +58 -0
- lorax/metadata/__init__.py +0 -0
- lorax/metadata/loader.py +426 -0
- lorax/metadata/mutations.py +146 -0
- lorax/modes.py +190 -0
- lorax/pg.py +183 -0
- lorax/redis_utils.py +30 -0
- lorax/routes.py +137 -0
- lorax/session_manager.py +206 -0
- lorax/sockets/__init__.py +55 -0
- lorax/sockets/connection.py +99 -0
- lorax/sockets/debug.py +47 -0
- lorax/sockets/decorators.py +112 -0
- lorax/sockets/file_ops.py +200 -0
- lorax/sockets/lineage.py +307 -0
- lorax/sockets/metadata.py +232 -0
- lorax/sockets/mutations.py +154 -0
- lorax/sockets/node_search.py +535 -0
- lorax/sockets/tree_layout.py +117 -0
- lorax/sockets/utils.py +10 -0
- lorax/tree_graph/__init__.py +12 -0
- lorax/tree_graph/tree_graph.py +689 -0
- lorax/utils.py +124 -0
- lorax_app/__init__.py +4 -0
- lorax_app/app.py +159 -0
- lorax_app/cli.py +114 -0
- lorax_app/static/X.png +0 -0
- lorax_app/static/assets/index-BCEGlUFi.js +2361 -0
- lorax_app/static/assets/index-iKjzUpA9.css +1 -0
- lorax_app/static/assets/localBackendWorker-BaWwjSV_.js +2 -0
- lorax_app/static/assets/renderDataWorker-BKLdiU7J.js +2 -0
- lorax_app/static/gestures/gesture-flick.ogv +0 -0
- lorax_app/static/gestures/gesture-two-finger-scroll.ogv +0 -0
- lorax_app/static/index.html +14 -0
- lorax_app/static/logo.png +0 -0
- lorax_app/static/lorax-logo.png +0 -0
- lorax_app/static/vite.svg +1 -0
- lorax_arg-0.1.dist-info/METADATA +131 -0
- lorax_arg-0.1.dist-info/RECORD +66 -0
- lorax_arg-0.1.dist-info/WHEEL +5 -0
- lorax_arg-0.1.dist-info/entry_points.txt +4 -0
- lorax_arg-0.1.dist-info/top_level.txt +2 -0
lorax/utils.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lorax Utility Functions.
|
|
3
|
+
|
|
4
|
+
JSON handling, file utilities, and other helpers.
|
|
5
|
+
Note: LRU caches have been moved to lorax.cache.lru
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import os
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Re-export LRU classes for backward compatibility during transition
|
|
15
|
+
from lorax.cache.lru import LRUCache, LRUCacheWithMeta
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_json_safe(obj):
|
|
19
|
+
if isinstance(obj, dict):
|
|
20
|
+
return {k: make_json_safe(v) for k, v in obj.items()}
|
|
21
|
+
if isinstance(obj, set):
|
|
22
|
+
return sorted(obj) # or list(obj)
|
|
23
|
+
if isinstance(obj, list):
|
|
24
|
+
return [make_json_safe(v) for v in obj]
|
|
25
|
+
return obj
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_json_dict(data):
|
|
29
|
+
# If already a dict, return as-is
|
|
30
|
+
if isinstance(data, dict):
|
|
31
|
+
return data
|
|
32
|
+
|
|
33
|
+
# If bytes, decode to string
|
|
34
|
+
if isinstance(data, (bytes, bytearray)):
|
|
35
|
+
data = data.decode("utf-8")
|
|
36
|
+
# If string, parse JSON
|
|
37
|
+
if isinstance(data, str):
|
|
38
|
+
return json.loads(data)
|
|
39
|
+
|
|
40
|
+
raise TypeError(f"Unsupported data type: {type(data)}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_json_serializable(obj):
|
|
44
|
+
"""Convert to JSON-safe Python structures and decode nested JSON strings."""
|
|
45
|
+
if isinstance(obj, bytes):
|
|
46
|
+
try:
|
|
47
|
+
text = obj.decode('utf-8')
|
|
48
|
+
return make_json_serializable(json.loads(text))
|
|
49
|
+
except Exception:
|
|
50
|
+
return text
|
|
51
|
+
elif isinstance(obj, str):
|
|
52
|
+
# Try to parse JSON strings like '{"family_id": "ST082"}'
|
|
53
|
+
try:
|
|
54
|
+
parsed = json.loads(obj)
|
|
55
|
+
return make_json_serializable(parsed)
|
|
56
|
+
except Exception:
|
|
57
|
+
return obj
|
|
58
|
+
elif isinstance(obj, dict):
|
|
59
|
+
return {k: make_json_serializable(v) for k, v in obj.items()}
|
|
60
|
+
elif isinstance(obj, list):
|
|
61
|
+
return [make_json_serializable(i) for i in obj]
|
|
62
|
+
elif hasattr(obj, '__dict__'):
|
|
63
|
+
return make_json_serializable(obj.__dict__)
|
|
64
|
+
elif isinstance(obj, np.ndarray):
|
|
65
|
+
return obj.tolist()
|
|
66
|
+
else:
|
|
67
|
+
return obj
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def extract_sample_names(newick_str):
|
|
71
|
+
tokens = re.findall(r'([^(),:]+):', newick_str)
|
|
72
|
+
|
|
73
|
+
samples = []
|
|
74
|
+
for t in tokens:
|
|
75
|
+
# Skip pure numbers (branch lengths)
|
|
76
|
+
if re.fullmatch(r'[0-9.+Ee-]+', t):
|
|
77
|
+
continue
|
|
78
|
+
samples.append(t)
|
|
79
|
+
|
|
80
|
+
# Remove duplicates while preserving order
|
|
81
|
+
return list(dict.fromkeys(samples))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def max_branch_length_from_newick(nwk):
|
|
85
|
+
values = re.findall(r":([0-9.eE+-]+)", nwk)
|
|
86
|
+
if not values:
|
|
87
|
+
return 0.0
|
|
88
|
+
return max(map(float, values))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_project_files(directory, projects, root, exclude_dirs=None):
|
|
92
|
+
"""
|
|
93
|
+
Recursively list files and folders for the given directory.
|
|
94
|
+
If subdirectories are found, they are added as keys and populated similarly.
|
|
95
|
+
"""
|
|
96
|
+
exclude = set(exclude_dirs or [])
|
|
97
|
+
for item in os.listdir(directory):
|
|
98
|
+
item_path = os.path.join(directory, item)
|
|
99
|
+
if os.path.isdir(item_path):
|
|
100
|
+
directory_name = os.path.relpath(item_path, root)
|
|
101
|
+
directory_basename = os.path.basename(item_path)
|
|
102
|
+
if directory_basename in exclude:
|
|
103
|
+
continue
|
|
104
|
+
if directory_basename not in projects:
|
|
105
|
+
projects[str(directory_basename)] = {
|
|
106
|
+
"folder": str(directory_name),
|
|
107
|
+
"files": [],
|
|
108
|
+
"description": "",
|
|
109
|
+
}
|
|
110
|
+
list_project_files(
|
|
111
|
+
item_path,
|
|
112
|
+
projects,
|
|
113
|
+
root=root,
|
|
114
|
+
exclude_dirs=exclude,
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
directory_name = os.path.relpath(directory, root)
|
|
118
|
+
directory_basename = os.path.basename(directory)
|
|
119
|
+
if os.path.isfile(item_path) and (
|
|
120
|
+
item.endswith(".trees") or item.endswith(".trees.tsz") or item.endswith(".csv")
|
|
121
|
+
):
|
|
122
|
+
if item not in projects[str(directory_basename)]["files"]:
|
|
123
|
+
projects[str(directory_basename)]["files"].append(item)
|
|
124
|
+
return projects
|
lorax_app/__init__.py
ADDED
lorax_app/app.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import socketio
|
|
8
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
from fastapi.responses import FileResponse
|
|
11
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
12
|
+
|
|
13
|
+
from lorax.constants import (
|
|
14
|
+
MAX_HTTP_BUFFER_SIZE,
|
|
15
|
+
SOCKET_PING_INTERVAL,
|
|
16
|
+
SOCKET_PING_TIMEOUT,
|
|
17
|
+
)
|
|
18
|
+
from lorax.context import REDIS_CLUSTER_URL, REDIS_CLUSTER
|
|
19
|
+
from lorax.routes import router as backend_router
|
|
20
|
+
from lorax.sockets import register_socket_events
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_static_dir() -> Path:
|
|
24
|
+
"""
|
|
25
|
+
Resolve the directory containing the built website assets.
|
|
26
|
+
|
|
27
|
+
Precedence:
|
|
28
|
+
1) `LORAX_APP_STATIC_DIR` env var (useful for local/dev without copying assets)
|
|
29
|
+
2) Packaged `static/` directory (included in the wheel for pip installs)
|
|
30
|
+
"""
|
|
31
|
+
override = os.getenv("LORAX_APP_STATIC_DIR")
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override).expanduser().resolve()
|
|
34
|
+
|
|
35
|
+
# In a built wheel, `static/` is shipped as package data.
|
|
36
|
+
return Path(__file__).resolve().parent / "static"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _serve_file(path: Path) -> FileResponse:
|
|
40
|
+
if not path.exists() or not path.is_file():
|
|
41
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
42
|
+
return FileResponse(path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_fastapi_app(static_dir: Optional[Path] = None) -> FastAPI:
|
|
46
|
+
static_dir = static_dir or _get_static_dir()
|
|
47
|
+
index_html = static_dir / "index.html"
|
|
48
|
+
if not index_html.exists():
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"Lorax UI assets not found.\n\n"
|
|
51
|
+
"If you are running from source, build the website and point the app to it:\n"
|
|
52
|
+
" npm ci && VITE_API_BASE=/api npm --workspace packages/website run build\n"
|
|
53
|
+
" export LORAX_APP_STATIC_DIR=packages/website/dist\n\n"
|
|
54
|
+
"If you are installing from PyPI, use an official wheel that includes UI assets."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
app = FastAPI(title="Lorax App", version="0.1.0")
|
|
58
|
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|
59
|
+
|
|
60
|
+
allowed_origins = [
|
|
61
|
+
o.strip()
|
|
62
|
+
for o in os.getenv(
|
|
63
|
+
"ALLOWED_ORIGINS",
|
|
64
|
+
"http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001",
|
|
65
|
+
).split(",")
|
|
66
|
+
if o.strip()
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
app.add_middleware(
|
|
70
|
+
CORSMiddleware,
|
|
71
|
+
allow_origins=allowed_origins,
|
|
72
|
+
allow_credentials=True,
|
|
73
|
+
allow_methods=["*"],
|
|
74
|
+
allow_headers=["*"],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Backend API under /api
|
|
78
|
+
app.include_router(backend_router, prefix="/api")
|
|
79
|
+
|
|
80
|
+
# Static files + SPA fallback
|
|
81
|
+
@app.get("/")
|
|
82
|
+
async def spa_root():
|
|
83
|
+
return _serve_file(index_html)
|
|
84
|
+
|
|
85
|
+
@app.get("/{path:path}")
|
|
86
|
+
async def spa_fallback(path: str, request: Request):
|
|
87
|
+
# Let /api/* be handled by the mounted backend router; if we got here,
|
|
88
|
+
# it wasn't a backend match, so treat it as 404.
|
|
89
|
+
if path.startswith("api/") or path == "api":
|
|
90
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
91
|
+
|
|
92
|
+
# Serve built asset directly if it exists (e.g. /assets/*.js, /logo.png).
|
|
93
|
+
candidate = (static_dir / path).resolve()
|
|
94
|
+
try:
|
|
95
|
+
candidate.relative_to(static_dir.resolve())
|
|
96
|
+
except ValueError:
|
|
97
|
+
# Prevent path traversal.
|
|
98
|
+
raise HTTPException(status_code=400, detail="Invalid path")
|
|
99
|
+
|
|
100
|
+
if candidate.exists() and candidate.is_file():
|
|
101
|
+
return _serve_file(candidate)
|
|
102
|
+
|
|
103
|
+
# SPA route (e.g. /<file>?project=Uploads)
|
|
104
|
+
return _serve_file(index_html)
|
|
105
|
+
|
|
106
|
+
return app
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def create_asgi_app() -> socketio.ASGIApp:
|
|
110
|
+
"""
|
|
111
|
+
Create the combined ASGI app.
|
|
112
|
+
|
|
113
|
+
- UI served by FastAPI routes.
|
|
114
|
+
- Backend router mounted at /api.
|
|
115
|
+
- Socket.IO served at /api/socket.io/.
|
|
116
|
+
"""
|
|
117
|
+
fastapi_app = create_fastapi_app()
|
|
118
|
+
|
|
119
|
+
client_manager = None
|
|
120
|
+
if REDIS_CLUSTER_URL and not REDIS_CLUSTER:
|
|
121
|
+
client_manager = socketio.AsyncRedisManager(REDIS_CLUSTER_URL)
|
|
122
|
+
elif REDIS_CLUSTER_URL and REDIS_CLUSTER:
|
|
123
|
+
print("Warning: Socket.IO Redis manager does not support Redis Cluster; running without shared manager.")
|
|
124
|
+
|
|
125
|
+
if client_manager:
|
|
126
|
+
sio = socketio.AsyncServer(
|
|
127
|
+
async_mode="asgi",
|
|
128
|
+
cors_allowed_origins="*",
|
|
129
|
+
client_manager=client_manager,
|
|
130
|
+
logger=False,
|
|
131
|
+
engineio_logger=False,
|
|
132
|
+
ping_timeout=SOCKET_PING_TIMEOUT,
|
|
133
|
+
ping_interval=SOCKET_PING_INTERVAL,
|
|
134
|
+
max_http_buffer_size=MAX_HTTP_BUFFER_SIZE,
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
sio = socketio.AsyncServer(
|
|
138
|
+
async_mode="asgi",
|
|
139
|
+
cors_allowed_origins="*",
|
|
140
|
+
logger=False,
|
|
141
|
+
engineio_logger=False,
|
|
142
|
+
ping_timeout=SOCKET_PING_TIMEOUT,
|
|
143
|
+
ping_interval=SOCKET_PING_INTERVAL,
|
|
144
|
+
max_http_buffer_size=MAX_HTTP_BUFFER_SIZE,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
register_socket_events(sio)
|
|
148
|
+
|
|
149
|
+
# Expose socket endpoint under /api/socket.io
|
|
150
|
+
return socketio.ASGIApp(
|
|
151
|
+
sio,
|
|
152
|
+
other_asgi_app=fastapi_app,
|
|
153
|
+
socketio_path="api/socket.io",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Default importable app for uvicorn: `uvicorn lorax_app.app:asgi_app`
|
|
158
|
+
asgi_app = create_asgi_app()
|
|
159
|
+
|
lorax_app/cli.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
from urllib.request import urlopen
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from lorax.constants import UPLOADS_DIR
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _copy_into_uploads(src: Path) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Copy a user-provided file into the backend's local uploads location.
|
|
19
|
+
|
|
20
|
+
Local-mode uploads layout is:
|
|
21
|
+
<UPLOADS_DIR>/Uploads/<filename>
|
|
22
|
+
"""
|
|
23
|
+
src = src.expanduser().resolve()
|
|
24
|
+
if not src.exists() or not src.is_file():
|
|
25
|
+
raise click.ClickException(f"File not found: {src}")
|
|
26
|
+
|
|
27
|
+
uploads_root = Path(UPLOADS_DIR) / "Uploads"
|
|
28
|
+
uploads_root.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
dest = uploads_root / src.name
|
|
31
|
+
print(f"Copying {src} to {dest}")
|
|
32
|
+
shutil.copy2(src, dest)
|
|
33
|
+
return dest.name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _wait_for_health(base_url: str, timeout_s: float = 20.0, interval_s: float = 0.5) -> bool:
|
|
37
|
+
"""Poll /api/health until the server is ready (or timeout)."""
|
|
38
|
+
deadline = time.monotonic() + timeout_s
|
|
39
|
+
health_url = f"{base_url}/api/health"
|
|
40
|
+
while time.monotonic() < deadline:
|
|
41
|
+
try:
|
|
42
|
+
with urlopen(health_url, timeout=1) as resp:
|
|
43
|
+
if resp.status == 200:
|
|
44
|
+
return True
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
time.sleep(interval_s)
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@click.group(
|
|
52
|
+
context_settings={"help_option_names": ["-h", "--help"], "allow_extra_args": True},
|
|
53
|
+
invoke_without_command=True,
|
|
54
|
+
)
|
|
55
|
+
@click.option("--file", "file_path", type=click.Path(dir_okay=False, path_type=Path))
|
|
56
|
+
@click.option("--host", default="127.0.0.1", show_default=True)
|
|
57
|
+
@click.option("--port", default=3000, type=int, show_default=True)
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def main(
|
|
60
|
+
ctx: click.Context,
|
|
61
|
+
file_path: Path | None,
|
|
62
|
+
host: str,
|
|
63
|
+
port: int,
|
|
64
|
+
):
|
|
65
|
+
"""
|
|
66
|
+
Run Lorax as a single-port app (UI + backend).
|
|
67
|
+
|
|
68
|
+
If FILE is provided, it is copied into Lorax uploads and the browser opens
|
|
69
|
+
directly to the viewer route for that file.
|
|
70
|
+
"""
|
|
71
|
+
if ctx.invoked_subcommand is None:
|
|
72
|
+
file = file_path
|
|
73
|
+
if file is None and ctx.args:
|
|
74
|
+
candidate = Path(ctx.args[0]).expanduser()
|
|
75
|
+
if candidate.exists() and candidate.is_file():
|
|
76
|
+
file = candidate
|
|
77
|
+
else:
|
|
78
|
+
raise click.ClickException(f"File not found: {candidate}")
|
|
79
|
+
_serve(file=file, host=host, port=port)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _serve(file: Path | None, host: str, port: int) -> None:
|
|
83
|
+
import uvicorn
|
|
84
|
+
|
|
85
|
+
filename = None
|
|
86
|
+
if file is not None:
|
|
87
|
+
filename = _copy_into_uploads(file)
|
|
88
|
+
|
|
89
|
+
def open_browser_delayed():
|
|
90
|
+
base = f"http://{host}:{port}" if host != "0.0.0.0" else f"http://127.0.0.1:{port}"
|
|
91
|
+
ready = _wait_for_health(base)
|
|
92
|
+
if not ready:
|
|
93
|
+
click.echo("Warning: /api/health did not respond yet; opening browser anyway.")
|
|
94
|
+
if filename:
|
|
95
|
+
url = f"{base}/{quote(filename)}?project=Uploads"
|
|
96
|
+
else:
|
|
97
|
+
url = f"{base}/"
|
|
98
|
+
click.echo(f"Opening browser: {url}")
|
|
99
|
+
webbrowser.open(url)
|
|
100
|
+
|
|
101
|
+
threading.Thread(target=open_browser_delayed, daemon=True).start()
|
|
102
|
+
|
|
103
|
+
click.echo(f"Starting Lorax app on {host}:{port}")
|
|
104
|
+
uvicorn.run(
|
|
105
|
+
"lorax_app.app:asgi_app",
|
|
106
|
+
host=host,
|
|
107
|
+
port=port,
|
|
108
|
+
reload=False,
|
|
109
|
+
log_level="info",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
lorax_app/static/X.png
ADDED
|
Binary file
|