copick-web 0.1.5__tar.gz

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 (55) hide show
  1. copick_web-0.1.5/.dockerignore +11 -0
  2. copick_web-0.1.5/.gitignore +211 -0
  3. copick_web-0.1.5/CHANGELOG.md +53 -0
  4. copick_web-0.1.5/Dockerfile +6 -0
  5. copick_web-0.1.5/Dockerfile.dev +11 -0
  6. copick_web-0.1.5/PKG-INFO +19 -0
  7. copick_web-0.1.5/pyproject.toml +62 -0
  8. copick_web-0.1.5/src/copick_web/__init__.py +3 -0
  9. copick_web-0.1.5/src/copick_web/app/__init__.py +1 -0
  10. copick_web-0.1.5/src/copick_web/app/config.py +52 -0
  11. copick_web-0.1.5/src/copick_web/app/dependencies.py +38 -0
  12. copick_web-0.1.5/src/copick_web/app/main.py +180 -0
  13. copick_web-0.1.5/src/copick_web/app/models.py +370 -0
  14. copick_web-0.1.5/src/copick_web/app/routes/__init__.py +1 -0
  15. copick_web-0.1.5/src/copick_web/app/routes/config.py +162 -0
  16. copick_web-0.1.5/src/copick_web/app/routes/filaments.py +169 -0
  17. copick_web-0.1.5/src/copick_web/app/routes/projects.py +15 -0
  18. copick_web-0.1.5/src/copick_web/app/routes/runs.py +392 -0
  19. copick_web-0.1.5/src/copick_web/app/routes/zarr_proxy.py +190 -0
  20. copick_web-0.1.5/src/copick_web/app/services/__init__.py +1 -0
  21. copick_web-0.1.5/src/copick_web/app/services/cache.py +103 -0
  22. copick_web-0.1.5/src/copick_web/app/services/compat.py +147 -0
  23. copick_web-0.1.5/src/copick_web/app/services/config_file.py +133 -0
  24. copick_web-0.1.5/src/copick_web/app/services/copick_service.py +757 -0
  25. copick_web-0.1.5/src/copick_web/app/services/filament_picks.py +113 -0
  26. copick_web-0.1.5/src/copick_web/app/services/object_types.py +245 -0
  27. copick_web-0.1.5/src/copick_web/app/services/project_registry.py +465 -0
  28. copick_web-0.1.5/src/copick_web/app/services/registry_client.py +93 -0
  29. copick_web-0.1.5/src/copick_web/app/services/run_info.py +148 -0
  30. copick_web-0.1.5/src/copick_web/app/services/thumbnails.py +132 -0
  31. copick_web-0.1.5/src/copick_web/app/services/zarr_urls.py +47 -0
  32. copick_web-0.1.5/src/copick_web/app/validation.py +46 -0
  33. copick_web-0.1.5/src/copick_web/cli.py +100 -0
  34. copick_web-0.1.5/src/copick_web/static/assets/blosc-D1xNXZJs-CRrdIaHK.js +17 -0
  35. copick_web-0.1.5/src/copick_web/static/assets/chunk-INHXZS53-DiyuLb3Z-DAOLzMPX.js +1 -0
  36. copick_web-0.1.5/src/copick_web/static/assets/index-CFHbVtPG.css +2 -0
  37. copick_web-0.1.5/src/copick_web/static/assets/index-DGooRI34.js +4333 -0
  38. copick_web-0.1.5/src/copick_web/static/assets/lz4-1Ws5oVWR-TBpHcg4u.js +15 -0
  39. copick_web-0.1.5/src/copick_web/static/assets/zstd-C4EcZnjq-_uEBMK3Z.js +15 -0
  40. copick_web-0.1.5/src/copick_web/static/copick-logo.png +0 -0
  41. copick_web-0.1.5/src/copick_web/static/index.html +33 -0
  42. copick_web-0.1.5/tests/__init__.py +0 -0
  43. copick_web-0.1.5/tests/conftest.py +66 -0
  44. copick_web-0.1.5/tests/fixtures/__init__.py +0 -0
  45. copick_web-0.1.5/tests/fixtures/make_demo_project.py +133 -0
  46. copick_web-0.1.5/tests/test_api.py +276 -0
  47. copick_web-0.1.5/tests/test_base_path.py +61 -0
  48. copick_web-0.1.5/tests/test_cache.py +59 -0
  49. copick_web-0.1.5/tests/test_compat.py +48 -0
  50. copick_web-0.1.5/tests/test_filament_edit.py +106 -0
  51. copick_web-0.1.5/tests/test_object_types.py +154 -0
  52. copick_web-0.1.5/tests/test_registry.py +188 -0
  53. copick_web-0.1.5/tests/test_reload.py +63 -0
  54. copick_web-0.1.5/tests/test_thumbnails.py +63 -0
  55. copick_web-0.1.5/uv.lock +3507 -0
@@ -0,0 +1,11 @@
1
+ .git
2
+ __pycache__
3
+ *.pyc
4
+ *.egg-info
5
+ .eggs
6
+ dist
7
+ build
8
+ .venv
9
+ .mypy_cache
10
+ .pytest_cache
11
+ .ruff_cache
@@ -0,0 +1,211 @@
1
+ # =============================================================================
2
+ # Python
3
+ # =============================================================================
4
+
5
+ # Byte-compiled / optimized / DLL files
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+
10
+ # C extensions
11
+ *.so
12
+
13
+ # Distribution / packaging
14
+ .Python
15
+ build/
16
+ develop-eggs/
17
+ dist/
18
+ downloads/
19
+ eggs/
20
+ .eggs/
21
+ lib/
22
+ lib64/
23
+ parts/
24
+ sdist/
25
+ var/
26
+ wheels/
27
+ share/python-wheels/
28
+ *.egg-info/
29
+ .installed.cfg
30
+ *.egg
31
+ MANIFEST
32
+
33
+ # PyInstaller
34
+ *.manifest
35
+ *.spec
36
+
37
+ # Installer logs
38
+ pip-log.txt
39
+ pip-delete-this-directory.txt
40
+
41
+ # Unit test / coverage reports
42
+ htmlcov/
43
+ .tox/
44
+ .nox/
45
+ .coverage
46
+ .coverage.*
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+ *.cover
51
+ *.py,cover
52
+ .hypothesis/
53
+ .pytest_cache/
54
+ cover/
55
+
56
+ # Translations
57
+ *.mo
58
+ *.pot
59
+
60
+ # Sphinx documentation
61
+ docs/_build/
62
+
63
+ # PyBuilder
64
+ .pybuilder/
65
+ target/
66
+
67
+ # Jupyter Notebook
68
+ .ipynb_checkpoints
69
+
70
+ # IPython
71
+ profile_default/
72
+ ipython_config.py
73
+
74
+ # Environments
75
+ .env*
76
+ .venv
77
+ env/
78
+ venv/
79
+ ENV/
80
+ env.bak/
81
+ venv.bak/
82
+
83
+ # mypy
84
+ .mypy_cache/
85
+ .dmypy.json
86
+ dmypy.json
87
+
88
+ # Pyre type checker
89
+ .pyre/
90
+
91
+ # pytype static type analyzer
92
+ .pytype/
93
+
94
+ # Cython debug symbols
95
+ cython_debug/
96
+
97
+ # =============================================================================
98
+ # Node.js / JavaScript / TypeScript
99
+ # =============================================================================
100
+
101
+ # Dependencies
102
+ node_modules/
103
+ .pnp
104
+ .pnp.js
105
+
106
+ # Build outputs
107
+ dist/
108
+ *.tsbuildinfo
109
+
110
+ # Testing
111
+ coverage/
112
+
113
+ # Logs
114
+ npm-debug.log*
115
+ yarn-debug.log*
116
+ yarn-error.log*
117
+ lerna-debug.log*
118
+ .pnpm-debug.log*
119
+
120
+ # Runtime data
121
+ pids
122
+ *.pid
123
+ *.seed
124
+ *.pid.lock
125
+
126
+ # Optional npm cache directory
127
+ .npm
128
+
129
+ # Optional eslint cache
130
+ .eslintcache
131
+
132
+ # Optional stylelint cache
133
+ .stylelintcache
134
+
135
+ # Microbundle cache
136
+ .rpt2_cache/
137
+ .rts2_cache_cjs/
138
+ .rts2_cache_es/
139
+ .rts2_cache_umd/
140
+
141
+ # Optional REPL history
142
+ .node_repl_history
143
+
144
+ # Yarn v2
145
+ .yarn/cache
146
+ .yarn/unplugged
147
+ .yarn/build-state.yml
148
+ .yarn/install-state.gz
149
+ .pnp.*
150
+
151
+ # Vite
152
+ *.local
153
+
154
+ # =============================================================================
155
+ # IDE / Editor
156
+ # =============================================================================
157
+
158
+ # JetBrains IDEs
159
+ .idea/
160
+
161
+ # VS Code (ignore all except shared config)
162
+ .vscode/*
163
+ !.vscode/tasks.json
164
+ !.vscode/extensions.json
165
+ *.code-workspace
166
+
167
+ # WIP Scratch folder
168
+ .scratch/
169
+
170
+ # Claude
171
+ .claude/
172
+ CLAUDE.md
173
+
174
+ # Vim
175
+ *.swp
176
+ *.swo
177
+ *~
178
+
179
+ # =============================================================================
180
+ # OS
181
+ # =============================================================================
182
+
183
+ # macOS
184
+ .DS_Store
185
+ .AppleDouble
186
+ .LSOverride
187
+ ._*
188
+
189
+ # Windows
190
+ Thumbs.db
191
+ ehthumbs.db
192
+ Desktop.ini
193
+
194
+ # Linux
195
+ *~
196
+
197
+ # =============================================================================
198
+ # Project-specific
199
+ # =============================================================================
200
+
201
+ # Built client files in server (generated by npm run build:server)
202
+ # Keep .gitkeep but ignore actual build output
203
+ server/src/copick_web/static/*
204
+ !server/src/copick_web/static/.gitkeep
205
+
206
+ # Local config files
207
+ *.local.json
208
+ copick_config.json
209
+
210
+ # Test data
211
+ .test/
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ ## [0.1.5](https://github.com/copick/copick-web/compare/server-v0.1.4...server-v0.1.5) (2026-10-26)
4
+
5
+
6
+ ### Features
7
+
8
+ * publish copick-web to PyPI with the web UI bundled ([#39](https://github.com/copick/copick-web/issues/39)) ([6323e7c](https://github.com/copick/copick-web/commit/6323e7c7873385f76d3302e51aa78e7f265111e6))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * require Python 3.11 for the server, so Dependabot can update uv.lock ([#69](https://github.com/copick/copick-web/issues/69)) ([a150289](https://github.com/copick/copick-web/commit/a150289cc03b4c5259cce438cba828919f9dbda0))
14
+
15
+
16
+ ### Continuous Integration
17
+
18
+ * set up Dependabot, with a server uv.lock ([#43](https://github.com/copick/copick-web/issues/43)) ([0f0dc7d](https://github.com/copick/copick-web/commit/0f0dc7d68b6c9500c0f3c93217c600957ce5839f))
19
+
20
+ ## [0.1.4](https://github.com/copick/copick-web/compare/server-v0.1.3...server-v0.1.4) (2026-10-14)
21
+
22
+
23
+ ### Features
24
+
25
+ * multi-tenant with embrella integration ([#10](https://github.com/copick/copick-web/issues/10)) ([5cbd935](https://github.com/copick/copick-web/commit/5cbd935dbd6002fa98936989e0542088c8042c41))
26
+
27
+ ## [0.1.3](https://github.com/copick/copick-web/compare/server-v0.1.2...server-v0.1.3) (2026-10-08)
28
+
29
+
30
+ ### Features
31
+
32
+ * multi-panel viewer, filaments, instance and panoptic segmentations ([#20](https://github.com/copick/copick-web/issues/20)) ([ff48363](https://github.com/copick/copick-web/commit/ff48363349f6b523801bd5c19866ab90de189873))
33
+ * reload the project from the sidebar ([#22](https://github.com/copick/copick-web/issues/22)) ([05d043d](https://github.com/copick/copick-web/commit/05d043d08db674e807895311249ec78b102380c5))
34
+ * serve under any URL prefix, set at runtime ([#26](https://github.com/copick/copick-web/issues/26)) ([bb77a09](https://github.com/copick/copick-web/commit/bb77a09d631562e2e1a98c6ce3bddfbec30d7ef9))
35
+
36
+ ## [0.1.2](https://github.com/copick/copick-web/compare/server-v0.1.1...server-v0.1.2) (2026-07-24)
37
+
38
+
39
+ ### Features
40
+
41
+ * containerize app ([#4](https://github.com/copick/copick-web/issues/4)) ([1b57cb4](https://github.com/copick/copick-web/commit/1b57cb40e51ce6553f47ea896802725e31e850bc))
42
+
43
+ ## [0.1.1](https://github.com/copick/copick-web/compare/server-v0.1.0...server-v0.1.1) (2026-02-25)
44
+
45
+
46
+ ### Features
47
+
48
+ * Particle picking! ([26113f8](https://github.com/copick/copick-web/commit/26113f8257592c57df5f01444e4b6064c2f0ae13))
49
+
50
+
51
+ ### Bug Fixes
52
+
53
+ * add missing files for particle picking. ([821f7aa](https://github.com/copick/copick-web/commit/821f7aa6f43cfea4aa2e7db111d5ff5098b76ff9))
@@ -0,0 +1,6 @@
1
+ FROM python:3.11-slim
2
+ RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && rm -rf /var/lib/apt/lists/*
3
+ WORKDIR /app
4
+ COPY . .
5
+ RUN pip install --no-cache-dir .
6
+ CMD ["uvicorn", "copick_web.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,11 @@
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev && rm -rf /var/lib/apt/lists/*
4
+
5
+ WORKDIR /app
6
+
7
+ COPY pyproject.toml .
8
+ RUN mkdir -p src/copick_web && touch src/copick_web/__init__.py && \
9
+ pip install --no-cache-dir -e ".[dev]"
10
+
11
+ CMD ["sh", "-c", "pip install -e '.[dev]' && uvicorn copick_web.app.main:app --reload --host 0.0.0.0 --port 8000"]
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.5
2
+ Name: copick-web
3
+ Version: 0.1.5
4
+ Summary: Web visualization for copick datasets
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.1.8
7
+ Requires-Dist: copick>=0.8.0
8
+ Requires-Dist: fastapi>=0.109.0
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: pydantic-settings>=2.0.0
11
+ Requires-Dist: pydantic>=2.5.0
12
+ Requires-Dist: python-dotenv>=1.0.0
13
+ Requires-Dist: sshfs>=2024.0.0
14
+ Requires-Dist: uvicorn>=0.25.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: black>=25.1.0; extra == 'dev'
17
+ Requires-Dist: pre-commit>=4.0.0; extra == 'dev'
18
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
19
+ Requires-Dist: ruff>=0.12.0; extra == 'dev'
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "copick-web"
7
+ version = "0.1.5"
8
+ description = "Web visualization for copick datasets"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "fastapi>=0.109.0",
12
+ "uvicorn>=0.25.0",
13
+ "pydantic>=2.5.0",
14
+ "pydantic-settings>=2.0.0",
15
+ "copick>=0.8.0",
16
+ "python-dotenv>=1.0.0",
17
+ "click>=8.1.8",
18
+ "httpx>=0.27.0",
19
+ "sshfs>=2024.0.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "black>=25.1.0",
25
+ "ruff>=0.12.0",
26
+ "pytest>=8.0.0",
27
+ "pre-commit>=4.0.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ copick-web = "copick_web.cli:main"
32
+
33
+ [tool.hatch.build]
34
+ # The client build is gitignored; ship it anyway when present (`npm run build:server` in client/).
35
+ artifacts = ["src/copick_web/static"]
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/copick_web"]
39
+
40
+ [tool.black]
41
+ line-length = 120
42
+ target-version = ['py311']
43
+
44
+ [tool.ruff]
45
+ line-length = 120
46
+ target-version = "py311"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "W", "F", "I", "B", "C4"]
50
+ ignore = ["E501"]
51
+
52
+ [tool.ruff.lint.flake8-bugbear]
53
+ # FastAPI's dependency-injection idiom is a call in the argument default.
54
+ # B008 flags every route signature otherwise.
55
+ extend-immutable-calls = [
56
+ "fastapi.Depends",
57
+ "fastapi.Query",
58
+ "fastapi.Path",
59
+ "fastapi.Body",
60
+ "fastapi.Header",
61
+ "fastapi.Form",
62
+ ]
@@ -0,0 +1,3 @@
1
+ """Copick Web - Web visualization for copick datasets."""
2
+
3
+ __version__ = "0.1.5"
@@ -0,0 +1 @@
1
+ # copick-web server app
@@ -0,0 +1,52 @@
1
+ """Server configuration using pydantic-settings."""
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ """Application settings loaded from environment variables."""
8
+
9
+ copick_config_paths: list[str] = []
10
+ registry_url: str | None = None
11
+ registry_refresh_seconds: int = 60
12
+ service_cache_size: int = 8
13
+ # Hard ceiling on a single zarr-chunk read. If exceeded, the request
14
+ # returns 504 and the underlying CopickService is evicted so the next
15
+ # request rebuilds with a fresh SSH connection.
16
+ zarr_read_timeout_seconds: float = 60.0
17
+ # Max concurrent zarr chunk reads per project. Bounds peak memory
18
+ # (≈ value × max_chunk_size) and avoids saturating a single asyncssh
19
+ # SFTP channel — past ~16 in flight, parallel reads stop helping.
20
+ zarr_concurrency_per_project: int = 8
21
+ slurm_user: str | None = None
22
+ slurm_keyfile: str | None = None
23
+ slurm_ssh_port: int = 22
24
+ # cluster_id -> SSH hostname/IP. Defaults mirror the umbrella seed
25
+ # (umbrella/stores/migrations/0010_seed_clusters.py). Override via env:
26
+ # CLUSTER_HOSTS='{"bruno":"…","czii":"…"}'
27
+ cluster_hosts: dict[str, str] = {
28
+ "bruno": "192.168.98.229",
29
+ "czii": "10.50.120.90",
30
+ }
31
+ cors_origins: list[str] = [
32
+ "http://localhost:5173",
33
+ "http://127.0.0.1:5173",
34
+ "http://localhost:8000",
35
+ "http://127.0.0.1:8000",
36
+ ]
37
+ host: str = "0.0.0.0"
38
+ port: int = 8000
39
+ base_path: str = ""
40
+ # In-memory caches of derived data (env: THUMBNAIL_CACHE_MB, MEASUREMENT_CACHE_MB, CACHE_MAX_AGE_SECONDS,
41
+ # CACHE_SWEEP_SECONDS). Entries older than the max age are dropped by a sweep every CACHE_SWEEP_SECONDS.
42
+ thumbnail_cache_mb: int = 64
43
+ measurement_cache_mb: int = 512
44
+ cache_max_age_seconds: int = 3600
45
+ cache_sweep_seconds: int = 300
46
+
47
+ class Config:
48
+ env_file = ".env"
49
+ env_file_encoding = "utf-8"
50
+
51
+
52
+ settings = Settings()
@@ -0,0 +1,38 @@
1
+ """FastAPI dependency injection for project-scoped routes."""
2
+
3
+ from fastapi import HTTPException, Path
4
+
5
+ from .models import ProjectSummaryResponse
6
+ from .services.copick_service import CopickService
7
+ from .services.project_registry import ProjectRegistry, get_project_registry
8
+ from .services.registry_client import RegistryError
9
+
10
+
11
+ def get_registry() -> ProjectRegistry:
12
+ """Dependency: the application's ``ProjectRegistry``."""
13
+ return get_project_registry()
14
+
15
+
16
+ def get_copick_service(project_id: str = Path(...)) -> CopickService:
17
+ """Dependency: resolve the ``CopickService`` for the path's ``project_id``."""
18
+ registry = get_project_registry()
19
+ try:
20
+ return registry.get_service(project_id)
21
+ except KeyError:
22
+ raise HTTPException(status_code=404, detail=f"Project '{project_id}' not found") from None
23
+ except RegistryError as e:
24
+ raise HTTPException(status_code=502, detail=str(e)) from e
25
+
26
+
27
+ def get_project_meta(project_id: str = Path(...)) -> ProjectSummaryResponse:
28
+ """Dependency: resolve the ``ProjectSummaryResponse`` for the path's ``project_id``.
29
+
30
+ Used by routes that need to branch on ``source`` (local vs. registry) — e.g.
31
+ zarr URL construction, where registry projects skip the server-side proxy
32
+ and point the browser at ``data_url`` directly.
33
+ """
34
+ registry = get_project_registry()
35
+ try:
36
+ return registry.get_metadata(project_id)
37
+ except KeyError:
38
+ raise HTTPException(status_code=404, detail=f"Project '{project_id}' not found") from None
@@ -0,0 +1,180 @@
1
+ """FastAPI application entry point."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+
8
+ from fastapi import FastAPI, HTTPException, Request
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import FileResponse, RedirectResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from .config import settings
14
+ from .routes import config, filaments, projects, runs, zarr_proxy
15
+ from .services.project_registry import ProjectRegistry, set_project_registry
16
+ from .services.registry_client import RegistryClient
17
+
18
+ logging.basicConfig(level=logging.DEBUG)
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ async def _refresh_loop(registry: ProjectRegistry, interval: int):
23
+ """Background loop that refreshes the registry list at a fixed cadence."""
24
+ while True:
25
+ try:
26
+ await asyncio.sleep(interval)
27
+ await registry.refresh_registry()
28
+ except asyncio.CancelledError:
29
+ raise
30
+ except Exception as e:
31
+ logger.exception("Registry refresh loop error: %s", e)
32
+
33
+
34
+ @asynccontextmanager
35
+ async def lifespan(app: FastAPI):
36
+ """Application lifespan handler."""
37
+ # Build registry
38
+ if not settings.copick_config_paths and not settings.registry_url:
39
+ raise RuntimeError(
40
+ "No project source configured. Provide local config paths via the CLI / "
41
+ "COPICK_CONFIG_PATHS, or set REGISTRY_URL."
42
+ )
43
+
44
+ client = RegistryClient(settings.registry_url) if settings.registry_url else None
45
+ registry = ProjectRegistry(registry_client=client, service_cache_size=settings.service_cache_size)
46
+
47
+ for path in settings.copick_config_paths:
48
+ try:
49
+ pid = registry.register_local(path)
50
+ logger.info(
51
+ "Registered local project '%s' (%s) from %s",
52
+ pid,
53
+ registry.get_metadata(pid).name or "unnamed",
54
+ path,
55
+ )
56
+ except FileNotFoundError as e:
57
+ logger.error("Local config not found, skipping: %s (%s)", path, e)
58
+
59
+ if client is not None:
60
+ try:
61
+ await registry.refresh_registry()
62
+ except Exception as e:
63
+ logger.warning("Initial registry refresh failed; continuing without registry projects: %s", e)
64
+
65
+ set_project_registry(registry)
66
+
67
+ tasks = [asyncio.create_task(_sweep_caches(registry, settings.cache_sweep_seconds))]
68
+ if client is not None:
69
+ tasks.append(asyncio.create_task(_refresh_loop(registry, settings.registry_refresh_seconds)))
70
+
71
+ logger.info("copick-web ready: %d project(s) listed.", len(registry.list_projects()))
72
+
73
+ try:
74
+ yield
75
+ finally:
76
+ for task in tasks:
77
+ task.cancel()
78
+ try:
79
+ await task
80
+ except asyncio.CancelledError:
81
+ pass
82
+ logger.info("Shutting down copick-web server")
83
+
84
+
85
+ async def _sweep_caches(registry: ProjectRegistry, every: float) -> None:
86
+ """Drop expired cache entries of every open project regularly, so memory is released even when nothing is
87
+ requested."""
88
+ while True:
89
+ await asyncio.sleep(max(every, 1))
90
+ try:
91
+ dropped = registry.sweep_caches()
92
+ if dropped:
93
+ logger.info(f"Dropped {dropped} expired cache entries")
94
+ except Exception as e: # never let the sweeper die
95
+ logger.warning(f"Cache sweep failed: {e}")
96
+
97
+
98
+ app = FastAPI(
99
+ title="Copick Web Server",
100
+ description="API server for copick web visualization",
101
+ version="0.1.0",
102
+ lifespan=lifespan,
103
+ # A prefix the proxy forwards (e.g. /node/<host>/<port>); requests with or without it are routed alike.
104
+ root_path=settings.base_path.rstrip("/"),
105
+ )
106
+
107
+
108
+ @app.middleware("http")
109
+ async def redirect_root_relatively(request: Request, call_next):
110
+ """Add the trailing slash to the app root under a forwarded prefix (``/node/<host>/<port>``) with a relative
111
+ redirect: Starlette's own is absolute and names the Host it was sent, which may be the backend behind the proxy."""
112
+ root = request.scope.get("root_path", "")
113
+ if root and request.url.path == root:
114
+ query = f"?{request.url.query}" if request.url.query else ""
115
+ return RedirectResponse(f"{root.rsplit('/', 1)[-1]}/{query}", status_code=307)
116
+ return await call_next(request)
117
+
118
+
119
+ # Configure CORS
120
+ app.add_middleware(
121
+ CORSMiddleware,
122
+ allow_origins=settings.cors_origins,
123
+ allow_credentials=True,
124
+ allow_methods=["*"],
125
+ allow_headers=["*"],
126
+ )
127
+
128
+ app.include_router(projects.router)
129
+ app.include_router(config.router)
130
+ app.include_router(runs.router)
131
+ app.include_router(filaments.router)
132
+ app.include_router(zarr_proxy.router)
133
+
134
+
135
+ @app.get("/health")
136
+ async def health_check():
137
+ """Liveness check. Async so a saturated threadpool can't make the server look dead."""
138
+ return {"status": "healthy"}
139
+
140
+
141
+ # Serve the built SPA. Order matters: SPA catch-all must come BEFORE the
142
+ # StaticFiles mount at "/" so it can intercept deep-link refreshes
143
+ # (e.g. /projects/abc) and return index.html instead of 404.
144
+ static_dir = Path(__file__).parent.parent / "static"
145
+
146
+
147
+ def _has_static_files() -> bool:
148
+ if not static_dir.exists():
149
+ return False
150
+ return any(f.name != ".gitkeep" for f in static_dir.iterdir())
151
+
152
+
153
+ if _has_static_files():
154
+ index_file = static_dir / "index.html"
155
+
156
+ @app.get("/projects/{full_path:path}", include_in_schema=False)
157
+ def spa_projects(full_path: str, request: Request):
158
+ """Serve the SPA index.html for client-side routes under /projects."""
159
+ if not index_file.exists():
160
+ raise HTTPException(status_code=404, detail="SPA index.html missing")
161
+ return FileResponse(index_file)
162
+
163
+ logger.info(f"Serving static files from: {static_dir}")
164
+ app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
165
+ elif static_dir.exists():
166
+ logger.warning(
167
+ f"Static directory exists but is empty: {static_dir}. "
168
+ "Run 'npm run build:server' in the client directory to build the client."
169
+ )
170
+
171
+
172
+ if __name__ == "__main__":
173
+ import uvicorn
174
+
175
+ uvicorn.run(
176
+ "copick_web.app.main:app",
177
+ host=settings.host,
178
+ port=settings.port,
179
+ reload=True,
180
+ )