From 581996c21e3591c866759adaffa60284908167a3 Mon Sep 17 00:00:00 2001 From: Erick Martins Ratamero Date: Thu, 3 Aug 2023 13:29:31 -0400 Subject: [PATCH] Add `--merge` option to `unpack` (#57) * bump to new ome-types * updating things for ome_types=0.4.0 * first try at merge projects, stubs for others * only missing find_dataset * first pass at merge, find_dataset needs reviewing * now resolving dataset refs to actual datasets * basic merge functionality working * added basic prepare metadata * doing clever image matching (again?) considering annotations * tests passing - made `conn` optional on `make_image_map` * merge tests passing, some extra minor fixes - merging orphaned datasets - using `isInstance` instead of `type` * updated readme and setup.py --- .gitignore | 3 + README.md | 4 + requirements.txt | 2 +- setup.py | 4 +- src/generate_omero_objects.py | 144 +++++++++++++++++++++++++----- src/generate_xml.py | 61 +++++++++---- src/omero_cli_transfer.py | 40 +++++++-- test/data/simple_screen.zip | Bin 23808 -> 24334 bytes test/integration/test_prepare.py | 28 +++--- test/integration/test_transfer.py | 34 +++++++ test/unit/test_set.py | 2 +- 11 files changed, 255 insertions(+), 67 deletions(-) diff --git a/.gitignore b/.gitignore index e6f8fb2..05792f8 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ dmypy.json # Pyre type checker .pyre/ + +# vs code settings +.vscode/ diff --git a/README.md b/README.md index 3b17f24..93e1952 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ Note that unpack needs to be able to identify the images it imports inequivocall `--folder` allows the user to point to a previously-unpacked folder rather than a single file. +`--merge` will use existing Projects, Datasets and Screens if the current user +already owns entities with the same name as ones defined in `transfer.xml`, +effectively merging the "new" unpacked entities with existing ones. + Examples: ``` omero transfer unpack transfer_pack.zip diff --git a/requirements.txt b/requirements.txt index 47a5a57..cc188e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ ezomero>=2.0.0 -ome-types>=0.3.4 +ome-types>=0.4.0 setuptools>=58.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py index c24dd83..d8df849 100644 --- a/setup.py +++ b/setup.py @@ -84,7 +84,7 @@ def read(fname): packages=['', 'omero.plugins'], package_dir={"": "src"}, name="omero-cli-transfer", - version='0.6.0', + version='0.7.0', maintainer="Erick Ratamero", maintainer_email="erick.ratamero@jax.org", description=("A set of utilities for exporting a transfer" @@ -96,7 +96,7 @@ def read(fname): url="https://github.com/TheJacksonLaboratory/omero-cli-transfer", install_requires=[ 'ezomero==2.0.0', - 'ome-types==0.3.4' + 'ome-types==0.4.0' ], extras_require={ "rocrate": ["rocrate==0.7.0"], diff --git a/src/generate_omero_objects.py b/src/generate_omero_objects.py index de40c86..3bd0e47 100644 --- a/src/generate_omero_objects.py +++ b/src/generate_omero_objects.py @@ -25,6 +25,20 @@ import copy +def create_or_set_projects(pjs: List[Project], conn: BlitzGateway, + merge: bool) -> dict: + pj_map = {} + if not merge: + pj_map = create_projects(pjs, conn) + else: + for pj in pjs: + pj_id = find_project(pj, conn) + if not pj_id: + pj_id = ezomero.post_project(conn, pj.name, pj.description) + pj_map[pj.id] = pj_id + return pj_map + + def create_projects(pjs: List[Project], conn: BlitzGateway) -> dict: pj_map = {} for pj in pjs: @@ -33,6 +47,29 @@ def create_projects(pjs: List[Project], conn: BlitzGateway) -> dict: return pj_map +def find_project(pj: Project, conn: BlitzGateway) -> int: + id = 0 + my_exp_id = conn.getUser().getId() + for p in conn.getObjects("Project", opts={'owner': my_exp_id}): + if p.getName() == pj.name: + id = p.getId() + return id + + +def create_or_set_screens(scrs: List[Screen], conn: BlitzGateway, merge: bool + ) -> dict: + scr_map = {} + if not merge: + scr_map = create_screens(scrs, conn) + else: + for scr in scrs: + scr_id = find_screen(scr, conn) + if not scr_id: + scr_id = ezomero.post_screen(conn, scr.name, scr.description) + scr_map[scr.id] = scr_id + return scr_map + + def create_screens(scrs: List[Screen], conn: BlitzGateway) -> dict: scr_map = {} for scr in scrs: @@ -41,6 +78,34 @@ def create_screens(scrs: List[Screen], conn: BlitzGateway) -> dict: return scr_map +def find_screen(sc: Screen, conn: BlitzGateway) -> int: + id = 0 + my_exp_id = conn.getUser().getId() + for s in conn.getObjects("Screen", opts={'owner': my_exp_id}): + if s.getName() == sc.name: + id = s.getId() + return id + + +def create_or_set_datasets(dss: List[Dataset], pjs: List[Project], + conn: BlitzGateway, merge: bool) -> dict: + ds_map = {} + if not merge: + ds_map = create_datasets(dss, conn) + else: + for ds in dss: + ds_id = find_dataset(ds, pjs, conn) + if not ds_id: + dataset = DatasetWrapper(conn, DatasetI()) + dataset.setName(ds.name) + if ds.description is not None: + dataset.setDescription(ds.description) + dataset.save() + ds_id = dataset.getId() + ds_map[ds.id] = ds_id + return ds_map + + def create_datasets(dss: List[Dataset], conn: BlitzGateway) -> dict: """ Currently doing it the non-ezomero way because ezomero always @@ -58,6 +123,31 @@ def create_datasets(dss: List[Dataset], conn: BlitzGateway) -> dict: return ds_map +def find_dataset(ds: Dataset, pjs: List[Project], conn: BlitzGateway) -> int: + id = 0 + my_exp_id = conn.getUser().getId() + orphan = True + for pj in pjs: + for dsref in pj.dataset_refs: + if dsref.id == ds.id: + orphan = False + if not orphan: + for pj in pjs: + for p in conn.getObjects("Project", opts={'owner': my_exp_id}): + if p.getName() == pj.name: + for dsref in pj.dataset_refs: + if dsref.id == ds.id: + for ds_rem in p.listChildren(): + if ds.name == ds_rem.getName(): + id = ds_rem.getId() + else: + for d in conn.getObjects("Dataset", opts={'owner': my_exp_id, + 'orphaned': True}): + if d.getName() == ds.name: + id = d.getId() + return id + + def create_annotations(ans: List[Annotation], conn: BlitzGateway, hash: str, folder: str, metadata: List[str]) -> dict: ann_map = {} @@ -73,7 +163,7 @@ def create_annotations(ans: List[Annotation], conn: BlitzGateway, hash: str, namespace = an.namespace map_ann.setNs(namespace) key_value_data = [] - for v in an.value.m: + for v in an.value.ms: if int(an.id.split(":")[-1]) < 0: if not metadata: key_value_data.append(['empty_metadata', "True"]) @@ -131,7 +221,7 @@ def create_original_file(ann: FileAnnotation, ans: List[Annotation], conn: BlitzGateway, folder: str ) -> OriginalFileWrapper: curr_folder = str(Path('.').resolve()) - for an in ann.annotation_ref: + for an in ann.annotation_refs: clean_id = int(an.id.split(":")[-1]) if clean_id < 0: cmnt_id = an.id @@ -149,7 +239,7 @@ def create_plate_map(ome: OME, img_map: dict, conn: BlitzGateway plate_map = {} map_ref_ids = [] for plate in ome.plates: - ann_ids = [i.id for i in plate.annotation_ref] + ann_ids = [i.id for i in plate.annotation_refs] file_path = "" for ann in ome.structured_annotations: if (ann.id in ann_ids and @@ -201,9 +291,9 @@ def create_plate_map(ome: OME, img_map: dict, conn: BlitzGateway plate_id = create_plate_from_images(plate, img_map, conn) plate_map[plate.id] = plate_id for p in newome.plates: - for ref in p.annotation_ref: + for ref in p.annotation_refs: if ref.id in map_ref_ids: - p.annotation_ref.remove(ref) + p.annotation_refs.remove(ref) return plate_map, newome @@ -342,7 +432,7 @@ def _int_to_rgba(omero_val: int) -> Tuple[int, int, int, int]: def create_rois(rois: List[ROI], imgs: List[Image], img_map: dict, conn: BlitzGateway): for img in imgs: - for roiref in img.roi_ref: + for roiref in img.roi_refs: roi = next(filter(lambda x: x.id == roiref.id, rois)) shapes = create_shapes(roi) img_id_dest = img_map[img.id] @@ -354,10 +444,15 @@ def create_rois(rois: List[ROI], imgs: List[Image], img_map: dict, def link_datasets(ome: OME, proj_map: dict, ds_map: dict, conn: BlitzGateway): for proj in ome.projects: proj_id = proj_map[proj.id] + proj_obj = conn.getObject("Project", proj_id) + existing_ds = [] + for dataset in proj_obj.listChildren(): + existing_ds.append(dataset.getId()) ds_ids = [] - for ds in proj.dataset_ref: + for ds in proj.dataset_refs: ds_id = ds_map[ds.id] - ds_ids.append(ds_id) + if ds_id not in existing_ds: + ds_ids.append(ds_id) ezomero.link_datasets_to_project(conn, ds_ids, proj_id) return @@ -366,10 +461,15 @@ def link_plates(ome: OME, screen_map: dict, plate_map: dict, conn: BlitzGateway): for screen in ome.screens: screen_id = screen_map[screen.id] + scr_obj = conn.getObject("Screen", screen_id) + existing_pl = [] + for pl in scr_obj.listChildren(): + existing_pl.append(pl.getId()) pl_ids = [] - for pl in screen.plate_ref: + for pl in screen.plate_refs: pl_id = plate_map[pl.id] - pl_ids.append(pl_id) + if pl_id not in existing_pl: + pl_ids.append(pl_id) ezomero.link_plates_to_screen(conn, pl_ids, screen_id) return @@ -378,7 +478,7 @@ def link_images(ome: OME, ds_map: dict, img_map: dict, conn: BlitzGateway): for ds in ome.datasets: ds_id = ds_map[ds.id] img_ids = [] - for img in ds.image_ref: + for img in ds.image_refs: try: img_id = img_map[img.id] img_ids.append(img_id) @@ -395,14 +495,14 @@ def link_annotations(ome: OME, proj_map: dict, ds_map: dict, img_map: dict, proj_id = proj_map[proj.id] proj_obj = conn.getObject("Project", proj_id) anns = ome.structured_annotations - for annref in proj.annotation_ref: + for annref in proj.annotation_refs: ann = next(filter(lambda x: x.id == annref.id, anns)) link_one_annotation(proj_obj, ann, ann_map, conn) for ds in ome.datasets: ds_id = ds_map[ds.id] ds_obj = conn.getObject("Dataset", ds_id) anns = ome.structured_annotations - for annref in ds.annotation_ref: + for annref in ds.annotation_refs: ann = next(filter(lambda x: x.id == annref.id, anns)) link_one_annotation(ds_obj, ann, ann_map, conn) for img in ome.images: @@ -410,7 +510,7 @@ def link_annotations(ome: OME, proj_map: dict, ds_map: dict, img_map: dict, img_id = img_map[img.id] img_obj = conn.getObject("Image", img_id) anns = ome.structured_annotations - for annref in img.annotation_ref: + for annref in img.annotation_refs: ann = next(filter(lambda x: x.id == annref.id, anns)) link_one_annotation(img_obj, ann, ann_map, conn) except KeyError: @@ -419,23 +519,23 @@ def link_annotations(ome: OME, proj_map: dict, ds_map: dict, img_map: dict, scr_id = scr_map[scr.id] scr_obj = conn.getObject("Screen", scr_id) anns = ome.structured_annotations - for annref in scr.annotation_ref: + for annref in scr.annotation_refs: ann = next(filter(lambda x: x.id == annref.id, anns)) link_one_annotation(scr_obj, ann, ann_map, conn) for pl in ome.plates: pl_id = pl_map[pl.id] pl_obj = conn.getObject("Plate", pl_id) anns = ome.structured_annotations - for annref in pl.annotation_ref: + for annref in pl.annotation_refs: ann = next(filter(lambda x: x.id == annref.id, anns)) link_one_annotation(pl_obj, ann, ann_map, conn) anns = ome.structured_annotations for well in pl.wells: - if len(well.annotation_ref) > 0: + if len(well.annotation_refs) > 0: row, col = well.row, well.column well_id = ezomero.get_well_id(conn, pl_id, row, col) well_obj = conn.getObject("Well", well_id) - for annref in well.annotation_ref: + for annref in well.annotation_refs: ann = next(filter(lambda x: x.id == annref.id, anns)) link_one_annotation(well_obj, ann, ann_map, conn) return @@ -485,13 +585,13 @@ def rename_plates(pls: List[Plate], pl_map: dict, conn: BlitzGateway): def populate_omero(ome: OME, img_map: dict, conn: BlitzGateway, hash: str, - folder: str, metadata: List[str]): + folder: str, metadata: List[str], merge: bool): plate_map, ome = create_plate_map(ome, img_map, conn) rename_images(ome.images, img_map, conn) rename_plates(ome.plates, plate_map, conn) - proj_map = create_projects(ome.projects, conn) - ds_map = create_datasets(ome.datasets, conn) - screen_map = create_screens(ome.screens, conn) + proj_map = create_or_set_projects(ome.projects, conn, merge) + ds_map = create_or_set_datasets(ome.datasets, ome.projects, conn, merge) + screen_map = create_or_set_screens(ome.screens, conn, merge) ann_map = create_annotations(ome.structured_annotations, conn, hash, folder, metadata) create_rois(ome.rois, ome.images, img_map, conn) diff --git a/src/generate_xml.py b/src/generate_xml.py index 672c2b3..161e84d 100644 --- a/src/generate_xml.py +++ b/src/generate_xml.py @@ -459,7 +459,7 @@ def create_provenance_metadata(conn: BlitzGateway, img_id: int, mmap.append(M(k=_key, value='')) kv, ref = create_kv_and_ref(id=id, namespace=ns, - value=Map(m=mmap)) + value=Map(ms=mmap)) return kv, ref @@ -527,7 +527,7 @@ def parse_files_import(text): def parse_showinf(text, counter_imgs, counter_plates, target): - ome = from_xml(text, parser='xmlschema') + ome = from_xml(text) images = [] plates = [] annotations = [] @@ -552,7 +552,10 @@ def parse_showinf(text, counter_imgs, counter_plates, target): ) annotations.append(an) anref = AnnotationRef(id=an.id) - img.annotation_ref.append(anref) + img.annotation_refs.append(anref) + an, anref = create_prepare_metadata() + annotations.append(an) + img.annotation_refs.append(anref) images.append(img) for plate in ome.plates: pl_id_str = f"Plate:{str(pl_id)}" @@ -568,11 +571,33 @@ def parse_showinf(text, counter_imgs, counter_plates, target): ) annotations.append(an) anref = AnnotationRef(id=an.id) - pl.annotation_ref.append(anref) + pl.annotation_refs.append(anref) plates.append(pl) return images, plates, annotations +def create_prepare_metadata(): + software = "omero-cli-transfer" + version = pkg_resources.get_distribution(software).version + date_time = datetime.now().strftime("%d/%m/%Y, %H:%M:%S") + ns = 'openmicroscopy.org/cli/transfer/prepare' + id = (-1) * uuid4().int + md_dict: Dict[str, Any] = {} + md_dict['software'] = software + md_dict['version'] = version + md_dict['packing_timestamp'] = date_time + mmap = [] + for _key, _value in md_dict.items(): + if _value: + mmap.append(M(k=_key, value=str(_value))) + else: + mmap.append(M(k=_key, value='')) + kv, ref = create_kv_and_ref(id=id, + namespace=ns, + value=Map(ms=mmap)) + return kv, ref + + def create_empty_pixels(image, id): pix_id = f"Pixels:{str(id)}" pixels = Pixels( @@ -630,11 +655,11 @@ def populate_image(obj: ImageI, ome: OME, conn: BlitzGateway, hostname: str, if kv_id not in [i.id for i in ome.structured_annotations]: ome.structured_annotations.append(kv) if ref: - img.annotation_ref.append(ref) + img.annotation_refs.append(ref) filepath_anns, refs = create_filepath_annotations(img_id, conn) for i in range(len(filepath_anns)): ome.structured_annotations.append(filepath_anns[i]) - img.annotation_ref.append(refs[i]) + img.annotation_refs.append(refs[i]) roi_service = conn.getRoiService() rois = roi_service.findByImage(id, None).rois for roi in rois: @@ -668,7 +693,7 @@ def populate_dataset(obj: DatasetI, ome: OME, conn: BlitzGateway, for img in obj.listChildren(): img_obj = conn.getObject('Image', img.getId()) img_ref = populate_image(img_obj, ome, conn, hostname, metadata) - ds.image_ref.append(img_ref) + ds.image_refs.append(img_ref) ds_id = f"Dataset:{str(ds.id)}" if ds_id not in [i.id for i in ome.datasets]: ome.datasets.append(ds) @@ -686,7 +711,7 @@ def populate_project(obj: ProjectI, ome: OME, conn: BlitzGateway, for ds in obj.listChildren(): ds_obj = conn.getObject('Dataset', ds.getId()) ds_ref = populate_dataset(ds_obj, ome, conn, hostname, metadata) - proj.dataset_ref.append(ds_ref) + proj.dataset_refs.append(ds_ref) ome.projects.append(proj) @@ -701,7 +726,7 @@ def populate_screen(obj: ScreenI, ome: OME, conn: BlitzGateway, for pl in obj.listChildren(): pl_obj = conn.getObject('Plate', pl.getId()) pl_ref = populate_plate(pl_obj, ome, conn, hostname, metadata) - scr.plate_ref.append(pl_ref) + scr.plate_refs.append(pl_ref) ome.screens.append(scr) @@ -720,23 +745,23 @@ def populate_plate(obj: PlateI, ome: OME, conn: BlitzGateway, if kv_id not in [i.id for i in ome.structured_annotations]: ome.structured_annotations.append(kv) if ref: - pl.annotation_ref.append(ref) + pl.annotation_refs.append(ref) for well in obj.listChildren(): well_obj = conn.getObject('Well', well.getId()) well_ref = populate_well(well_obj, ome, conn, hostname, metadata) pl.wells.append(well_ref) - last_image_anns = ome.images[-1].annotation_ref + last_image_anns = ome.images[-1].annotation_refs last_image_anns_ids = [i.id for i in last_image_anns] for ann in ome.structured_annotations: if (ann.id in last_image_anns_ids and - type(ann) == CommentAnnotation and + isinstance(ann, CommentAnnotation) and int(ann.id.split(":")[-1]) < 0): plate_path = ann.value filepath_anns, refs = create_filepath_annotations(pl.id, conn, plate_path=plate_path) for i in range(len(filepath_anns)): ome.structured_annotations.append(filepath_anns[i]) - pl.annotation_ref.append(refs[i]) + pl.annotation_refs.append(refs[i]) pl_id = f"Plate:{str(pl.id)}" if pl_id not in [i.id for i in ome.plates]: ome.plates.append(pl) @@ -784,7 +809,7 @@ def add_annotation(obj: Union[Project, Dataset, Image, Plate, Screen, kv, ref = create_kv_and_ref(id=ann.getId(), namespace=ann.getNs(), value=Map( - m=mmap)) + ms=mmap)) if kv.id not in [i.id for i in ome.structured_annotations]: ome.structured_annotations.append(kv) obj.annotation_ref.append(ref) @@ -934,10 +959,10 @@ def generate_columns(ome: OME, ids: dict) -> List[str]: columns.append("comment") anns = ome.structured_annotations for i in ome.images: - for ann_ref in i.annotation_ref: + for ann_ref in i.annotation_refs: ann = next(filter(lambda x: x.id == ann_ref.id, anns)) if isinstance(ann, MapAnnotation): - for v in ann.value.m: + for v in ann.value.ms: if v.k not in columns: columns.append(v.k) return columns @@ -969,7 +994,7 @@ def list_files(ome: OME, ids: dict, top_level: str) -> List[str]: def find_dataset(id: str, ome: OME) -> Union[str, None]: for d in ome.datasets: def lfunc(x): return x.id == id - if any(filter(lfunc, d.image_ref)): + if any(filter(lfunc, d.image_refs)): return d.name return None @@ -1029,7 +1054,7 @@ def generate_lines_and_move(img: Image, ome: OME, ids: dict, folder: str, def get_annotation_vals(cols: List[str], img: Image, ome: OME) -> List[str]: anns = [] - for annref in img.annotation_ref: + for annref in img.annotation_refs: a = next(filter(lambda x: x.id == annref.id, ome.structured_annotations)) anns.append(a) diff --git a/src/omero_cli_transfer.py b/src/omero_cli_transfer.py index 798b0ed..d9524a6 100644 --- a/src/omero_cli_transfer.py +++ b/src/omero_cli_transfer.py @@ -95,6 +95,10 @@ --folder allows the user to point to a previously-unpacked folder rather than a single file. +--merge will use existing Projects, Datasets and Screens if the current user +already owns entities with the same name as ones defined in `transfer.xml`, +effectively merging the "new" unpacked entities with existing ones. + --metadata allows you to specify which transfer metadata will be used from `transfer.xml` as MapAnnotation values to the images. Fields that do not exist on `transfer.xml` will be ignored. Default is `all` (equivalent to @@ -198,6 +202,9 @@ def _configure(self, parser): unpack.add_argument( "--ln_s_import", help="Use in-place import", action="store_true") + unpack.add_argument( + "--merge", help="Use existing entities if possible", + action="store_true") unpack.add_argument( "--folder", help="Pass path to a folder rather than a pack", action="store_true") @@ -384,7 +391,7 @@ def __unpack(self, args): args.output) else: folder = Path(args.filepath) - ome = from_xml(folder / "transfer.xml", parser='xmlschema') + ome = from_xml(folder / "transfer.xml") hash = "imported from folder" print("Generating Image mapping and import filelist...") ome, src_img_map, filelist = self._create_image_map(ome) @@ -397,10 +404,10 @@ def __unpack(self, args): ln_s, args.skip, self.gateway) self._delete_all_rois(dest_img_map, self.gateway) print("Matching source and destination images...") - img_map = self._make_image_map(src_img_map, dest_img_map) + img_map = self._make_image_map(src_img_map, dest_img_map, self.gateway) print("Creating and linking OMERO objects...") populate_omero(ome, img_map, self.gateway, - hash, folder, self.metadata) + hash, folder, self.metadata, args.merge) return def _load_from_pack(self, filepath: str, output: Optional[str] = None @@ -433,7 +440,7 @@ def _load_from_pack(self, filepath: str, output: Optional[str] = None raise ValueError("File is not a zip or tar file") else: raise FileNotFoundError("filepath is not a zip file") - ome = from_xml(folder / "transfer.xml", parser='xmlschema') + ome = from_xml(folder / "transfer.xml") return hash, ome, folder def _create_image_map(self, ome: OME @@ -458,9 +465,9 @@ def _create_image_map(self, ome: OME filelist.append(ann.value) newome.structured_annotations.remove(ann) for i in newome.images: - for ref in i.annotation_ref: + for ref in i.annotation_refs: if ref.id in map_ref_ids: - i.annotation_ref.remove(ref) + i.annotation_refs.remove(ref) filelist = list(set(filelist)) img_map = DefaultDict(list, {x: sorted(img_map[x]) for x in img_map.keys()}) @@ -532,7 +539,8 @@ def _get_image_ids(self, file_path: str, conn: BlitzGateway) -> List[str]: image_ids.append(img_id) return image_ids - def _make_image_map(self, source_map: dict, dest_map: dict) -> dict: + def _make_image_map(self, source_map: dict, dest_map: dict, + conn: Optional[BlitzGateway] = None) -> dict: # using both source and destination file-to-image-id maps, # map image IDs between source and destination src_dict = DefaultDict(list) @@ -555,10 +563,24 @@ def _make_image_map(self, source_map: dict, dest_map: dict) -> dict: src_v = src_dict[src_k] if src_k in dest_dict.keys(): dest_v = dest_dict[src_k] - if len(src_v) == len(dest_v): + clean_dest = [] + if not conn: + clean_dest = dest_v + else: + for i in dest_v: + img_obj = conn.getObject("Image", i) + anns = 0 + for j in img_obj.listAnnotations(): + ns = j.getNs() + if ns.startswith( + "openmicroscopy.org/cli/transfer"): + anns += 1 + if not anns: + clean_dest.append(i) + if len(src_v) == len(clean_dest): for count in range(len(src_v)): map_key = f"Image:{src_v[count]}" - imgmap[map_key] = dest_v[count] + imgmap[map_key] = clean_dest[count] return imgmap def __prepare(self, args): diff --git a/test/data/simple_screen.zip b/test/data/simple_screen.zip index 01530e7d10d64c02380afc2f11ee237aa58a4106..52d41dad5b013641182d09aaf39dcd68b88b434d 100644 GIT binary patch delta 14658 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 delta 13988 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 diff --git a/test/integration/test_prepare.py b/test/integration/test_prepare.py index 21c8ae4..c3eb80e 100644 --- a/test/integration/test_prepare.py +++ b/test/integration/test_prepare.py @@ -168,8 +168,8 @@ def run_asserts_edited(self): elif img_name == "edited image name": kvs = ezomero.get_map_annotation_ids(self.gw, "Image", img.getId()) - assert len(kvs) == 1 - kv = ezomero.get_map_annotation(self.gw, kvs[0]) + assert len(kvs) == 2 + kv = ezomero.get_map_annotation(self.gw, kvs[-1]) assert len(kv) == 2 assert kv['key1'] == "value1" assert kv['key2'] == "2" @@ -179,7 +179,7 @@ def run_asserts_edited(self): shapes = ezomero.get_shape_ids(self.gw, rois[0]) assert len(shapes) == 1 shape = ezomero.get_shape(self.gw, shapes[0]) - assert type(shape) == ezomero.rois.Rectangle + assert type(shape) is ezomero.rois.Rectangle assert shape.x == 1 assert shape.y == 2 assert shape.width == 3 @@ -195,12 +195,12 @@ def run_asserts_edited(self): assert len(pl_ids) == 1 def edit_xml(self, filename): - ome = from_xml(filename, parser='xmlschema') + ome = from_xml(filename) new_proj = Project(id="Project:1", name="edited project") new_ds = Dataset(id="Dataset:1", name="edited dataset") newtag1 = TagAnnotation(id="Annotation:1", value="tag for proj") newtag2 = TagAnnotation(id="Annotation:2", value="tag for img") - new_proj.annotation_ref.append(AnnotationRef(id=newtag1.id)) + new_proj.annotation_refs.append(AnnotationRef(id=newtag1.id)) md_dict = {"key1": "value1", "key2": 2} mmap = [] for _key, _value in md_dict.items(): @@ -208,31 +208,31 @@ def edit_xml(self, filename): mmap.append(M(k=_key, value=str(_value))) else: mmap.append(M(k=_key, value='')) - mapann = MapAnnotation(id="Annotation:3", value=Map(m=mmap)) + mapann = MapAnnotation(id="Annotation:3", value=Map(ms=mmap)) rect = Rectangle(id="Shape:1", x=1, y=2, width=3, height=4) roi = ROI(id="ROI:1", union=[rect]) ome.rois.append(roi) ome.structured_annotations.extend([newtag1, newtag2, mapann]) for img in ome.images: if img.name == "test_pyramid.tiff": - img.annotation_ref.append(AnnotationRef(id=newtag2.id)) + img.annotation_refs.append(AnnotationRef(id=newtag2.id)) imref = ImageRef(id=img.id) - new_ds.image_ref.append(imref) + new_ds.image_refs.append(imref) elif img.name == "vsi-ets-test-jpg2k.vsi [001 C405, C488]": img.name = "edited image name" - img.annotation_ref.append(AnnotationRef(id=mapann.id)) + img.annotation_refs.append(AnnotationRef(id=mapann.id)) imref = ImageRef(id=img.id) - new_ds.image_ref.append(imref) + new_ds.image_refs.append(imref) elif img.name == "vsi-ets-test-jpg2k.vsi [macro image]": - img.roi_ref.append(ROIRef(id=roi.id)) + img.roi_refs.append(ROIRef(id=roi.id)) imref = ImageRef(id=img.id) - new_ds.image_ref.append(imref) + new_ds.image_refs.append(imref) dsref = DatasetRef(id=new_ds.id) - new_proj.dataset_ref.append(dsref) + new_proj.dataset_refs.append(dsref) ome.projects.append(new_proj) ome.datasets.append(new_ds) new_scr = Screen(id="Screen:1", name="edited screen") - new_scr.plate_ref.append(PlateRef(id=ome.plates[0].id)) + new_scr.plate_refs.append(PlateRef(id=ome.plates[0].id)) ome.screens.append(new_scr) with open(filename, 'w') as fp: print(to_xml(ome), file=fp) diff --git a/test/integration/test_transfer.py b/test/integration/test_transfer.py index 51f12cb..1e047db 100644 --- a/test/integration/test_transfer.py +++ b/test/integration/test_transfer.py @@ -317,6 +317,40 @@ def test_unpack_skip(self): assert img.getName() == 'combined_result.tiff' self.delete_all() + def test_unpack_merge(self): + proj_args = self.args + ["unpack", + "test/data/valid_single_project.zip"] + self.cli.invoke(proj_args, strict=True) + proj_args += ['--merge'] + self.cli.invoke(proj_args, strict=True) + pj_ids = ezomero.get_project_ids(self.gw) + assert len(pj_ids) == 1 + ds_ids = ezomero.get_dataset_ids(self.gw, pj_ids[0]) + assert len(ds_ids) == 2 + ds_args = self.args + ['unpack', "test/data/valid_single_dataset.zip"] + print(ds_args) + self.cli.invoke(ds_args, strict=True) + orphan = ezomero.get_dataset_ids(self.gw) + assert len(orphan) == 1 + ds_args += ['--merge'] + self.cli.invoke(ds_args, strict=True) + orphan = ezomero.get_dataset_ids(self.gw) + assert len(orphan) == 1 + scr_args = self.args + ['unpack', "test/data/simple_screen.zip"] + self.cli.invoke(scr_args, strict=True) + scr_args += ['--merge'] + self.cli.invoke(scr_args, strict=True) + scr_ids = [] + for screen in self.gw.getObjects("Screen"): + scr_ids.append(screen.getId()) + assert len(scr_ids) == 1 + pl_ids = [] + screen = self.gw.getObject("Screen", scr_ids[0]) + for plate in screen.listChildren(): + pl_ids.append(plate.getId()) + assert len(pl_ids) == 4 + self.delete_all() + @pytest.mark.parametrize('target_name', sorted(SUPPORTED)) def test_pack_unpack(self, target_name, tmpdir): if target_name == "datasetid" or target_name == "projectid" or\ diff --git a/test/unit/test_set.py b/test/unit/test_set.py index 282b54e..726ec21 100644 --- a/test/unit/test_set.py +++ b/test/unit/test_set.py @@ -88,7 +88,7 @@ def test_non_existing_file(self): 'data/output_folder') def test_src_img_map(self): - ome = from_xml('test/data/transfer.xml', parser='xmlschema') + ome = from_xml('test/data/transfer.xml') _, src_img_map, filelist = self.transfer._create_image_map(ome) correct_map = {"root_0/2022-01/14/" "18-30-55.264/combined_result.tiff": [1678, 1679]}