ethoscopy 2.2.0__tar.gz → 2.2.1__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 (78) hide show
  1. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/.github/workflows/release.yml +0 -12
  2. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/CLAUDE.md +41 -27
  3. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/.env.dummy.example +4 -0
  4. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/.env.github.example +4 -0
  5. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/.env.google.example +4 -0
  6. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/.env.keycloak.example +4 -0
  7. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/Dockerfile +10 -2
  8. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/README_AUTH.md +28 -0
  9. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/config/jupyterhub_config.py +39 -5
  10. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/docker-compose.yml +11 -1
  11. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/PKG-INFO +2 -2
  12. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/pyproject.toml +1 -1
  13. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/load.py +151 -72
  14. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_load_optimizations.py +8 -2
  15. ethoscopy-2.2.1/tests/test_load_wal.py +493 -0
  16. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/.codecov.yml +0 -0
  17. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/.github/workflows/ci.yml +0 -0
  18. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/.gitignore +0 -0
  19. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/.pre-commit-config.yaml +0 -0
  20. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/.env.gitlab.example +0 -0
  21. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/.env.keycloak +0 -0
  22. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/README.md +0 -0
  23. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/config/users.py +0 -0
  24. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/install_r_packages.r +0 -0
  25. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/Docker/jupyterhub_data/jupyterhub_config.py +0 -0
  26. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/LICENSE +0 -0
  27. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/README.md +0 -0
  28. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/TESTING.md +0 -0
  29. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/_config.yml +0 -0
  30. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/pytest.ini +0 -0
  31. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/run_tests.py +0 -0
  32. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/scripts/README.md +0 -0
  33. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/scripts/convert_databases.sh +0 -0
  34. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/scripts/convert_wal_to_delete.py +0 -0
  35. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/scripts/publish_tutorials.py +0 -0
  36. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/setup.py +0 -0
  37. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/__init__.py +0 -0
  38. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/analyse.py +0 -0
  39. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/behavpy.py +0 -0
  40. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/behavpy_HMM_class.py +0 -0
  41. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/behavpy_class.py +0 -0
  42. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/behavpy_core.py +0 -0
  43. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/behavpy_draw.py +0 -0
  44. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/behavpy_periodogram_class.py +0 -0
  45. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/behavpy_plotly.py +0 -0
  46. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/behavpy_seaborn.py +0 -0
  47. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/metadata_db.py +0 -0
  48. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/misc/__init__.py +0 -0
  49. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/misc/circadian_bars.py +0 -0
  50. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/misc/general_functions.py +0 -0
  51. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/misc/get_HMM.py +0 -0
  52. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/misc/get_tutorials.py +0 -0
  53. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/misc/hmm_functions.py +0 -0
  54. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/misc/periodogram_functions.py +0 -0
  55. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/src/ethoscopy/misc/validate_datetime.py +0 -0
  56. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/__init__.py +0 -0
  57. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/conftest.py +0 -0
  58. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/data/README.md +0 -0
  59. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/data/test_ethoscope.db +0 -0
  60. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_analyse.py +0 -0
  61. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_baseline_enhancements.py +0 -0
  62. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_behavpy.py +0 -0
  63. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_behavpy_core_simple.py +0 -0
  64. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_compatibility_classes.py +0 -0
  65. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_general_functions.py +0 -0
  66. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_get_tutorials.py +0 -0
  67. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_load.py +0 -0
  68. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_load_comprehensive.py +0 -0
  69. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tests/test_load_metadata_fixes.py +0 -0
  70. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tutorial_notebook/1_Overview_tutorial.ipynb +0 -0
  71. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tutorial_notebook/2_HMM_tutorial.ipynb +0 -0
  72. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tutorial_notebook/3_Circadian_tutorial.ipynb +0 -0
  73. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tutorial_notebook/4_Navigating_db_tutorial.ipynb +0 -0
  74. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tutorial_notebook/5_Ethoscopy_catch22_tutorial.ipynb +0 -0
  75. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tutorial_notebook/6_Ethoscopy_to_hctsa_tutorial.ipynb +0 -0
  76. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tutorial_notebook/ethoscope_db.csv +0 -0
  77. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tutorial_notebook/jones_et_al_metadata.csv +0 -0
  78. {ethoscopy-2.2.0 → ethoscopy-2.2.1}/tutorial_notebook/notebook_paper.ipynb +0 -0
@@ -3,9 +3,6 @@ name: Release to PyPI
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
- push:
7
- tags:
8
- - 'v*.*.*'
9
6
 
10
7
  jobs:
11
8
  test:
@@ -58,16 +55,7 @@ jobs:
58
55
  run: |
59
56
  twine check dist/*
60
57
 
61
- - name: Publish to Test PyPI
62
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
63
- env:
64
- TWINE_USERNAME: __token__
65
- TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }}
66
- run: |
67
- twine upload --repository testpypi dist/*
68
-
69
58
  - name: Publish to PyPI
70
- if: github.event_name == 'release'
71
59
  env:
72
60
  TWINE_USERNAME: __token__
73
61
  TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
@@ -36,48 +36,62 @@ pip install -e ".[dev]"
36
36
  ```
37
37
 
38
38
  ### Testing
39
- - **No formal test suite found** - project relies on Jupyter notebooks for validation
40
- - Use the tutorial notebooks in `tutorial_notebook/` for testing functionality
41
- - Run notebooks: `jupyter notebook tutorial_notebook/`
39
+ - **Pytest suite in `tests/`**: `python -m pytest tests/ -q` (~230 tests, seconds to run)
40
+ - **Caveat**: `pytest.ini` declares its section as `[tool:pytest]`, which is the
41
+ *setup.cfg* spelling pytest silently ignores the file, so `addopts` (coverage,
42
+ `--strict-markers`) and the `markers` registrations do **not** apply. Renaming the
43
+ section to `[pytest]` would switch on `--cov-fail-under=70` and `--strict-markers`
44
+ for the whole suite; check it passes before doing so.
45
+ - Tutorial notebooks in `tutorial_notebook/` complement the suite for end-to-end checks
42
46
 
43
47
  ### Docker Environment
44
48
  - **Docker available** for JupyterHub deployment
45
49
  - Build: `JUPYTER_HUB_TAG=5.3.0 ETHOSCOPE_LAB_TAG=1.0 docker compose build`
46
50
  - Run: `docker compose up -d` (from Docker/ directory)
47
51
 
48
- ### Troubleshooting: Database "Malformed" Errors
52
+ ### Opening databases: WAL mode and read-only mounts
49
53
 
50
- **Problem**: Intermittent "database disk image is malformed" errors when loading ethoscope data in Docker
54
+ Ethoscopes record in **WAL** (Write-Ahead Logging) mode and the results tree is
55
+ normally mounted read-only (`:ro` in Docker). That combination has broken loading
56
+ repeatedly — as `database disk image is malformed`, and as
57
+ `unable to open database file` on `SELECT * FROM ROI_MAP`.
51
58
 
52
- **Root Cause**: SQLite databases in WAL (Write-Ahead Logging) mode on read-only Docker mounts
59
+ **How `_connect_db()` handles it** (load.py)
53
60
 
54
- **Solution Options**:
55
- 1. **Convert databases to DELETE mode** (recommended for immediate fix)
56
- ```bash
57
- # Using the conversion script (from project root)
58
- python3 scripts/convert_wal_to_delete.py /mnt/ethoscope_data/results --verbose
61
+ ethoscopy never writes to these files, so the connection is *always* read-only —
62
+ this also stops a load from checkpointing or truncating raw data as a side effect.
63
+ It walks an ordered ladder of open modes, `_READ_STRATEGIES`, and **probes each
64
+ one with a real statement** before returning it:
59
65
 
60
- # Or using bash wrapper
61
- ./scripts/convert_databases.sh /mnt/ethoscope_data/results
62
- ```
66
+ | rung | reads | notes |
67
+ |---|---|---|
68
+ | `mode=ro` | every recoverable state, incl. WAL with `-shm`, corrupt `-shm`, hot rollback journal | the only mode that sees data still in an uncheckpointed `-wal` |
69
+ | `immutable=1` | WAL database whose sidecars are absent on a read-only mount | ignores the `-wal`; warns via `_warn_if_wal_ignored()` if it would hide committed rows |
63
70
 
64
- 2. **Improved connection handling** (v2.0.4+)
65
- - The `_connect_db()` function in `load.py` now detects WAL mode automatically
66
- - Uses `mode=ro&nolock=1` URI parameters for WAL databases on read-only mounts
67
- - Includes retry logic in `read_single_roi_optimized()` for resilience
71
+ Two traps worth not rediscovering:
68
72
 
69
- **Key Implementation Details** (load.py):
70
- - Lines 18-70: `_connect_db()` - Smart connection with WAL detection and appropriate parameters
71
- - Lines 943-979: Retry logic in `read_single_roi_optimized()` - Handles transient errors with fresh connections
73
+ - **Never use `mode=ro&nolock=1`.** SQLite rejects it for *every* WAL database.
74
+ - **`sqlite3.connect()` never touches the file**, so a bad URI opens "successfully"
75
+ and only fails on the first query past any `try/except` around the connect
76
+ call. That is why every rung is probed, and it is how the `nolock=1` bug survived
77
+ a fallback that looked like it covered the case.
78
+ - `immutable=1` will also "open" a **missing** path, *creating* an empty database
79
+ and reporting `no such table: ROI_MAP`. `_connect_db()` checks the file exists first.
72
80
 
73
- **Testing After Changes**:
74
- ```python
75
- import ethoscopy as etho
76
- metadata = etho.link_meta_index('metadata.csv', '/mnt/ethoscope_results')
77
- data = etho.load_ethoscope(metadata, reference_hour=9.0, FUN=etho.sleep_annotation)
78
- # Should load all ROIs without "malformed" errors
81
+ `read_single_roi_optimized()` retries a failed read with `_connect_db(path,
82
+ degraded=True)`, which skips to the last rung — a retry with the default ladder
83
+ would just pick the same failing mode again.
84
+
85
+ **Optional**: convert databases to DELETE mode to avoid the question entirely
86
+ ```bash
87
+ python3 scripts/convert_wal_to_delete.py /mnt/ethoscope_data/results --verbose
88
+ ./scripts/convert_databases.sh /mnt/ethoscope_data/results # bash wrapper
79
89
  ```
80
90
 
91
+ **Tests**: `tests/test_load_wal.py` builds real SQLite files in each on-disk state
92
+ (read-only and writable directories) rather than mocking sqlite3 — the failures
93
+ being guarded against come from SQLite itself and only appear when a statement runs.
94
+
81
95
  **See Also**: `Docker/README.md` for detailed database preparation instructions
82
96
 
83
97
  ## Architecture and Code Structure
@@ -16,6 +16,10 @@ ALLOWED_USERS=amadabhushi,ggilestro,mjoyce,lguo,labguest1,labguest2,labguest3,la
16
16
  # Comma-separated list of admin users
17
17
  ADMIN_USERS=ggilestro
18
18
 
19
+ # Superusers see /home as their JupyterLab root and can browse/open/run
20
+ # notebooks across every user. Defaults to ADMIN_USERS if unset.
21
+ SUPERUSERS=ggilestro
22
+
19
23
  # Docker user/group (optional, defaults to 1000:1000)
20
24
  UID=1000
21
25
  GID=1000
@@ -30,6 +30,10 @@ ALLOWED_USERS=username1,username2,username3
30
30
  # Comma-separated list of admin users (GitHub usernames)
31
31
  ADMIN_USERS=your-github-username
32
32
 
33
+ # Superusers see /home as their JupyterLab root and can browse/open/run
34
+ # notebooks across every user. Defaults to ADMIN_USERS if unset.
35
+ SUPERUSERS=your-github-username
36
+
33
37
  # Docker user/group (optional, defaults to 1000:1000)
34
38
  UID=1000
35
39
  GID=1000
@@ -32,6 +32,10 @@ ALLOWED_USERS=user1,user2,user3
32
32
  # Comma-separated list of admin users
33
33
  ADMIN_USERS=your-email-username
34
34
 
35
+ # Superusers see /home as their JupyterLab root and can browse/open/run
36
+ # notebooks across every user. Defaults to ADMIN_USERS if unset.
37
+ SUPERUSERS=your-email-username
38
+
35
39
  # Docker user/group (optional, defaults to 1000:1000)
36
40
  UID=1000
37
41
  GID=1000
@@ -44,6 +44,10 @@ ALLOWED_USERS=amadabhushi,ggilestro,mjoyce,lguo,labguest1,labguest2,labguest3,la
44
44
  # Comma-separated list of admin users
45
45
  ADMIN_USERS=ggilestro
46
46
 
47
+ # Superusers see /home as their JupyterLab root and can browse/open/run
48
+ # notebooks across every user. Defaults to ADMIN_USERS if unset.
49
+ SUPERUSERS=ggilestro
50
+
47
51
  # Docker user/group (optional, defaults to 1000:1000)
48
52
  UID=1000
49
53
  GID=1000
@@ -66,7 +66,7 @@ RUN ln -s /bin/tar /bin/gtar && \
66
66
  # JUPYTER INSTALLATION AND CONFIGURATION
67
67
  # ============================================================================
68
68
 
69
- # Install JupyterLab (version 3.6.4)
69
+ # Install JupyterLab (latest 4.x; pip resolves the newest compatible release)
70
70
  RUN pip3 install jupyterlab --ignore-installed
71
71
 
72
72
  # To upgrade to version 4.0.2 (commented out)
@@ -75,6 +75,14 @@ RUN pip3 install jupyterlab --ignore-installed
75
75
  # Install Jupyter extensions (compatible with 3.x only)
76
76
  RUN pip3 install jupyterlab-spreadsheet-editor
77
77
 
78
+ # AI assistant: Copilot-style inline completions + chat panel.
79
+ # Reason: lightweight (declares only jupyter-server as its Jupyter dep), so it
80
+ # does NOT pull in jupyter-collaboration / server-documents like jupyter-ai does
81
+ # — that RTC stack is what caused "File ID could not be retrieved" on file open.
82
+ # Pinned for reproducible builds. Supports OpenRouter via its OpenAI-compatible
83
+ # provider (base URL https://openrouter.ai/api/v1).
84
+ RUN pip3 install --no-cache-dir "notebook-intelligence==5.1.0"
85
+
78
86
  # ============================================================================
79
87
  # PYTHON PACKAGES AND ETHOSCOPE SETUP
80
88
  # ============================================================================
@@ -98,7 +106,7 @@ RUN pip3 install \
98
106
  pyarrow \
99
107
  tqdm \
100
108
  jupyterlab-git \
101
- ethoscopy==2.1.0
109
+ ethoscopy==2.2.0
102
110
  # pycatch22
103
111
 
104
112
  # Pre-populate tutorial datasets inside the installed package so non-root
@@ -15,6 +15,7 @@ This JupyterHub deployment supports multiple authentication methods, configurabl
15
15
  - [Adding New Users (OAuth)](#adding-new-users-oauth)
16
16
  - [Adding New Users (Dummy Auth)](#adding-new-users-dummy-auth)
17
17
  - [Making Users Admins](#making-users-admins)
18
+ - [Granting Cross-User File Access (Superusers)](#granting-cross-user-file-access-superusers)
18
19
  - [Switching Authentication Methods](#switching-authentication-methods)
19
20
  - [Dockerfile Optimization](#dockerfile-optimization)
20
21
  - [Troubleshooting](#troubleshooting)
@@ -388,6 +389,33 @@ docker compose restart
388
389
  - Navigate to: `https://jupyter.lab.gilest.ro/hub/admin`
389
390
  - You should see the admin panel with all users listed
390
391
 
392
+ ### Granting Cross-User File Access (Superusers)
393
+
394
+ By default JupyterLab roots each user's file browser at their own home (`/home/<username>`). A **superuser** instead gets `/home` as their root, so they can browse, open, and run notebooks across every user's directory directly from their own JupyterLab session.
395
+
396
+ This works because all single-user servers in this deployment run under the same container UID (see `ConfigUserSpawner` in `config/jupyterhub_config.py`), so filesystem-level access to all homes already exists — `SUPERUSERS` simply surfaces it in the UI.
397
+
398
+ **Configuration**:
399
+
400
+ ```bash
401
+ # .env
402
+ SUPERUSERS=ggilestro,jdoe
403
+ ```
404
+
405
+ If `SUPERUSERS` is unset, it defaults to `ADMIN_USERS`.
406
+
407
+ **Apply changes**:
408
+ ```bash
409
+ docker compose restart
410
+ ```
411
+
412
+ **Verify**:
413
+ - Log in as a superuser
414
+ - The JupyterLab file browser should list every user's home folder
415
+ - Opening another user's `.ipynb` runs it in place; edits and kernel output write back to that user's home
416
+
417
+ **Security note**: Regular users are not isolated from each other at the filesystem level in this setup — anyone with a JupyterLab terminal can already `cd` into another user's home. `SUPERUSERS` only changes what is shown by default in the file browser. True per-user isolation would require a different spawner (e.g. `DockerSpawner` or `KubeSpawner` with distinct UIDs).
418
+
391
419
  ---
392
420
 
393
421
  ## Dockerfile Optimization
@@ -120,6 +120,17 @@ else:
120
120
  # Use imported list from users.py
121
121
  c.Authenticator.admin_users = ADMIN_USERS
122
122
 
123
+ # Superusers: users whose JupyterLab file browser is rooted at /home instead
124
+ # of their own home, so they can browse/open/run notebooks across every user.
125
+ # Reason: all single-user servers run as the same container UID (see
126
+ # ConfigUserSpawner below), so filesystem access already exists — this just
127
+ # exposes it in the UI. Defaults to admins; override with SUPERUSERS env var.
128
+ superusers_env = os.getenv('SUPERUSERS')
129
+ if superusers_env is not None:
130
+ SUPERUSERS = {u.strip() for u in superusers_env.split(',') if u.strip()}
131
+ else:
132
+ SUPERUSERS = set(c.Authenticator.admin_users)
133
+
123
134
  # ============================================================================
124
135
  # CUSTOM SPAWNER (Preserved - critical for current setup)
125
136
  # ============================================================================
@@ -129,25 +140,48 @@ class ConfigUserSpawner(LocalProcessSpawner):
129
140
  def make_preexec_fn(self, name):
130
141
  """Don't try to switch users - run everything as current user"""
131
142
  return None
132
-
143
+
133
144
  def user_env(self, env):
134
145
  """Set user environment without system user lookup"""
135
146
  env = env.copy()
136
147
  home_dir = f'/home/{self.user.name}'
137
-
148
+
138
149
  # Ensure home directory exists with proper permissions
139
150
  os.makedirs(home_dir, mode=0o755, exist_ok=True)
140
-
151
+
141
152
  # Set environment variables
142
153
  env['USER'] = self.user.name
143
154
  env['HOME'] = home_dir
144
155
  env['SHELL'] = '/bin/bash'
145
156
  env['LOGNAME'] = self.user.name
146
-
157
+
158
+ # Notebook Intelligence (AI assistant): forward central LLM config from
159
+ # the container environment so students never enter API keys themselves
160
+ # (avoids keys leaking into dotfiles, cf. the pxin OpenAI-key incident).
161
+ # LiteLLM reads OPENROUTER_API_KEY directly and routes the
162
+ # `openrouter/...` model id; values come from .env via docker-compose.
163
+ for _key in (
164
+ 'OPENROUTER_API_KEY',
165
+ 'NBI_CHAT_MODEL_PROVIDER', 'NBI_CHAT_MODEL_ID',
166
+ 'NBI_INLINE_COMPLETION_MODEL_PROVIDER', 'NBI_INLINE_COMPLETION_MODEL_ID',
167
+ ):
168
+ _val = os.getenv(_key)
169
+ if _val:
170
+ env[_key] = _val
171
+
147
172
  return env
148
173
 
149
174
  c.JupyterHub.spawner_class = ConfigUserSpawner
150
- c.Spawner.notebook_dir = '/home/{username}'
175
+
176
+
177
+ def _set_notebook_dir(spawner):
178
+ if spawner.user.name in SUPERUSERS:
179
+ spawner.notebook_dir = '/home'
180
+ else:
181
+ spawner.notebook_dir = f'/home/{spawner.user.name}'
182
+
183
+
184
+ c.Spawner.pre_spawn_hook = _set_notebook_dir
151
185
 
152
186
  # Timeouts
153
187
  c.Spawner.http_timeout = 60
@@ -6,13 +6,14 @@ services:
6
6
  dockerfile: Dockerfile
7
7
  args:
8
8
  JUPYTER_HUB_TAG: ${JUPYTER_HUB_TAG:-5.4.0}
9
- image: ggilestro/ethoscope-lab:${ETHOSCOPE_LAB_TAG:-1.2}
9
+ image: ggilestro/ethoscope-lab:${ETHOSCOPE_LAB_TAG:-1.3}
10
10
  container_name: ethoscope-lab
11
11
  ports:
12
12
  - 8082:8000
13
13
  volumes:
14
14
  - /mnt/ethoscope_data/results:/mnt/ethoscope_results:ro
15
15
  - /mnt/ethoscope_data/ethoscope_metadata:/opt/ethoscope_metadata
16
+ - /mnt/data/projects:/mnt/data/projects:ro
16
17
  - /mnt/homes:/home
17
18
  - /mnt/cache:/home/cache
18
19
  - ./jupyterhub_data:/srv/jupyterhub/
@@ -34,6 +35,7 @@ services:
34
35
  - DUMMY_PASSWORD=${DUMMY_PASSWORD:-ethoscope}
35
36
  - ALLOWED_USERS=${ALLOWED_USERS}
36
37
  - ADMIN_USERS=${ADMIN_USERS}
38
+ - SUPERUSERS=${SUPERUSERS}
37
39
  # OAuth settings (only needed when using OAuth authenticators)
38
40
  - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}
39
41
  - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}
@@ -47,6 +49,14 @@ services:
47
49
  - GITHUB_ORG=${GITHUB_ORG}
48
50
  - GOOGLE_HOSTED_DOMAIN=${GOOGLE_HOSTED_DOMAIN}
49
51
  - GITLAB_URL=${GITLAB_URL}
52
+ # Notebook Intelligence (AI assistant) — central OpenRouter config.
53
+ # Put OPENROUTER_API_KEY in .env (gitignored). Models are overridable;
54
+ # verify/adjust slugs at https://openrouter.ai/models
55
+ - OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
56
+ - NBI_CHAT_MODEL_PROVIDER=${NBI_CHAT_MODEL_PROVIDER:-litellm-compatible}
57
+ - NBI_CHAT_MODEL_ID=${NBI_CHAT_MODEL_ID:-openrouter/anthropic/claude-sonnet-4.5}
58
+ - NBI_INLINE_COMPLETION_MODEL_PROVIDER=${NBI_INLINE_COMPLETION_MODEL_PROVIDER:-litellm-compatible}
59
+ - NBI_INLINE_COMPLETION_MODEL_ID=${NBI_INLINE_COMPLETION_MODEL_ID:-openrouter/openai/gpt-4o-mini}
50
60
 
51
61
  user: "${UID:-1000}:${GID:-1000}" # Adjust to your own user. Make sure this matches the permissions of your /mnt/{homes,cache} folder
52
62
  restart: always
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ethoscopy
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Summary: "A python based toolkit to download and anlyse data from the Ethoscope hardware system."
5
5
  Author-email: Lblackhurst29 <lblackhurst29@gmail.com>
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ethoscopy"
3
- version = "2.2.0"
3
+ version = "2.2.1"
4
4
  description = "\"A python based toolkit to download and anlyse data from the Ethoscope hardware system.\""
5
5
  authors = [{name = "Lblackhurst29",email = "lblackhurst29@gmail.com"}]
6
6
  readme = "README.md"
@@ -3,6 +3,7 @@ import ftplib
3
3
  import os
4
4
  import sqlite3
5
5
  import time
6
+ import warnings
6
7
 
7
8
  # Reason: newer ethoscope firmwares serialize the "selected_options" METADATA
8
9
  # field with an ``OrderedDict([...])`` wrapper. ``get_meta`` below round-trips
@@ -22,57 +23,119 @@ from ethoscopy.misc.validate_datetime import validate_datetime
22
23
  pd.options.mode.chained_assignment = None
23
24
 
24
25
 
25
- def _connect_db(path):
26
+ # Ordered ladder of read-only SQLite open modes, most faithful first.
27
+ #
28
+ # Reason: whether an ethoscope database can be opened is not predictable from
29
+ # its journal mode alone. Journal mode, the presence and state of the -wal/-shm
30
+ # sidecars and the writability of the containing directory interact, and the
31
+ # combinations are not rare in practice -- ethoscopes write in WAL mode and the
32
+ # results tree is usually mounted read-only. Empirically (sqlite 3.45 and 3.53,
33
+ # read-only and writable directories):
34
+ #
35
+ # * mode=ro reads every recoverable state, including a WAL database whose
36
+ # -shm exists, a stale or corrupt -shm, and a hot rollback journal, and it
37
+ # is the only mode that sees data still sitting in an uncheckpointed -wal.
38
+ # * immutable=1 is the sole survivor of one real case: a WAL-mode database
39
+ # whose sidecars are absent on a read-only mount (SQLite would have to
40
+ # create the -shm to read it). It ignores the -wal entirely, so it is the
41
+ # fallback, never the first choice -- see _warn_if_wal_ignored.
42
+ #
43
+ # Note nolock=1 is deliberately absent: SQLite rejects it for *every* WAL
44
+ # database, and because sqlite3.connect() never touches the file the rejection
45
+ # only surfaces on the first query. That is why each rung below is probed.
46
+ _READ_STRATEGIES = ("mode=ro", "immutable=1")
47
+
48
+ # Errors that mean "this open mode cannot read this file" rather than "this
49
+ # query is wrong" -- worth dropping to the next rung of the ladder for.
50
+ _UNREADABLE_ERRORS = ("unable to open database file", "readonly database", "malformed")
51
+
52
+
53
+ def _warn_if_wal_ignored(path_str):
26
54
  """
27
- Connect to a SQLite database with smart read-only filesystem detection.
55
+ Warn when falling back to immutable=1 would hide committed data.
28
56
 
29
- When the database directory is read-only (e.g., mounted with :ro in Docker),
30
- SQLite cannot create journal/WAL files and will fail to open the database.
31
- This function detects read-only filesystems and uses appropriate connection
32
- parameters to handle WAL-mode databases safely.
57
+ immutable=1 reads the main database file only. If an uncheckpointed -wal
58
+ sidecar still holds committed transactions, those rows silently disappear
59
+ from the loaded data -- the worst possible failure for an analysis library,
60
+ so make it loud.
61
+
62
+ Args:
63
+ path_str (str): Path to the SQLite database file
64
+ """
65
+ try:
66
+ wal_size = os.path.getsize(f"{path_str}-wal")
67
+ except OSError:
68
+ return
69
+
70
+ if wal_size > 0:
71
+ warnings.warn(
72
+ f"{path_str} could only be opened in SQLite's immutable mode, which "
73
+ f"ignores its {wal_size} byte -wal sidecar: data committed to the WAL "
74
+ "but not yet checkpointed will be missing. Checkpoint the database "
75
+ "(scripts/convert_wal_to_delete.py) or make its directory writable.",
76
+ RuntimeWarning,
77
+ stacklevel=3,
78
+ )
79
+
80
+
81
+ def _connect_db(path, degraded=False):
82
+ """
83
+ Open an ethoscope database read-only, tolerating WAL state and read-only mounts.
84
+
85
+ ethoscopy never writes to these files, so the connection is always read-only:
86
+ that also stops a load from checkpointing or truncating the raw data as a side
87
+ effect. Each candidate open mode is probed with a real statement before being
88
+ handed back, because sqlite3.connect() does not touch the file and an
89
+ unusable mode would otherwise only fail deep inside the first data query.
33
90
 
34
91
  Args:
35
92
  path (str): Path to the SQLite database file
93
+ degraded (bool, optional): Skip the faithful open modes and go straight to
94
+ the last-resort one. Used to escalate after a connection that opened
95
+ cleanly fails part-way through a read. Default is False.
36
96
 
37
97
  Returns:
38
- sqlite3.Connection: Database connection object
98
+ sqlite3.Connection: A connection that has answered at least one statement
39
99
 
40
- Note:
41
- For WAL-mode databases on read-only mounts, this function uses mode=ro
42
- with nolock=1 to prevent "database disk image is malformed" errors.
43
- Any uncommitted WAL data will not be visible, which is acceptable for
44
- read-only mounts where the data cannot change anyway.
100
+ Raises:
101
+ FileNotFoundError: If path does not exist
102
+ sqlite3.OperationalError: If no open mode can read the file
45
103
  """
46
104
  path_str = str(path)
47
- dir_path = os.path.dirname(path_str)
48
105
 
49
- # Check if we can write to the directory
50
- if not os.access(dir_path, os.W_OK):
51
- # Read-only filesystem - check if database is in WAL mode
106
+ # Reason: immutable=1 happily "opens" a path that is not there, creating an
107
+ # empty database file and leaving the caller with a baffling
108
+ # "no such table: ROI_MAP". Fail on the real problem instead.
109
+ if not os.path.isfile(path_str):
110
+ raise FileNotFoundError(
111
+ errno.ENOENT, "No such ethoscope database file", path_str
112
+ )
113
+
114
+ strategies = _READ_STRATEGIES[-1:] if degraded else _READ_STRATEGIES
115
+
116
+ last_error = None
117
+ for strategy in strategies:
118
+ conn = None
52
119
  try:
53
- # Try to detect WAL mode by opening in read-only mode first
54
- temp_conn = sqlite3.connect(f"file:{path_str}?mode=ro", uri=True)
55
- cursor = temp_conn.cursor()
56
- cursor.execute("PRAGMA journal_mode;")
57
- journal_mode = cursor.fetchone()[0].lower()
58
- temp_conn.close()
59
-
60
- if journal_mode == "wal":
61
- # WAL mode on read-only mount: use mode=ro with nolock
62
- # This prevents "database disk image is malformed" errors
63
- # by avoiding operations that require WAL/SHM files
64
- return sqlite3.connect(
65
- f"file:{path_str}?mode=ro&nolock=1", uri=True, timeout=10.0
66
- )
67
- else:
68
- # Non-WAL mode: use immutable mode for better performance
69
- return sqlite3.connect(f"file:{path_str}?immutable=1", uri=True)
70
- except Exception:
71
- # If detection fails, fall back to immutable mode
72
- return sqlite3.connect(f"file:{path_str}?immutable=1", uri=True)
73
- else:
74
- # Normal read-write access
75
- return sqlite3.connect(path_str)
120
+ conn = sqlite3.connect(
121
+ f"file:{path_str}?{strategy}", uri=True, timeout=10.0
122
+ )
123
+ # Probe: forces SQLite to actually reach the file and its sidecars
124
+ conn.execute("PRAGMA journal_mode;").fetchone()
125
+ except sqlite3.Error as e:
126
+ last_error = e
127
+ if conn is not None:
128
+ conn.close()
129
+ continue
130
+
131
+ if strategy == "immutable=1":
132
+ _warn_if_wal_ignored(path_str)
133
+ return conn
134
+
135
+ raise sqlite3.OperationalError(
136
+ f"Could not open {path_str} for reading with any of {list(strategies)}. "
137
+ f"Last error: {last_error}"
138
+ )
76
139
 
77
140
 
78
141
  def download_from_remote_dir(meta, remote_dir, local_dir, progress=True):
@@ -512,6 +575,7 @@ def load_ethoscope(
512
575
  try:
513
576
  for db_path, group in grouped_metadata:
514
577
  conn = None
578
+ pbar_at_db_start = pbar.n
515
579
 
516
580
  try:
517
581
  # Open connection once per database file
@@ -603,6 +667,19 @@ def load_ethoscope(
603
667
  finally:
604
668
  pbar.update(1)
605
669
 
670
+ except Exception as e:
671
+ # Reason: opening the database or reading its shared tables sits
672
+ # outside the per-ROI handler below, so without this one
673
+ # unreadable file aborts the whole load and discards every ROI
674
+ # already read from the other databases. Report it and move on.
675
+ if verbose is True:
676
+ tqdm.write(
677
+ "Skipping {} - none of its {} ROIs could be loaded: {}".format(
678
+ db_path, len(group), e
679
+ )
680
+ )
681
+ pbar.update(len(group) - (pbar.n - pbar_at_db_start))
682
+
606
683
  finally:
607
684
  # Close connection when done with this database
608
685
  if conn:
@@ -973,46 +1050,48 @@ def read_single_roi_optimized(
973
1050
  file["region_id"], min_time, max_time_condtion
974
1051
  )
975
1052
 
976
- # Execute query with retry logic for WAL-related errors
1053
+ # Execute query, escalating to a degraded open mode if the shared
1054
+ # connection turns out to be unable to read this database after all.
1055
+ # Reason: the connection was probed at open time, but the -wal/-shm
1056
+ # sidecars can change underneath a long read on a live mount, so the
1057
+ # failure can still land here. Retrying with _connect_db() alone would
1058
+ # just pick the same open mode again -- degraded=True is what makes the
1059
+ # retry a different attempt rather than a repeat of the failed one.
977
1060
  try:
978
1061
  data = pd.read_sql_query(sql_query, conn)
979
1062
  except sqlite3.DatabaseError as e:
980
- # Handle "database disk image is malformed" errors
981
- # This can occur with WAL-mode databases on read-only mounts
982
- if "malformed" in str(e).lower() or "disk image" in str(e).lower():
1063
+ if not any(marker in str(e).lower() for marker in _UNREADABLE_ERRORS):
1064
+ # A genuine query error - retrying will not help
1065
+ raise
1066
+
1067
+ tqdm.write(
1068
+ f"Warning: Database error for ROI {file['region_id']} ({e}), "
1069
+ "retrying with a fresh read-only connection..."
1070
+ )
1071
+
1072
+ db_path = file.get("path")
1073
+ if not db_path:
983
1074
  tqdm.write(
984
- f"Warning: Database error for ROI {file['region_id']}, attempting retry with fresh connection..."
1075
+ "Error: Cannot retry - database path not found in file metadata"
985
1076
  )
1077
+ raise
986
1078
 
987
- # Get database path from file metadata
988
- db_path = file.get("path")
989
- if not db_path:
990
- tqdm.write(
991
- "Error: Cannot retry - database path not found in file metadata"
992
- )
993
- raise
994
-
995
- # Create a fresh connection just for the retry
996
- retry_conn = None
997
- try:
998
- retry_conn = _connect_db(db_path)
999
- data = pd.read_sql_query(sql_query, retry_conn)
1000
- tqdm.write(f"Success: ROI {file['region_id']} loaded on retry")
1001
- except Exception as retry_error:
1002
- tqdm.write(
1003
- f"Error: Retry failed for ROI {file['region_id']}: {retry_error}"
1004
- )
1005
- raise
1006
- finally:
1007
- # Clean up retry connection
1008
- if retry_conn:
1009
- try:
1010
- retry_conn.close()
1011
- except Exception:
1012
- pass
1013
- else:
1014
- # Re-raise other database errors
1079
+ retry_conn = None
1080
+ try:
1081
+ retry_conn = _connect_db(db_path, degraded=True)
1082
+ data = pd.read_sql_query(sql_query, retry_conn)
1083
+ tqdm.write(f"Success: ROI {file['region_id']} loaded on retry")
1084
+ except Exception as retry_error:
1085
+ tqdm.write(
1086
+ f"Error: Retry failed for ROI {file['region_id']}: {retry_error}"
1087
+ )
1015
1088
  raise
1089
+ finally:
1090
+ if retry_conn:
1091
+ try:
1092
+ retry_conn.close()
1093
+ except Exception:
1094
+ pass
1016
1095
 
1017
1096
  if "id" in data.columns:
1018
1097
  # Check if 'id' is a primary key (reuse cursor)