lamindb 0.76.12__py3-none-any.whl → 0.76.14__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.
lamindb/_feature_set.py CHANGED
@@ -216,7 +216,9 @@ def members(self) -> QuerySet:
216
216
 
217
217
 
218
218
  def _get_related_name(self: FeatureSet) -> str:
219
- feature_sets_related_models = dict_related_model_to_related_name(self)
219
+ feature_sets_related_models = dict_related_model_to_related_name(
220
+ self, instance=self._state.db
221
+ )
220
222
  related_name = feature_sets_related_models.get(self.registry)
221
223
  return related_name
222
224
 
lamindb/_from_values.py CHANGED
@@ -64,11 +64,7 @@ def get_or_create_records(
64
64
  if source_record:
65
65
  from bionty.core._add_ontology import check_source_in_db
66
66
 
67
- check_source_in_db(
68
- registry=registry,
69
- source=source_record,
70
- update=True,
71
- )
67
+ check_source_in_db(registry=registry, source=source_record)
72
68
 
73
69
  from_source = not source_record.in_db
74
70
  elif hasattr(registry, "source_id"):
lamindb/_parents.py CHANGED
@@ -310,7 +310,12 @@ def _record_label(record: Record, field: str | None = None):
310
310
  rf' FACE="Monospace">uid={record.uid}<BR/>version={record.version}</FONT>>'
311
311
  )
312
312
  elif isinstance(record, Run):
313
- name = f'{record.transform.name.replace("&", "&amp;")}'
313
+ if record.transform.name:
314
+ name = f'{record.transform.name.replace("&", "&amp;")}'
315
+ elif record.transform.key:
316
+ name = f'{record.transform.key.replace("&", "&amp;")}'
317
+ else:
318
+ name = f"{record.transform.uid}"
314
319
  user_display = (
315
320
  record.created_by.handle
316
321
  if record.created_by.name is None
@@ -365,7 +370,6 @@ def _get_all_parent_runs(data: Artifact | Collection) -> list:
365
370
  inputs_run += (
366
371
  r.input_collections.all().filter(visibility__in=[0, 1]).list()
367
372
  )
368
- run_inputs_outputs += [(inputs_run, r)]
369
373
  outputs_run = (
370
374
  r.__getattribute__(f"output_{name}s")
371
375
  .all()
@@ -376,7 +380,18 @@ def _get_all_parent_runs(data: Artifact | Collection) -> list:
376
380
  outputs_run += (
377
381
  r.output_collections.all().filter(visibility__in=[0, 1]).list()
378
382
  )
379
- run_inputs_outputs += [(r, outputs_run)]
383
+ # if inputs are outputs artifacts are the same, will result infinite loop
384
+ # so only show as outputs
385
+ overlap = set(inputs_run).intersection(outputs_run)
386
+ if overlap:
387
+ logger.warning(
388
+ f"The following artifacts are both inputs and outputs of Run(uid={r.uid}): {overlap}\n → Only showing as outputs."
389
+ )
390
+ inputs_run = list(set(inputs_run) - overlap)
391
+ if len(inputs_run) > 0:
392
+ run_inputs_outputs += [(inputs_run, r)]
393
+ if len(outputs_run) > 0:
394
+ run_inputs_outputs += [(r, outputs_run)]
380
395
  inputs += inputs_run
381
396
  runs = [f.run for f in inputs if f.run is not None]
382
397
  return run_inputs_outputs
lamindb/_query_manager.py CHANGED
@@ -98,26 +98,11 @@ class QueryManager(models.Manager):
98
98
 
99
99
  return _lookup(cls=self.all(), field=field, **kwargs)
100
100
 
101
- def __getitem__(self, item: str):
102
- try:
103
- source_field_name = self.source_field_name
104
- target_field_name = self.target_field_name
105
-
106
- if (
107
- source_field_name in {"artifact", "collection"}
108
- and target_field_name == "feature_set"
109
- ):
110
- return get_feature_set_by_slot_(host=self.instance).get(item)
111
-
112
- except Exception: # pragma: no cover
113
- return
114
-
115
101
 
116
102
  models.Manager.list = QueryManager.list
117
103
  models.Manager.df = QueryManager.df
118
104
  models.Manager.search = QueryManager.search
119
105
  models.Manager.lookup = QueryManager.lookup
120
- models.Manager.__getitem__ = QueryManager.__getitem__
121
106
  models.Manager._track_run_input_manager = QueryManager._track_run_input_manager
122
107
  # the two lines below would be easy if we could actually inherit; like this,
123
108
  # they're suboptimal
lamindb/_query_set.py CHANGED
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, NamedTuple
6
6
  import pandas as pd
7
7
  from django.db import models
8
8
  from django.db.models import F
9
- from lamin_utils import logger
9
+ from lamin_utils import colors, logger
10
10
  from lamindb_setup.core._docs import doc_args
11
11
  from lnschema_core.models import (
12
12
  Artifact,
@@ -115,7 +115,7 @@ def get(
115
115
  else:
116
116
  assert idlike is None # noqa: S101
117
117
  expressions = process_expressions(registry, expressions)
118
- return registry.objects.get(**expressions)
118
+ return registry.objects.using(qs.db).get(**expressions)
119
119
 
120
120
 
121
121
  class RecordsList(UserList):
@@ -186,6 +186,7 @@ class QuerySet(models.QuerySet):
186
186
  if pk_column_name in df.columns:
187
187
  df = df.set_index(pk_column_name)
188
188
  if len(df) == 0:
189
+ logger.warning(colors.yellow("No records found"))
189
190
  return df
190
191
  if include is not None:
191
192
  if isinstance(include, str):
@@ -218,12 +219,15 @@ class QuerySet(models.QuerySet):
218
219
  f"{related_ORM.__name__.lower()}__{lookup_str}"
219
220
  )
220
221
  link_df = pd.DataFrame(
221
- field.through.objects.values(
222
+ field.through.objects.using(self.db).values(
222
223
  left_side_link_model, values_expression
223
224
  )
224
225
  )
225
226
  if link_df.shape[0] == 0:
226
- return df
227
+ logger.warning(
228
+ f"{colors.yellow(expression)} is not shown because no values are found"
229
+ )
230
+ continue
227
231
  link_groupby = link_df.groupby(left_side_link_model)[
228
232
  values_expression
229
233
  ].apply(list)
lamindb/_record.py CHANGED
@@ -7,7 +7,7 @@ import dj_database_url
7
7
  import lamindb_setup as ln_setup
8
8
  from django.db import connections, transaction
9
9
  from django.db.models import IntegerField, Manager, Q, QuerySet, Value
10
- from lamin_utils import logger
10
+ from lamin_utils import colors, logger
11
11
  from lamin_utils._lookup import Lookup
12
12
  from lamindb_setup._connect_instance import (
13
13
  get_owner_name_from_identifier,
@@ -17,10 +17,11 @@ from lamindb_setup._connect_instance import (
17
17
  from lamindb_setup.core._docs import doc_args
18
18
  from lamindb_setup.core._hub_core import connect_instance_hub
19
19
  from lamindb_setup.core._settings_store import instance_settings_file
20
- from lnschema_core.models import IsVersioned, Record, Run, Transform
20
+ from lnschema_core.models import Artifact, Feature, IsVersioned, Record, Run, Transform
21
21
 
22
22
  from lamindb._utils import attach_func_to_class_method
23
23
  from lamindb.core._settings import settings
24
+ from lamindb.core.exceptions import RecordNameChangeIntegrityError
24
25
 
25
26
  if TYPE_CHECKING:
26
27
  import pandas as pd
@@ -129,6 +130,7 @@ def __init__(record: Record, *args, **kwargs):
129
130
  else:
130
131
  # object is loaded from DB (**kwargs could be omitted below, I believe)
131
132
  super(Record, record).__init__(*args, **kwargs)
133
+ _store_record_old_name(record)
132
134
 
133
135
 
134
136
  @classmethod # type:ignore
@@ -376,6 +378,8 @@ def using(
376
378
  instance: str | None,
377
379
  ) -> QuerySet:
378
380
  """{}""" # noqa: D415
381
+ from ._query_set import QuerySet
382
+
379
383
  if instance is None:
380
384
  return QuerySet(model=cls, using=None)
381
385
  owner, name = get_owner_name_from_identifier(instance)
@@ -395,15 +399,15 @@ def using(
395
399
  if not source_schema.issubset(target_schema):
396
400
  missing_members = source_schema - target_schema
397
401
  logger.warning(
398
- f"source schema has additional modules: {missing_members}\nconsider mounting these schema modules to not encounter errors"
402
+ f"source schema has additional modules: {missing_members}\nconsider mounting these schema modules to transfer all metadata"
399
403
  )
400
- cache_filepath.write_text(iresult["lnid"]) # type: ignore
404
+ cache_filepath.write_text(f"{iresult['lnid']}\n{iresult['schema_str']}") # type: ignore
401
405
  settings_file = instance_settings_file(name, owner)
402
406
  db = update_db_using_local(iresult, settings_file)
403
407
  else:
404
408
  isettings = load_instance_settings(settings_file)
405
409
  db = isettings.db
406
- cache_filepath.write_text(isettings.uid)
410
+ cache_filepath.write_text(f"{isettings.uid}\n{','.join(isettings.schema)}") # type: ignore
407
411
  add_db_connection(db, instance)
408
412
  return QuerySet(model=cls, using=instance)
409
413
 
@@ -470,7 +474,7 @@ def get_transfer_run(record) -> Run:
470
474
  cache_filepath = ln_setup.settings.cache_dir / f"instance--{owner}--{name}--uid.txt"
471
475
  if not cache_filepath.exists():
472
476
  raise SystemExit("Need to call .using() before")
473
- instance_uid = cache_filepath.read_text()
477
+ instance_uid = cache_filepath.read_text().split("\n")[0]
474
478
  key = f"transfers/{instance_uid}"
475
479
  uid = instance_uid + "0000"
476
480
  transform = Transform.filter(uid=uid).one_or_none()
@@ -582,11 +586,15 @@ def save(self, *args, **kwargs) -> Record:
582
586
  with transaction.atomic():
583
587
  revises._revises = None # ensure we don't start a recursion
584
588
  revises.save()
589
+ check_name_change(self)
585
590
  super(Record, self).save(*args, **kwargs)
591
+ _store_record_old_name(self)
586
592
  self._revises = None
587
593
  # save unversioned record
588
594
  else:
595
+ check_name_change(self)
589
596
  super(Record, self).save(*args, **kwargs)
597
+ _store_record_old_name(self)
590
598
  # perform transfer of many-to-many fields
591
599
  # only supported for Artifact and Collection records
592
600
  if db is not None and db != "default" and using_key is None:
@@ -614,6 +622,74 @@ def save(self, *args, **kwargs) -> Record:
614
622
  return self
615
623
 
616
624
 
625
+ def _store_record_old_name(record: Record):
626
+ # writes the name to the _name attribute, so we can detect renaming upon save
627
+ if hasattr(record, "_name_field"):
628
+ record._name = getattr(record, record._name_field)
629
+
630
+
631
+ def check_name_change(record: Record):
632
+ """Warns if a record's name has changed."""
633
+ if (
634
+ not record.pk
635
+ or not hasattr(record, "_name")
636
+ or not hasattr(record, "_name_field")
637
+ ):
638
+ return
639
+
640
+ old_name = record._name
641
+ new_name = getattr(record, record._name_field)
642
+ registry = record.__class__.__name__
643
+
644
+ if old_name != new_name:
645
+ # when a label is renamed, only raise a warning if it has a feature
646
+ if hasattr(record, "artifacts"):
647
+ linked_records = (
648
+ record.artifacts.through.filter(
649
+ label_ref_is_name=True, **{f"{registry.lower()}_id": record.pk}
650
+ )
651
+ .exclude(feature_id=None) # must have a feature
652
+ .exclude(
653
+ feature_ref_is_name=None
654
+ ) # must be linked via Curator and therefore part of a featureset
655
+ .distinct()
656
+ )
657
+ artifact_ids = linked_records.list("artifact__uid")
658
+ n = len(artifact_ids)
659
+ s = "s" if n > 1 else ""
660
+ if n > 0:
661
+ logger.error(
662
+ f"You are trying to {colors.red('rename label')} from '{old_name}' to '{new_name}'!\n"
663
+ f" → The following {n} artifact{s} {colors.red('will no longer be validated')}: {artifact_ids}\n\n"
664
+ f"{colors.bold('To rename this label')}, make it external:\n"
665
+ f" → run `artifact.labels.make_external(label)`\n\n"
666
+ f"After renaming, consider re-curating the above artifact{s}:\n"
667
+ f' → in each dataset, manually modify label "{old_name}" to "{new_name}"\n'
668
+ f" → run `ln.Curator`\n"
669
+ )
670
+ raise RecordNameChangeIntegrityError
671
+
672
+ # when a feature is renamed
673
+ elif isinstance(record, Feature):
674
+ # only internal features are associated with featuresets
675
+ linked_artifacts = Artifact.filter(feature_sets__features=record).list(
676
+ "uid"
677
+ )
678
+ n = len(linked_artifacts)
679
+ s = "s" if n > 1 else ""
680
+ if n > 0:
681
+ logger.error(
682
+ f"You are trying to {colors.red('rename feature')} from '{old_name}' to '{new_name}'!\n"
683
+ f" → The following {n} artifact{s} {colors.red('will no longer be validated')}: {linked_artifacts}\n\n"
684
+ f"{colors.bold('To rename this feature')}, make it external:\n"
685
+ " → run `artifact.features.make_external(feature)`\n\n"
686
+ f"After renaming, consider re-curating the above artifact{s}:\n"
687
+ f" → in each dataset, manually modify feature '{old_name}' to '{new_name}'\n"
688
+ f" → run `ln.Curator`\n"
689
+ )
690
+ raise RecordNameChangeIntegrityError
691
+
692
+
617
693
  def delete(self) -> None:
618
694
  """Delete the record."""
619
695
  # note that the logic below does not fire if a record is moved to the trash
lamindb/core/__init__.py CHANGED
@@ -60,9 +60,11 @@ Modules:
60
60
  types
61
61
  exceptions
62
62
  subsettings
63
+ logger
63
64
 
64
65
  """
65
66
 
67
+ from lamin_utils import logger
66
68
  from lamin_utils._inspect import InspectResult
67
69
  from lnschema_core.models import (
68
70
  CanValidate,
lamindb/core/_context.py CHANGED
@@ -438,7 +438,7 @@ class Context:
438
438
  )
439
439
  return (
440
440
  f'Filename "{key}" clashes with the existing key "{transform.key}" for uid "{transform.uid[:-4]}...."\n\nEither init a new transform with a new uid:\n\n'
441
- f'ln.track("{ids.base62_12()}0000)"\n\n{update_key_note}'
441
+ f'ln.track("{ids.base62_12()}0000")\n\n{update_key_note}'
442
442
  )
443
443
 
444
444
  # make a new transform record
lamindb/core/_data.py CHANGED
@@ -26,6 +26,7 @@ from lamindb.core._settings import settings
26
26
  from ._context import context
27
27
  from ._django import get_artifact_with_related, get_related_model
28
28
  from ._feature_manager import (
29
+ add_label_feature_links,
29
30
  get_feature_set_links,
30
31
  get_host_id_field,
31
32
  get_label_links,
@@ -67,11 +68,17 @@ def add_transform_to_kwargs(kwargs: dict[str, Any], run: Run):
67
68
 
68
69
  def save_feature_sets(self: Artifact | Collection) -> None:
69
70
  if hasattr(self, "_feature_sets"):
71
+ from lamindb.core._feature_manager import get_feature_set_by_slot_
72
+
73
+ existing_feature_sets = get_feature_set_by_slot_(self)
70
74
  saved_feature_sets = {}
71
75
  for key, feature_set in self._feature_sets.items():
72
76
  if isinstance(feature_set, FeatureSet) and feature_set._state.adding:
73
77
  feature_set.save()
74
78
  saved_feature_sets[key] = feature_set
79
+ if key in existing_feature_sets:
80
+ # remove existing feature set on the same slot
81
+ self.feature_sets.remove(existing_feature_sets[key])
75
82
  if len(saved_feature_sets) > 0:
76
83
  s = "s" if len(saved_feature_sets) > 1 else ""
77
84
  display_feature_set_keys = ",".join(
@@ -305,6 +312,8 @@ def add_labels(
305
312
  feature: Feature | None = None,
306
313
  *,
307
314
  field: StrField | None = None,
315
+ feature_ref_is_name: bool | None = None,
316
+ label_ref_is_name: bool | None = None,
308
317
  ) -> None:
309
318
  """{}""" # noqa: D415
310
319
  if self._state.adding:
@@ -373,14 +382,17 @@ def add_labels(
373
382
  if registry_name not in self.features._accessor_by_registry:
374
383
  logger.warning(f"skipping {registry_name}")
375
384
  continue
376
- labels_accessor = getattr(
377
- self, self.features._accessor_by_registry[registry_name]
385
+ if len(records) == 0:
386
+ continue
387
+ features_labels = {
388
+ registry_name: [(feature, label_record) for label_record in records]
389
+ }
390
+ add_label_feature_links(
391
+ self.features,
392
+ features_labels,
393
+ feature_ref_is_name=feature_ref_is_name,
394
+ label_ref_is_name=label_ref_is_name,
378
395
  )
379
- # remove labels that are already linked as add doesn't perform update
380
- linked_labels = [r for r in records if r in labels_accessor.filter()]
381
- if len(linked_labels) > 0:
382
- labels_accessor.remove(*linked_labels)
383
- labels_accessor.add(*records, through_defaults={"feature_id": feature.id})
384
396
  links_feature_set = get_feature_set_links(self)
385
397
  feature_set_ids = [link.featureset_id for link in links_feature_set.all()]
386
398
  # get all linked features of type Feature
lamindb/core/_django.py CHANGED
@@ -6,7 +6,7 @@ from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
6
6
  from django.db.models.functions import JSONObject
7
7
  from lnschema_core.models import Artifact, FeatureSet, Record
8
8
 
9
- from .schema import dict_related_model_to_related_name
9
+ from .schema import dict_related_model_to_related_name, get_schemas_modules
10
10
 
11
11
 
12
12
  def get_related_model(model, field_name):
@@ -35,22 +35,36 @@ def get_artifact_with_related(
35
35
  """Fetch an artifact with its related data."""
36
36
  from lamindb._can_validate import get_name_field
37
37
 
38
+ from ._label_manager import LABELS_EXCLUDE_SET
39
+
38
40
  model = artifact.__class__
39
- foreign_key_fields = [f.name for f in model._meta.fields if f.is_relation]
41
+ schema_modules = get_schemas_modules(artifact._state.db)
42
+
43
+ foreign_key_fields = [
44
+ f.name
45
+ for f in model._meta.fields
46
+ if f.is_relation and f.related_model.__get_schema_name__() in schema_modules
47
+ ]
40
48
 
41
49
  m2m_relations = (
42
50
  []
43
51
  if not include_m2m
44
52
  else [
45
53
  v
46
- for v in dict_related_model_to_related_name(model).values()
47
- if not v.startswith("_")
54
+ for v in dict_related_model_to_related_name(
55
+ model, instance=artifact._state.db
56
+ ).values()
57
+ if not v.startswith("_") and v not in LABELS_EXCLUDE_SET
48
58
  ]
49
59
  )
50
60
  link_tables = (
51
61
  []
52
62
  if not include_feature_link
53
- else list(dict_related_model_to_related_name(model, links=True).values())
63
+ else list(
64
+ dict_related_model_to_related_name(
65
+ model, links=True, instance=artifact._state.db
66
+ ).values()
67
+ )
54
68
  )
55
69
 
56
70
  # Clear previous queries
@@ -13,6 +13,7 @@ from django.contrib.postgres.aggregates import ArrayAgg
13
13
  from django.db import connections
14
14
  from django.db.models import Aggregate
15
15
  from lamin_utils import colors, logger
16
+ from lamindb_setup.core.hashing import hash_set
16
17
  from lamindb_setup.core.upath import create_path
17
18
  from lnschema_core.models import (
18
19
  Artifact,
@@ -216,9 +217,11 @@ def _print_categoricals(
216
217
  if not print_params:
217
218
  labels_msg = ""
218
219
  labels_by_feature = defaultdict(list)
219
- for _, (_, links) in get_labels_as_dict(self, links=True).items():
220
+ for _, (_, links) in get_labels_as_dict(
221
+ self, links=True, instance=self._state.db
222
+ ).items():
220
223
  for link in links:
221
- if link.feature_id is not None:
224
+ if hasattr(link, "feature_id") and link.feature_id is not None:
222
225
  link_attr = get_link_attr(link, self)
223
226
  labels_by_feature[link.feature_id].append(
224
227
  getattr(link, link_attr).name
@@ -584,6 +587,48 @@ def _accessor_by_registry(self):
584
587
  return self._accessor_by_registry_
585
588
 
586
589
 
590
+ def add_label_feature_links(
591
+ self,
592
+ features_labels,
593
+ *,
594
+ label_ref_is_name: bool | None = None,
595
+ feature_ref_is_name: bool | None = None,
596
+ ):
597
+ if list(features_labels.keys()) != ["ULabel"]:
598
+ related_names = dict_related_model_to_related_name(self._host.__class__)
599
+ else:
600
+ related_names = {"ULabel": "ulabels"}
601
+ for class_name, registry_features_labels in features_labels.items():
602
+ related_name = related_names[class_name] # e.g., "ulabels"
603
+ LinkORM = getattr(self._host, related_name).through
604
+ field_name = f"{get_link_attr(LinkORM, self._host)}_id" # e.g., ulabel_id
605
+ links = [
606
+ LinkORM(
607
+ **{
608
+ "artifact_id": self._host.id,
609
+ "feature_id": feature.id,
610
+ field_name: label.id,
611
+ "feature_ref_is_name": feature_ref_is_name,
612
+ "label_ref_is_name": label_ref_is_name,
613
+ }
614
+ )
615
+ for (feature, label) in registry_features_labels
616
+ ]
617
+ # a link might already exist
618
+ try:
619
+ save(links, ignore_conflicts=False)
620
+ except Exception:
621
+ save(links, ignore_conflicts=True)
622
+ # now delete links that were previously saved without a feature
623
+ LinkORM.filter(
624
+ **{
625
+ "artifact_id": self._host.id,
626
+ "feature_id": None,
627
+ f"{field_name}__in": [l.id for _, l in registry_features_labels],
628
+ }
629
+ ).all().delete()
630
+
631
+
587
632
  def _add_values(
588
633
  self,
589
634
  values: dict[str, str | int | float | bool],
@@ -715,49 +760,9 @@ def _add_values(
715
760
  f"Here is how to create ulabels for them:\n\n{hint}"
716
761
  )
717
762
  raise ValidationError(msg)
718
- # bulk add all links to ArtifactULabel
763
+ # bulk add all links
719
764
  if features_labels:
720
- if list(features_labels.keys()) != ["ULabel"]:
721
- related_names = dict_related_model_to_related_name(self._host.__class__)
722
- else:
723
- related_names = {"ULabel": "ulabels"}
724
- for class_name, registry_features_labels in features_labels.items():
725
- related_name = related_names[class_name] # e.g., "ulabels"
726
- LinkORM = getattr(self._host, related_name).through
727
- field_name = f"{get_link_attr(LinkORM, self._host)}_id" # e.g., ulabel_id
728
- links = [
729
- LinkORM(
730
- **{
731
- "artifact_id": self._host.id,
732
- "feature_id": feature.id,
733
- field_name: label.id,
734
- }
735
- )
736
- for (feature, label) in registry_features_labels
737
- ]
738
- # a link might already exist
739
- try:
740
- save(links, ignore_conflicts=False)
741
- except Exception:
742
- save(links, ignore_conflicts=True)
743
- # now deal with links that were previously saved without a feature_id
744
- links_saved = LinkORM.filter(
745
- **{
746
- "artifact_id": self._host.id,
747
- f"{field_name}__in": [
748
- l.id for _, l in registry_features_labels
749
- ],
750
- }
751
- )
752
- for link in links_saved.all():
753
- # TODO: also check for inconsistent features
754
- if link.feature_id is None:
755
- link.feature_id = [
756
- f.id
757
- for f, l in registry_features_labels
758
- if l.id == getattr(link, field_name)
759
- ][0]
760
- link.save()
765
+ add_label_feature_links(self, features_labels)
761
766
  if _feature_values:
762
767
  save(_feature_values)
763
768
  if is_param:
@@ -1004,6 +1009,36 @@ def _add_from(self, data: Artifact | Collection, transfer_logs: dict = None):
1004
1009
  self._host.features.add_feature_set(feature_set_self, slot)
1005
1010
 
1006
1011
 
1012
+ def make_external(self, feature: Feature) -> None:
1013
+ """Make a feature external, aka, remove feature from feature sets.
1014
+
1015
+ Args:
1016
+ feature: `Feature` A feature record.
1017
+
1018
+ """
1019
+ if not isinstance(feature, Feature):
1020
+ raise TypeError("feature must be a Feature record!")
1021
+ feature_sets = FeatureSet.filter(features=feature).all()
1022
+ for fs in feature_sets:
1023
+ f = Feature.filter(uid=feature.uid).all()
1024
+ features_updated = fs.members.difference(f)
1025
+ if len(features_updated) > 0:
1026
+ # re-compute the hash of feature sets based on the updated members
1027
+ features_hash = hash_set({feature.uid for feature in features_updated})
1028
+ fs.hash = features_hash
1029
+ fs.n = len(features_updated)
1030
+ fs.save()
1031
+ # delete the link between the feature and the feature set
1032
+ FeatureSet.features.through.objects.filter(
1033
+ feature_id=feature.id, featureset_id=fs.id
1034
+ ).delete()
1035
+ # if no members are left in the featureset, delete it
1036
+ if len(features_updated) == 0:
1037
+ logger.warning(f"deleting empty feature set: {fs}")
1038
+ fs.artifacts.set([])
1039
+ fs.delete()
1040
+
1041
+
1007
1042
  FeatureManager.__init__ = __init__
1008
1043
  ParamManager.__init__ = __init__
1009
1044
  FeatureManager.__repr__ = __repr__
@@ -1020,6 +1055,7 @@ FeatureManager._add_set_from_mudata = _add_set_from_mudata
1020
1055
  FeatureManager._add_from = _add_from
1021
1056
  FeatureManager.filter = filter
1022
1057
  FeatureManager.get = get
1058
+ FeatureManager.make_external = make_external
1023
1059
  ParamManager.add_values = add_values_params
1024
1060
  ParamManager.get_values = get_values
1025
1061
  ParamManager.filter = filter