diff --git a/lamindb/__init__.py b/lamindb/__init__.py index 70669d9ee..17ea35b0c 100644 --- a/lamindb/__init__.py +++ b/lamindb/__init__.py @@ -182,6 +182,7 @@ DB, ) from .models.save import save +from .models.sqlrecord import UNSET, Unset from . import core from . import integrations from . import curators @@ -230,6 +231,8 @@ "examples", "errors", "setup", + # sentinels + "Unset", # low-level functionality "base", "core", diff --git a/lamindb/models/feature.py b/lamindb/models/feature.py index e5c86d1e1..869e878b1 100644 --- a/lamindb/models/feature.py +++ b/lamindb/models/feature.py @@ -51,6 +51,7 @@ Space, SQLRecord, UNSET, + Unset, _get_record_kwargs, pop_space_branch_kwargs, ) @@ -671,7 +672,7 @@ def process_init_feature_param(args, kwargs): name: str = kwargs.pop("name", None) dtype: SimpleDtype | SimpleDtypeStr | str | None = kwargs.pop("dtype", None) is_type: bool = kwargs.pop("is_type", False) - type_: Feature | str | None = kwargs.pop("type", UNSET) + type_: Feature | str | None | Unset = kwargs.pop("type", UNSET) description: str | None = kwargs.pop("description", None) space_branch_kwargs = pop_space_branch_kwargs(kwargs) _skip_validation = kwargs.pop("_skip_validation", False) @@ -1221,7 +1222,7 @@ def __init__( | Registry | list[Registry] | FieldAttr, - type: Feature | None = None, + type: Feature | None | Unset = UNSET, is_type: bool = False, unit: str | None = None, description: str | None = None, diff --git a/lamindb/models/project.py b/lamindb/models/project.py index 56cc24226..050949ebb 100644 --- a/lamindb/models/project.py +++ b/lamindb/models/project.py @@ -28,7 +28,19 @@ from .record import Record from .run import Run, TracksRun, TracksUpdates, User from .schema import Schema -from .sqlrecord import BaseSQLRecord, HasType, IsLink, SQLRecord, ValidateFields +from lamindb.errors import FieldValidationError + +from .sqlrecord import ( + UNSET, + Unset, + BaseSQLRecord, + HasType, + IsLink, + SQLRecord, + ValidateFields, + _get_record_kwargs, + pop_space_branch_kwargs, +) from .transform import Transform from .ulabel import ULabel @@ -217,7 +229,7 @@ class Meta(SQLRecord.Meta, TracksRun.Meta, TracksUpdates.Meta): def __init__( self, name: str, - type: Reference | None = None, + type: Reference | None | Unset = UNSET, is_type: bool = False, abbr: str | None = None, url: str | None = None, @@ -237,7 +249,44 @@ def __init__( ): ... def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + if len(args) == len(self._meta.concrete_fields): + super().__init__(*args, **kwargs) + return None + if len(args) > 0: + raise ValueError("Only keyword args allowed") + name: str = kwargs.pop("name", None) + type: Reference | None | Unset = kwargs.pop("type", UNSET) + is_type: bool = kwargs.pop("is_type", False) + abbr: str | None = kwargs.pop("abbr", None) + url: str | None = kwargs.pop("url", None) + pubmed_id: int | None = kwargs.pop("pubmed_id", None) + doi: str | None = kwargs.pop("doi", None) + description: str | None = kwargs.pop("description", None) + text: str | None = kwargs.pop("text", None) + date: DateType | None = kwargs.pop("date", None) + space_branch_kwargs = pop_space_branch_kwargs(kwargs) + _skip_validation = kwargs.pop("_skip_validation", False) + _aux = kwargs.pop("_aux", None) + if len(kwargs) > 0: + valid_keywords = ", ".join([val[0] for val in _get_record_kwargs(Reference)]) + raise FieldValidationError( + f"Only {valid_keywords} are valid keyword arguments" + ) + super().__init__( + name=name, + type=type, + is_type=is_type, + abbr=abbr, + url=url, + pubmed_id=pubmed_id, + doi=doi, + description=description, + text=text, + date=date, + _skip_validation=_skip_validation, + _aux=_aux, + **space_branch_kwargs, + ) def query_references(self) -> QuerySet: """Query references of sub types. @@ -449,7 +498,7 @@ class Meta(SQLRecord.Meta, TracksRun.Meta, TracksUpdates.Meta): def __init__( self, name: str, - type: Project | None = None, + type: Project | None | Unset = UNSET, is_type: bool = False, abbr: str | None = None, url: str | None = None, @@ -466,7 +515,38 @@ def __init__( ): ... def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + if len(args) == len(self._meta.concrete_fields): + super().__init__(*args, **kwargs) + return None + if len(args) > 0: + raise ValueError("Only keyword args allowed") + name: str = kwargs.pop("name", None) + type: Project | None | Unset = kwargs.pop("type", UNSET) + is_type: bool = kwargs.pop("is_type", False) + abbr: str | None = kwargs.pop("abbr", None) + url: str | None = kwargs.pop("url", None) + start_date: DateType | None = kwargs.pop("start_date", None) + end_date: DateType | None = kwargs.pop("end_date", None) + space_branch_kwargs = pop_space_branch_kwargs(kwargs) + _skip_validation = kwargs.pop("_skip_validation", False) + _aux = kwargs.pop("_aux", None) + if len(kwargs) > 0: + valid_keywords = ", ".join([val[0] for val in _get_record_kwargs(Project)]) + raise FieldValidationError( + f"Only {valid_keywords} are valid keyword arguments" + ) + super().__init__( + name=name, + type=type, + is_type=is_type, + abbr=abbr, + url=url, + start_date=start_date, + end_date=end_date, + _skip_validation=_skip_validation, + _aux=_aux, + **space_branch_kwargs, + ) def query_projects(self) -> QuerySet: """Query projects of sub types. diff --git a/lamindb/models/record.py b/lamindb/models/record.py index 13d50722a..645456376 100644 --- a/lamindb/models/record.py +++ b/lamindb/models/record.py @@ -39,6 +39,7 @@ Space, SQLRecord, UNSET, + Unset, _get_record_kwargs, pop_space_branch_kwargs, ) @@ -873,7 +874,7 @@ class Meta(SQLRecord.Meta, TracksRun.Meta, TracksUpdates.Meta): def __init__( self, name: str | None = None, - type: Record | None = None, + type: Record | None | Unset = UNSET, is_type: bool = False, features: dict[str | Feature, Any] | None = None, description: str | None = None, @@ -901,7 +902,7 @@ def __init__( if len(args) > 0: raise ValueError("Only one non-keyword arg allowed") name: str = kwargs.pop("name", None) - type: str | None = kwargs.pop("type", UNSET) + type: Record | None | Unset = kwargs.pop("type", UNSET) is_type: bool = kwargs.pop("is_type", False) features: dict[str | Feature, Any] | None = kwargs.pop("features", None) description: str | None = kwargs.pop("description", None) diff --git a/lamindb/models/schema.py b/lamindb/models/schema.py index 42225807c..4168e24bb 100644 --- a/lamindb/models/schema.py +++ b/lamindb/models/schema.py @@ -49,6 +49,7 @@ Space, SQLRecord, UNSET, + Unset, _get_record_kwargs, init_self_from_db, pop_space_branch_kwargs, @@ -631,7 +632,7 @@ def __init__( name: str | None = None, description: str | None = None, itype: str | Registry | FieldAttr | None = None, - type: Schema | None = None, + type: Schema | None | Unset = UNSET, is_type: bool = False, index: Feature | None = None, flexible: bool | None = None, @@ -673,7 +674,7 @@ def __init__( description: str | None = kwargs.pop("description", None) itype: str | SQLRecord | DeferredAttribute | None = kwargs.pop("itype", None) flexible: bool | None = kwargs.pop("flexible", None) - type: Feature | None = kwargs.pop("type", UNSET) + type: Feature | None | Unset = kwargs.pop("type", UNSET) is_type: bool = kwargs.pop("is_type", False) otype: str | None = kwargs.pop("otype", None) suffix: str | None = kwargs.pop("suffix", None) @@ -738,6 +739,7 @@ def __init__( coerce=coerce_dtype, n_features=n_features, ) + # pop before update_attributes/super so it never reaches Django fields or getattr if not features and not slots and not is_type and not itype: raise InvalidArgument( "Please pass features or slots or itype or set is_type=True" @@ -858,7 +860,7 @@ def _validate_kwargs_calculate_hash( validated_kwargs = { "name": name, "description": description, - "type": None if type is UNSET else type, + "type": type, "is_type": is_type, "_dtype_str": dtype, "otype": otype, diff --git a/lamindb/models/sqlrecord.py b/lamindb/models/sqlrecord.py index e7be87647..520cd5936 100644 --- a/lamindb/models/sqlrecord.py +++ b/lamindb/models/sqlrecord.py @@ -108,9 +108,24 @@ T = TypeVar("T", bound="SQLRecord") -# Sentinel to distinguish "user didn't pass type=" from "user explicitly passed type=None". -# Uses object() so identity checks (is) never call __eq__ on model instances. -UNSET = object() +class Unset: + """Sentinel type to distinguish 'not passed' from explicit ``None``. + + Use ``is`` checks to compare against the singleton :data:`UNSET`. + """ + + _instance: Unset | None = None + + def __new__(cls) -> Unset: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "UNSET" + + +UNSET: Unset = Unset() IPYTHON = getattr(builtins, "__IPYTHON__", False) UNIQUE_FIELD_NAMES = { "root", @@ -451,7 +466,7 @@ def init_self_from_db( def update_attributes(record: SQLRecord, attributes: dict[str, str]): for key, value in attributes.items(): - if getattr(record, key) != value and value is not None: + if value is not None and value is not UNSET and getattr(record, key) != value: if key not in {"uid", "_dtype_str", "otype", "hash"}: logger.warning(f"updated {key} from {getattr(record, key)} to {value}") setattr(record, key, value) @@ -624,10 +639,18 @@ def suggest_records_with_similar_names( # the below needs to be .first() because there might be multiple records with the same # name field in case the record is versioned (e.g. for Transform key) if isinstance(record, HasType): - if kwargs.get("type", None) is None: + # "type" is always present in kwargs at this point (Solution A contract) + type = kwargs["type"] + if type is UNSET: + # user passed nothing → search all type contexts + # catches typed records with same name, fixes silent dup bug + subset = record.__class__.filter() + elif type is None: + # explicit type=None → root-level dedup (type IS NULL) subset = record.__class__.filter(type__isnull=True) else: - subset = record.__class__.filter(type=kwargs["type"]) + # specific type object → scoped dedup within that type + subset = record.__class__.filter(type=type) else: subset = record.__class__ exact_match = subset.filter(**{name_field: kwargs[name_field]}).first() @@ -1162,10 +1185,6 @@ class Meta: def __init__(self, *args, **kwargs): skip_validation = kwargs.pop("_skip_validation", False) - # strip sentinel before validate_fields and Django's Model.__init__ see it - # `is` never calls __eq__, so FeaturePredicate objects are safe - if isinstance(self, HasType) and kwargs.get("type", UNSET) is UNSET: - kwargs.pop("type", None) if not args: def resolve_fk_or_id(field_name: str) -> bool: @@ -1214,6 +1233,9 @@ def resolve_fk_or_id(field_name: str) -> bool: # the current one), so the record is created on that branch. kwargs["created_on"] = kwargs["branch"] if skip_validation: + # strip UNSET just before Django sees kwargs — FK descriptors reject non-model values + if isinstance(self, HasType) and kwargs["type"] is UNSET: + kwargs.pop("type") super().__init__(**kwargs) else: from ..core._settings import settings @@ -1270,6 +1292,9 @@ def resolve_fk_or_id(field_name: str) -> bool: # track original values after replacing with the existing record self._populate_tracked_fields() return None + # strip UNSET just before Django sees kwargs — FK descriptors reject non-model values + if isinstance(self, HasType) and kwargs["type"] is UNSET: + kwargs.pop("type") super().__init__(**kwargs) if isinstance(self, ValidateFields): # this will trigger validation against django validators diff --git a/lamindb/models/ulabel.py b/lamindb/models/ulabel.py index 4582a3c8a..06d04d82e 100644 --- a/lamindb/models/ulabel.py +++ b/lamindb/models/ulabel.py @@ -26,6 +26,7 @@ IsLink, SQLRecord, UNSET, + Unset, _get_record_kwargs, pop_space_branch_kwargs, ) @@ -224,7 +225,7 @@ class Meta(SQLRecord.Meta, TracksRun.Meta, TracksUpdates.Meta): def __init__( self, name: str, - type: ULabel | None = None, + type: ULabel | None | Unset = UNSET, is_type: bool = False, description: str | None = None, reference: str | None = None, @@ -250,7 +251,7 @@ def __init__( if len(args) > 0: raise ValueError("Only one non-keyword arg allowed") name: str = kwargs.pop("name", None) - type: str | None = kwargs.pop("type", UNSET) + type: ULabel | None | Unset = kwargs.pop("type", UNSET) is_type: bool = kwargs.pop("is_type", False) description: str | None = kwargs.pop("description", None) reference: str | None = kwargs.pop("reference", None) diff --git a/tests/pydata/test_record_basics.py b/tests/pydata/test_record_basics.py index 3f6d656c3..4e289882e 100644 --- a/tests/pydata/test_record_basics.py +++ b/tests/pydata/test_record_basics.py @@ -690,14 +690,24 @@ def test_feature_manager_raise_not_validated_values(): def test_name_lookup(): my_type = ln.Record(name="MyType", is_type=True).save() label1 = ln.Record(name="label 1", type=my_type).save() + # same type → returns existing typed record label2 = ln.Record(name="label 1", type=my_type) assert label2 == label1 + # no type passed, only typed record exists → fallback returns the typed one label2 = ln.Record(name="label 1") - assert label2 != label1 - label2.save() - label3 = ln.Record(name="label 1") - assert label3 == label2 - label2.delete(permanent=True) + assert label2 == label1 + # explicit type=None → root-level dedup: label1 is typed so not found → new record + label_new = ln.Record(name="label 1", type=None) + assert label_new != label1 + assert label_new._state.adding # not yet saved, truly a new record + # explicit type=None, root-level record exists → root-level dedup finds it → returns it + root_label = ln.Record(name="root label 1").save() + label3 = ln.Record(name="root label 1", type=None) + assert label3 == root_label + # no type passed (UNSET) → search all → finds the existing root-level record + label4 = ln.Record(name="root label 1") + assert label4 == root_label + root_label.delete(permanent=True) label1.delete(permanent=True) my_type.delete(permanent=True) @@ -1194,7 +1204,7 @@ def test_record_features_add_remove_values(): # test passing ISO-format date string for date - test_record2 = ln.Record(name="test_record").save() + test_record2 = ln.Record(name="test_record_2").save() # we could also test different ways of formatting but don't yet do that # in to_dataframe() we enforce ISO format already feature_date = ln.Feature.get(name="feature_date") diff --git a/tests/pydata/test_sqlrecord.py b/tests/pydata/test_sqlrecord.py index 3767cd1ae..3c50017a8 100644 --- a/tests/pydata/test_sqlrecord.py +++ b/tests/pydata/test_sqlrecord.py @@ -12,6 +12,7 @@ from lamindb.errors import FieldValidationError from lamindb.models import sqlrecord as sqlrecord_module from lamindb.models.sqlrecord import ( + UNSET, _get_record_kwargs, _search, check_key, @@ -287,10 +288,10 @@ def test_suggest_similar_names(): assert ln.Record(name="Test experiment 1").uid == record1.uid assert suggest_records_with_similar_names( - record1, "name", {"name": "Test experiment 1"} + record1, "name", {"name": "Test experiment 1", "type": UNSET} ) assert not suggest_records_with_similar_names( - record2, "name", {"name": "Test experiment 123"} + record2, "name", {"name": "Test experiment 123", "type": UNSET} ) queryset = _search( @@ -401,7 +402,7 @@ def test_get_record_kwargs(): "dtype", "SimpleDtype | SimpleDtypeStr | ULabel | Record | Registry | list[Registry] | FieldAttr", ), - ("type", "Feature | None"), + ("type", "Feature | None | Unset"), ("is_type", "bool"), ("unit", "str | None"), ("description", "str | None"),