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.
Files changed (66) hide show
  1. lorax/buffer.py +43 -0
  2. lorax/cache/__init__.py +43 -0
  3. lorax/cache/csv_tree_graph.py +59 -0
  4. lorax/cache/disk.py +467 -0
  5. lorax/cache/file_cache.py +142 -0
  6. lorax/cache/file_context.py +72 -0
  7. lorax/cache/lru.py +90 -0
  8. lorax/cache/tree_graph.py +293 -0
  9. lorax/cli.py +312 -0
  10. lorax/cloud/__init__.py +0 -0
  11. lorax/cloud/gcs_utils.py +205 -0
  12. lorax/constants.py +66 -0
  13. lorax/context.py +80 -0
  14. lorax/csv/__init__.py +7 -0
  15. lorax/csv/config.py +250 -0
  16. lorax/csv/layout.py +182 -0
  17. lorax/csv/newick_tree.py +234 -0
  18. lorax/handlers.py +998 -0
  19. lorax/lineage.py +456 -0
  20. lorax/loaders/__init__.py +0 -0
  21. lorax/loaders/csv_loader.py +10 -0
  22. lorax/loaders/loader.py +31 -0
  23. lorax/loaders/tskit_loader.py +119 -0
  24. lorax/lorax_app.py +75 -0
  25. lorax/manager.py +58 -0
  26. lorax/metadata/__init__.py +0 -0
  27. lorax/metadata/loader.py +426 -0
  28. lorax/metadata/mutations.py +146 -0
  29. lorax/modes.py +190 -0
  30. lorax/pg.py +183 -0
  31. lorax/redis_utils.py +30 -0
  32. lorax/routes.py +137 -0
  33. lorax/session_manager.py +206 -0
  34. lorax/sockets/__init__.py +55 -0
  35. lorax/sockets/connection.py +99 -0
  36. lorax/sockets/debug.py +47 -0
  37. lorax/sockets/decorators.py +112 -0
  38. lorax/sockets/file_ops.py +200 -0
  39. lorax/sockets/lineage.py +307 -0
  40. lorax/sockets/metadata.py +232 -0
  41. lorax/sockets/mutations.py +154 -0
  42. lorax/sockets/node_search.py +535 -0
  43. lorax/sockets/tree_layout.py +117 -0
  44. lorax/sockets/utils.py +10 -0
  45. lorax/tree_graph/__init__.py +12 -0
  46. lorax/tree_graph/tree_graph.py +689 -0
  47. lorax/utils.py +124 -0
  48. lorax_app/__init__.py +4 -0
  49. lorax_app/app.py +159 -0
  50. lorax_app/cli.py +114 -0
  51. lorax_app/static/X.png +0 -0
  52. lorax_app/static/assets/index-BCEGlUFi.js +2361 -0
  53. lorax_app/static/assets/index-iKjzUpA9.css +1 -0
  54. lorax_app/static/assets/localBackendWorker-BaWwjSV_.js +2 -0
  55. lorax_app/static/assets/renderDataWorker-BKLdiU7J.js +2 -0
  56. lorax_app/static/gestures/gesture-flick.ogv +0 -0
  57. lorax_app/static/gestures/gesture-two-finger-scroll.ogv +0 -0
  58. lorax_app/static/index.html +14 -0
  59. lorax_app/static/logo.png +0 -0
  60. lorax_app/static/lorax-logo.png +0 -0
  61. lorax_app/static/vite.svg +1 -0
  62. lorax_arg-0.1.dist-info/METADATA +131 -0
  63. lorax_arg-0.1.dist-info/RECORD +66 -0
  64. lorax_arg-0.1.dist-info/WHEEL +5 -0
  65. lorax_arg-0.1.dist-info/entry_points.txt +4 -0
  66. 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
@@ -0,0 +1,4 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
4
+
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