Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Added `Dataset.update_metadata` method to update metadata of multiple samples at once.
- Added cloud storage support for Pascal VOC semantic segmentation annotations.
- Exposed Pascal VOC segmentation export from the Python interface.
Comment thread
MalteEbner marked this conversation as resolved.
Outdated
- Show Embedding Plot selection as a filter item in the left panel.
- Added thumbnail quality setting in the Settings dialog. Enable "High Quality Thumbnails" to load compressed JPEG thumbnails in grid views, reducing bandwidth for large datasets.
- Added a floating selection panel to grid pages.
- Tags can be created and assigned directly from the sample and annotation detail view.
- Tags can be created and assigned directly from the side panel in the grid view.
- Tags can be deleted from the side panel in the grid view.
- Exposed Pascal VOC segmentation export from the Python interface.
- Added warning if connecting to a database with a different LightlyStudio version.

### Changed

Expand Down
3 changes: 3 additions & 0 deletions lightly_studio/src/lightly_studio/api/db_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
from lightly_studio.models.dataset import (
DatasetTable, # noqa: F401, required for SQLModel to work properly
)
from lightly_studio.models.database_version import (
DatabaseVersionTable, # noqa: F401, required for SQLModel to work properly
)
from lightly_studio.models.embedding_model import (
EmbeddingModelTable, # noqa: F401, required for SQLModel to work properly
)
Expand Down
58 changes: 56 additions & 2 deletions lightly_studio/src/lightly_studio/db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@
from collections.abc import Generator
from contextlib import contextmanager
from enum import Enum
from importlib import metadata
from pathlib import Path
from typing import Annotated

import sqlalchemy_utils
from fastapi import Depends
from sqlalchemy import StaticPool, text
from sqlalchemy import StaticPool, inspect, text
from sqlalchemy.engine import Engine
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel import Session, SQLModel, create_engine, select

import lightly_studio.api.db_tables # noqa: F401, required for SQLModel to work properly
from lightly_studio.dataset.env import LIGHTLY_STUDIO_DATABASE_URL
from lightly_studio.models.database_version import DatabaseVersionTable


class DatabaseBackend(str, Enum):
Expand Down Expand Up @@ -111,7 +113,12 @@ def __init__(
SQLModel.metadata.drop_all(bind=self._engine)
logging.info("Dropped all tables in PostgreSQL database.")

existing_managed_tables = _get_existing_managed_tables(engine=self._engine)
SQLModel.metadata.create_all(self._engine)
_validate_or_initialize_database_version(
engine=self._engine,
existing_managed_tables=existing_managed_tables,
)
Comment thread
MalteEbner marked this conversation as resolved.
Outdated

@contextmanager
def session(self) -> Generator[Session, None, None]:
Expand Down Expand Up @@ -345,3 +352,50 @@ def _session_dependency() -> Generator[Session, None, None]:


SessionDep = Annotated[Session, Depends(_session_dependency)]


def _get_existing_managed_tables(engine: Engine) -> set[str]:
"""Get existing tables managed by SQLModel before create_all."""
inspector = inspect(engine)
existing_tables = set(inspector.get_table_names())
managed_tables = set(SQLModel.metadata.tables)
return existing_tables.intersection(managed_tables)


def _validate_or_initialize_database_version(
engine: Engine,
existing_managed_tables: set[str],
) -> None:
"""Validate database schema version or initialize it for fresh databases.

The database schema version is tied to the full lightly-studio package version.
Only fresh databases get a version row written; existing databases with a
missing or mismatched version are warned about but never auto-updated, so
the warning persists until the user takes action.
"""
expected_version = metadata.version("lightly-studio")
Comment thread
MalteEbner marked this conversation as resolved.
Outdated

with Session(engine) as version_session:
db_versions = version_session.exec(select(DatabaseVersionTable)).all()
if len(db_versions) > 1:
raise RuntimeError(
f"Expected at most one row in 'database_version', got {len(db_versions)}."
)

db_version = db_versions[0] if db_versions else None
if db_version is None:
if existing_managed_tables:
logging.warning(
f"Incompatible database schema version. Expected version "
f"'{expected_version}', but found missing version metadata."
)
return
version_session.add(DatabaseVersionTable(version=expected_version))
version_session.commit()
Comment thread
MalteEbner marked this conversation as resolved.
Outdated
return

if db_version.version != expected_version:
logging.warning(
f"Incompatible database schema version. Expected version "
f"'{expected_version}', got '{db_version.version}'."
)
13 changes: 13 additions & 0 deletions lightly_studio/src/lightly_studio/models/database_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Model for tracking the LightlyStudio database schema version."""

from __future__ import annotations

from sqlmodel import Field, SQLModel


class DatabaseVersionTable(SQLModel, table=True):
"""Stores the schema version expected by the running LightlyStudio package."""

__tablename__ = "database_version"

version: str = Field(primary_key=True)
Comment thread
MalteEbner marked this conversation as resolved.
125 changes: 125 additions & 0 deletions lightly_studio/tests/test_db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@

from __future__ import annotations

from importlib import metadata
from pathlib import Path

import pytest
import sqlmodel
from pytest_mock import MockerFixture
from sqlmodel import select

from lightly_studio import ImageDataset, db_manager
from lightly_studio.core.dataset_query.image_sample_field import ImageSampleField
Expand All @@ -20,12 +22,15 @@
_detect_backend_from_url,
)
from lightly_studio.models.collection import CollectionTable
from lightly_studio.models.database_version import DatabaseVersionTable
from lightly_studio.resolvers import image_resolver
from tests.helpers_resolvers import (
create_collection,
create_image,
)

PACKAGE_VERSION = metadata.version("lightly-studio")


@pytest.fixture
def patch_engine_singleton(mocker: MockerFixture) -> None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to lightly_studio/tests/conftest.py

Expand Down Expand Up @@ -332,3 +337,123 @@ def test_get_backend(

assert db_manager.get_backend() == DatabaseBackend.DUCKDB
db_manager.close()


def _create_database(db_url: str, version: str | None = PACKAGE_VERSION) -> None:
db_manager.connect(db_url=db_url)
with db_manager.session() as session:
db_version = session.exec(select(DatabaseVersionTable)).first()
assert db_version is not None
if version != db_version.version:
session.delete(db_version)
if version is not None:
session.add(DatabaseVersionTable(version=version))
db_manager.close()


def _assert_database_version_after_connect(
db_url: str,
*,
caplog: pytest.LogCaptureFixture,
must_exist: bool,
expected_version: str | None,
expected_warning: str | None = None,
) -> None:
caplog.clear()
with caplog.at_level("WARNING"):
db_manager.connect(db_url=db_url, must_exist=must_exist)
with db_manager.session() as session:
db_version = session.exec(select(DatabaseVersionTable)).first()
if expected_version is None:
assert db_version is None
else:
assert db_version is not None
assert db_version.version == expected_version
db_manager.close()
schema_warnings = [
record.getMessage()
for record in caplog.records
if "database schema version" in record.getMessage().lower()
]
if expected_warning is None:
assert not schema_warnings
else:
assert any(expected_warning in message for message in schema_warnings)


def test_connect__initializes_database_version_for_new_database(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'no_db.db'}"
_assert_database_version_after_connect(
db_url=db_url,
caplog=caplog,
must_exist=False,
expected_version=PACKAGE_VERSION,
)


def test_connect__warns_for_database_without_version_metadata(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'db_without_version.db'}"
_create_database(db_url=db_url, version=None)
_assert_database_version_after_connect(
db_url=db_url,
caplog=caplog,
must_exist=True,
expected_version=None,
expected_warning="missing version metadata",
)


def test_connect__does_not_warn_for_same_database_version(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'db_with_same_version.db'}"
_create_database(db_url=db_url)
_assert_database_version_after_connect(
db_url=db_url,
caplog=caplog,
must_exist=True,
expected_version=PACKAGE_VERSION,
)


def test_connect__warns_for_other_database_version(
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'db_with_other_version.db'}"
_create_database(db_url=db_url, version="0.0.0")
_assert_database_version_after_connect(
db_url=db_url,
caplog=caplog,
must_exist=True,
expected_version="0.0.0",
expected_warning="got '0.0.0'",
)


def test_connect__raises_for_multiple_database_versions(
tmp_path: Path,
patch_engine_singleton: None, # noqa: ARG001
) -> None:
db_url = f"duckdb:///{tmp_path / 'db_with_multiple_versions.db'}"
_create_database(db_url=db_url, version=None)

db_manager.connect(db_url=db_url)
with db_manager.session() as session:
session.add(DatabaseVersionTable(version="0.0.0"))
session.add(DatabaseVersionTable(version="0.0.1"))
db_manager.close()

with pytest.raises(RuntimeError, match=r"Expected at most one row in 'database_version'"):
db_manager.connect(db_url=db_url, must_exist=True)
2 changes: 2 additions & 0 deletions lightly_studio_view/src/lib/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4120,6 +4120,8 @@ export interface operations {
query?: never;
header?: never;
path: {
/** @description The ID of the collection */
collection_id: string;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated

tag_id: string;
};
cookie?: never;
Expand Down
Loading