diff --git a/pyproject.toml b/pyproject.toml index 20c11bb..3ed83ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vitessce" -version = "3.3.5" +version = "3.4.0" authors = [ { name="Mark Keller", email="mark_keller@hms.harvard.edu" }, ] diff --git a/tests/test_config.py b/tests/test_config.py index 3b83687..fe57262 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -20,6 +20,20 @@ ) +class MockArtifactPath: + def __init__(self, url): + self.url = url + + def to_url(self): + return self.url + + +class MockArtifact: + def __init__(self, name, url): + self.name = name + self.path = MockArtifactPath(url) + + def test_config_creation(): vc = VitessceConfig(schema_version="1.0.15") vc_dict = vc.to_dict() @@ -107,6 +121,55 @@ def test_config_add_anndata_url(): } +def test_config_add_anndata_artifact(): + vc = VitessceConfig(schema_version="1.0.15") + vc.add_dataset(name='My Dataset').add_object( + AnnDataWrapper( + adata_artifact=MockArtifact("My anndata artifact", "http://example.com/adata.h5ad.zarr"), + obs_set_paths=["obs/louvain"], + ) + ) + + vc_dict = vc.to_dict() + + assert vc_dict == { + "version": "1.0.15", + "name": "", + "description": "", + "datasets": [ + { + 'uid': 'A', + 'name': 'My Dataset', + 'files': [ + { + "fileType": "anndata.zarr", + "url": "http://example.com/adata.h5ad.zarr", + "options": { + "obsSets": [ + { + "name": "louvain", + "path": "obs/louvain", + } + ] + } + } + ] + } + ], + 'coordinationSpace': { + 'dataset': { + 'A': 'A' + }, + }, + "layout": [], + "initStrategy": "auto" + } + + vc_artifacts = vc.get_artifacts() + assert list(vc_artifacts.keys()) == ["http://example.com/adata.h5ad.zarr"] + assert vc_artifacts["http://example.com/adata.h5ad.zarr"].name == "My anndata artifact" + + def test_config_add_dataset_add_files(): vc = VitessceConfig(schema_version="1.0.15") vc.add_dataset(name='My Chained Dataset').add_file( diff --git a/vitessce/config.py b/vitessce/config.py index 63103eb..2574d70 100644 --- a/vitessce/config.py +++ b/vitessce/config.py @@ -235,6 +235,13 @@ def get_routes(self): return routes + def get_artifacts(self): + artifacts = {} + for obj in self.objs: + artifacts.update(obj.get_artifacts()) + + return artifacts + def get_stores(self, base_url=None): stores = {} for obj in self.objs: @@ -1590,6 +1597,18 @@ def get_routes(self): routes += d.get_routes() return routes + def get_artifacts(self): + """ + Get all artifacts for this view config from the datasets. + + :returns: A dict mapping artifact URLs to corresponding artifact objects. + :rtype: dict[str, lamindb.Artifact] + """ + artifacts = {} + for d in self.config["datasets"]: + artifacts.update(d.get_artifacts()) + return artifacts + def get_stores(self, base_url=None): """ Convert the routes for this view config from the datasets. diff --git a/vitessce/wrappers.py b/vitessce/wrappers.py index 7e4569e..87017f6 100644 --- a/vitessce/wrappers.py +++ b/vitessce/wrappers.py @@ -47,6 +47,7 @@ def __init__(self, **kwargs): self.file_def_creators = [] self.base_dir = None self.stores = {} + self.artifacts = {} self._request_init = kwargs['request_init'] if 'request_init' in kwargs else None def __repr__(self): @@ -74,6 +75,28 @@ def get_routes(self): """ return self.routes + def register_artifact(self, artifact): + """ + Register an artifact. + + :param artifact: The artifact object to register. + :type artifact: lamindb.Artifact + :returns: The artifact URL. + :rtype: str + """ + artifact_url = artifact.path.to_url() + self.artifacts[artifact_url] = artifact + return artifact_url + + def get_artifacts(self): + """ + Obtain the dictionary that maps artifact URLs to artifact objects. + + :returns: A dictionary that maps artifact URLs to Artifact objects. + :rtype: dict[str, lamindb.Artifact] + """ + return self.artifacts + def get_stores(self, base_url): """ Obtain the stores that have been created for this wrapper class. @@ -417,21 +440,36 @@ class ImageOmeTiffWrapper(AbstractWrapper): :param \\*\\*kwargs: Keyword arguments inherited from :class:`~vitessce.wrappers.AbstractWrapper` """ - def __init__(self, img_path=None, offsets_path=None, img_url=None, offsets_url=None, coordinate_transformations=None, coordination_values=None, **kwargs): + def __init__(self, img_path=None, img_url=None, img_artifact=None, offsets_path=None, offsets_url=None, offsets_artifact=None, coordinate_transformations=None, coordination_values=None, **kwargs): super().__init__(**kwargs) self._repr = make_repr(locals()) + num_inputs = sum([1 for x in [img_path, img_url, img_artifact] if x is not None]) + if num_inputs != 1: + raise ValueError( + "Expected one of img_path, img_url, or img_artifact to be provided") + + num_inputs = sum([1 for x in [offsets_path, offsets_url, offsets_artifact] if x is not None]) + if num_inputs > 1: + raise ValueError( + "Expected zero or one of offsets_path, offsets_url, or offsets_artifact to be provided") + self._img_path = img_path self._img_url = img_url + self._img_artifact = img_artifact self._offsets_path = offsets_path self._offsets_url = offsets_url + self._offsets_artifact = offsets_artifact self._coordinate_transformations = coordinate_transformations self._coordination_values = coordination_values - self.is_remote = img_url is not None + self.is_remote = img_url is not None or img_artifact is not None self.local_img_uid = make_unique_filename(".ome.tif") self.local_offsets_uid = make_unique_filename(".offsets.json") - if img_url is not None and (img_path is not None or offsets_path is not None): - raise ValueError( - "Did not expect img_path or offsets_path to be provided with img_url") + + if img_artifact is not None: + self._img_url = self.register_artifact(img_artifact) + + if offsets_artifact is not None: + self._offsets_url = self.register_artifact(offsets_artifact) def convert_and_save(self, dataset_uid, obj_i, base_dir=None): # Only create out-directory if needed @@ -521,31 +559,50 @@ class ObsSegmentationsOmeTiffWrapper(AbstractWrapper): Wrap an OME-TIFF File by creating an instance of the ``ObsSegmentationsOmeTiffWrapper`` class. Intended to be used with the spatialBeta and layerControllerBeta views. :param str img_path: A local filepath to an OME-TIFF file. - :param str offsets_path: A local filepath to an offsets.json file. :param str img_url: A remote URL of an OME-TIFF file. + :param img_artifact: A lamindb Artifact corresponding to the image. + :type img_artifact: lamindb.Artifact + :param str offsets_path: A local filepath to an offsets.json file. :param str offsets_url: A remote URL of an offsets.json file. + :param offsets_artifact: A lamindb Artifact corresponding to the offsets JSON. + :type offsets_artifact: lamindb.Artifact :param list coordinate_transformations: A column-major ordered matrix for transforming this image (see http://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices/#homogeneous-coordinates for more information). :param bool obs_types_from_channel_names: Whether to use the channel names to determine the obs types. Optional. :param dict coordination_values: Optional coordinationValues to be passed in the file definition. :param \\*\\*kwargs: Keyword arguments inherited from :class:`~vitessce.wrappers.AbstractWrapper` """ - def __init__(self, img_path=None, offsets_path=None, img_url=None, offsets_url=None, coordinate_transformations=None, obs_types_from_channel_names=None, coordination_values=None, **kwargs): + def __init__(self, img_path=None, img_url=None, img_artifact=None, offsets_path=None, offsets_url=None, offsets_artifact=None, coordinate_transformations=None, obs_types_from_channel_names=None, coordination_values=None, **kwargs): super().__init__(**kwargs) self._repr = make_repr(locals()) + num_inputs = sum([1 for x in [img_path, img_url, img_artifact] if x is not None]) + if num_inputs != 1: + raise ValueError( + "Expected one of img_path, img_url, or img_artifact to be provided") + + num_inputs = sum([1 for x in [offsets_path, offsets_url, offsets_artifact] if x is not None]) + if num_inputs > 1: + raise ValueError( + "Expected zero or one of offsets_path, offsets_url, or offsets_artifact to be provided") + self._img_path = img_path self._img_url = img_url + self._img_artifact = img_artifact self._offsets_path = offsets_path self._offsets_url = offsets_url + self._offsets_artifact = offsets_artifact self._coordinate_transformations = coordinate_transformations self._obs_types_from_channel_names = obs_types_from_channel_names self._coordination_values = coordination_values - self.is_remote = img_url is not None + self.is_remote = img_url is not None or img_artifact is not None self.local_img_uid = make_unique_filename(".ome.tif") self.local_offsets_uid = make_unique_filename(".offsets.json") - if img_url is not None and (img_path is not None or offsets_path is not None): - raise ValueError( - "Did not expect img_path or offsets_path to be provided with img_url") + + if img_artifact is not None: + self._img_url = self.register_artifact(img_artifact) + + if offsets_artifact is not None: + self._offsets_url = self.register_artifact(offsets_artifact) def convert_and_save(self, dataset_uid, obj_i, base_dir=None): # Only create out-directory if needed @@ -809,28 +866,36 @@ class ImageOmeZarrWrapper(AbstractWrapper): :param str img_path: A local filepath to an OME-NGFF Zarr store. :param str img_url: A remote URL of an OME-NGFF Zarr store. + :param img_artifact: A lamindb Artifact corresponding to the image. + :type img_artifact: lamindb.Artifact :param list coordinate_transformations: A column-major ordered matrix for transforming this image (see http://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices/#homogeneous-coordinates for more information). :param dict coordination_values: Optional coordinationValues to be passed in the file definition. :param \\*\\*kwargs: Keyword arguments inherited from :class:`~vitessce.wrappers.AbstractWrapper` """ - def __init__(self, img_path=None, img_url=None, coordinate_transformations=None, coordination_values=None, **kwargs): + def __init__(self, img_path=None, img_url=None, img_artifact=None, coordinate_transformations=None, coordination_values=None, **kwargs): super().__init__(**kwargs) self._repr = make_repr(locals()) - if img_url is not None and img_path is not None: - raise ValueError( - "Did not expect img_path to be provided with img_url") - if img_url is None and img_path is None: + + num_inputs = sum([1 for x in [img_path, img_url, img_artifact] if x is not None]) + if num_inputs != 1: raise ValueError( - "Expected either img_url or img_path to be provided") + "Expected one of img_path, img_url, or img_artifact to be provided") + self._img_path = img_path self._img_url = img_url + self._img_artifact = img_artifact self._coordinate_transformations = coordinate_transformations self._coordination_values = coordination_values if self._img_path is not None: self.is_remote = False else: self.is_remote = True + + if self._img_artifact is not None: + # To serve as a placeholder in the config JSON URL field + self._img_url = self.register_artifact(img_artifact) + self.local_dir_uid = make_unique_filename(".ome.zarr") def convert_and_save(self, dataset_uid, obj_i, base_dir=None): @@ -883,23 +948,25 @@ class ObsSegmentationsOmeZarrWrapper(AbstractWrapper): :param str img_path: A local filepath to an OME-NGFF Zarr store. :param str img_url: A remote URL of an OME-NGFF Zarr store. + :param img_artifact: A lamindb Artifact corresponding to the image. + :type img_artifact: lamindb.Artifact :param list coordinate_transformations: A column-major ordered matrix for transforming this image (see http://www.opengl-tutorial.org/beginners-tutorials/tutorial-3-matrices/#homogeneous-coordinates for more information). :param dict coordination_values: Optional coordinationValues to be passed in the file definition. :param bool obs_types_from_channel_names: Whether to use the channel names to determine the obs types. Optional. :param \\*\\*kwargs: Keyword arguments inherited from :class:`~vitessce.wrappers.AbstractWrapper` """ - def __init__(self, img_path=None, img_url=None, coordinate_transformations=None, coordination_values=None, obs_types_from_channel_names=None, **kwargs): + def __init__(self, img_path=None, img_url=None, img_artifact=None, coordinate_transformations=None, coordination_values=None, obs_types_from_channel_names=None, **kwargs): super().__init__(**kwargs) self._repr = make_repr(locals()) - if img_url is not None and img_path is not None: - raise ValueError( - "Did not expect img_path to be provided with img_url") - if img_url is None and img_path is None: + + num_inputs = sum([1 for x in [img_path, img_url, img_artifact] if x is not None]) + if num_inputs != 1: raise ValueError( - "Expected either img_url or img_path to be provided") + "Expected one of img_path, img_url, or img_artifact to be provided") self._img_path = img_path self._img_url = img_url + self._img_artifact = img_artifact self._coordinate_transformations = coordinate_transformations self._obs_types_from_channel_names = obs_types_from_channel_names self._coordination_values = coordination_values @@ -907,6 +974,11 @@ def __init__(self, img_path=None, img_url=None, coordinate_transformations=None, self.is_remote = False else: self.is_remote = True + + if self._img_artifact is not None: + # To serve as a placeholder in the config JSON URL field + self._img_url = self.register_artifact(img_artifact) + self.local_dir_uid = make_unique_filename(".ome.zarr") def convert_and_save(self, dataset_uid, obj_i, base_dir=None): @@ -956,7 +1028,7 @@ def image_file_def_creator(base_url): class AnnDataWrapper(AbstractWrapper): - def __init__(self, adata_path=None, adata_url=None, adata_store=None, ref_path=None, ref_url=None, obs_feature_matrix_path=None, feature_filter_path=None, initial_feature_filter_path=None, obs_set_paths=None, obs_set_names=None, obs_locations_path=None, obs_segmentations_path=None, obs_embedding_paths=None, obs_embedding_names=None, obs_embedding_dims=None, obs_spots_path=None, obs_points_path=None, feature_labels_path=None, obs_labels_path=None, convert_to_dense=True, coordination_values=None, obs_labels_paths=None, obs_labels_names=None, **kwargs): + def __init__(self, adata_path=None, adata_url=None, adata_store=None, adata_artifact=None, ref_path=None, ref_url=None, ref_artifact=None, obs_feature_matrix_path=None, feature_filter_path=None, initial_feature_filter_path=None, obs_set_paths=None, obs_set_names=None, obs_locations_path=None, obs_segmentations_path=None, obs_embedding_paths=None, obs_embedding_names=None, obs_embedding_dims=None, obs_spots_path=None, obs_points_path=None, feature_labels_path=None, obs_labels_path=None, convert_to_dense=True, coordination_values=None, obs_labels_paths=None, obs_labels_names=None, **kwargs): """ Wrap an AnnData object by creating an instance of the ``AnnDataWrapper`` class. @@ -964,6 +1036,8 @@ def __init__(self, adata_path=None, adata_url=None, adata_store=None, ref_path=N :param str adata_url: A remote url pointing to a zarr-backed AnnData store. :param adata_store: A path to pass to zarr.DirectoryStore, or an existing store instance. :type adata_store: str or zarr.Storage + :param adata_artifact: A lamindb Artifact corresponding to the AnnData object. + :type adata_artifact: lamindb.Artifact :param str obs_feature_matrix_path: Location of the expression (cell x gene) matrix, like `X` or `obsm/highly_variable_genes_subset` :param str feature_filter_path: A string like `var/highly_variable` used in conjunction with `obs_feature_matrix_path` if obs_feature_matrix_path points to a subset of `X` of the full `var` list. :param str initial_feature_filter_path: A string like `var/highly_variable` used in conjunction with `obs_feature_matrix_path` if obs_feature_matrix_path points to a subset of `X` of the full `var` list. @@ -990,36 +1064,42 @@ def __init__(self, adata_path=None, adata_url=None, adata_store=None, ref_path=N self._adata_path = adata_path self._adata_url = adata_url self._adata_store = adata_store + self._adata_artifact = adata_artifact # For reference spec JSON with .h5ad files self._ref_path = ref_path self._ref_url = ref_url + self._ref_artifact = ref_artifact - if ref_path is not None or ref_url is not None: + if ref_path is not None or ref_url is not None or ref_artifact is not None: self.is_h5ad = True else: self.is_h5ad = False - if adata_store is not None and (ref_path is not None or ref_url is not None): + if adata_store is not None and (ref_path is not None or ref_url is not None or ref_artifact is not None): raise ValueError( - "Did not expect ref_path or ref_url to be provided with adata_store") + "Did not expect reference JSON to be provided with adata_store") - num_inputs = sum([1 for x in [adata_path, adata_url, adata_store] if x is not None]) - if num_inputs > 1: - raise ValueError( - "Expected only one of adata_path, adata_url, or adata_store to be provided") - if num_inputs == 0: + num_inputs = sum([1 for x in [adata_path, adata_url, adata_store, adata_artifact] if x is not None]) + if num_inputs != 1: raise ValueError( - "Expected one of adata_path, adata_url, or adata_store to be provided") + "Expected one of adata_path, adata_url, adata_artifact, or adata_store to be provided") if adata_path is not None: self.is_remote = False self.is_store = False self.zarr_folder = 'anndata.zarr' - elif adata_url is not None: + elif adata_url is not None or adata_artifact is not None: self.is_remote = True self.is_store = False self.zarr_folder = None + + # Store artifacts on AbstractWrapper.artifacts for downstream access, + # e.g. in lamindb.save_vitessce_config + if adata_artifact is not None: + self._adata_url = self.register_artifact(adata_artifact) + if ref_artifact is not None: + self._ref_url = self.register_artifact(ref_artifact) else: # Store case self.is_remote = False