├── .gitattributes ├── MANIFEST.in ├── .isort.cfg ├── .gitignore ├── CHANGES.txt ├── .github └── workflows │ ├── publish_pypi.yml │ └── omero_plugin.yml ├── .pre-commit-config.yaml ├── src ├── omero │ └── plugins │ │ └── rdf.py └── omero_rdf │ └── __init__.py ├── test ├── unit │ └── test_validate_extensions.py └── integration │ └── clitest │ └── test_rdf.py ├── README.rst ├── pyproject.toml └── LICENSE.txt /.gitattributes: -------------------------------------------------------------------------------- 1 | src/omero_rdf/_version.py export-subst 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.rst 3 | prune dist 4 | prune build 5 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | known_third_party = omero,omero_marshal,omero_rdf,pyld,rdflib,rdflib_pyld_compat 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | omero-rdf-* 4 | *.egg* 5 | .cache 6 | *.DS_Store 7 | .*un~ 8 | *.pyc 9 | .omero 10 | .*.swp 11 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | ======= 2 | Changes 3 | ======= 4 | 5 | 0.1 - 2022-07-26 6 | ================== 7 | * Initial release. Based on https://github.com/ome/cookiecutter-omero-cli-plugin 8 | -------------------------------------------------------------------------------- /.github/workflows/publish_pypi.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PyPI 3 | on: push 4 | 5 | jobs: 6 | build-n-publish: 7 | name: Build and publish Python distribution to PyPI 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-python@v5 12 | with: 13 | python-version: '3.9' 14 | - name: Build a binary wheel and a source tarball 15 | run: | 16 | python -mpip install setuptools build wheel 17 | python -m build 18 | - name: Publish distribution to PyPI 19 | if: startsWith(github.ref, 'refs/tags') 20 | uses: pypa/gh-action-pypi-publish@v1.8.14 21 | with: 22 | password: ${{ secrets.PYPI_PASSWORD }} 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/astral-sh/ruff-pre-commit 4 | # Ruff version. 5 | rev: v0.14.6 6 | hooks: 7 | # Run the linter. 8 | - id: ruff-check 9 | # Run the formatter. 10 | - id: ruff-format 11 | 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v4.0.1 14 | hooks: 15 | - id: fix-encoding-pragma 16 | args: 17 | - --remove 18 | - id: check-case-conflict 19 | - id: check-symlinks 20 | - id: pretty-format-json 21 | args: 22 | - --autofix 23 | 24 | - repo: https://github.com/pre-commit/mirrors-mypy 25 | rev: v1.18.2 26 | hooks: 27 | - id: mypy 28 | language_version: python3 29 | 30 | - repo: https://github.com/adrienverge/yamllint.git 31 | rev: v1.26.3 32 | hooks: 33 | - id: yamllint 34 | # args: [--config-data=relaxed] 35 | -------------------------------------------------------------------------------- /src/omero/plugins/rdf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # 4 | # Copyright (c) 2022 German BioImaging 5 | # All rights reserved. 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | 22 | import sys 23 | 24 | from omero.cli import CLI 25 | from omero_rdf import HELP, RdfControl 26 | 27 | try: 28 | register("rdf", RdfControl, HELP) # type: ignore 29 | except NameError: 30 | if __name__ == "__main__": 31 | cli = CLI() 32 | cli.register("rdf", RdfControl, HELP) 33 | cli.invoke(sys.argv[1:]) 34 | -------------------------------------------------------------------------------- /test/unit/test_validate_extensions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from argparse import Namespace 3 | 4 | from omero_rdf import RdfControl 5 | 6 | 7 | class DummyCtx: 8 | def out(self, msg): 9 | pass 10 | 11 | def err(self, msg): 12 | pass 13 | 14 | 15 | def test_warns_for_nt_when_turtle(caplog): 16 | ctrl = RdfControl() 17 | ctrl.ctx = DummyCtx() 18 | args = Namespace(file="out.nt", format="turtle", pretty=False, yes=True) 19 | 20 | with caplog.at_level(logging.WARNING): 21 | ctrl._validate_extensions(args) 22 | 23 | assert ".nt' does not match format 'turtle'" in caplog.text 24 | 25 | 26 | def test_warns_when_pretty_overrides_extension(caplog): 27 | ctrl = RdfControl() 28 | ctrl.ctx = DummyCtx() 29 | args = Namespace(file="out.nt", format="ntriples", pretty=True, yes=True) 30 | 31 | with caplog.at_level(logging.WARNING): 32 | ctrl._validate_extensions(args) 33 | 34 | assert "--pretty sets output format to Turtle" in caplog.text 35 | 36 | 37 | def test_allows_matching_extension(caplog): 38 | ctrl = RdfControl() 39 | ctrl.ctx = DummyCtx() 40 | args = Namespace(file="out.nt", format="ntriples", pretty=False, yes=True) 41 | 42 | with caplog.at_level(logging.WARNING): 43 | ctrl._validate_extensions(args) 44 | 45 | assert not caplog.records 46 | -------------------------------------------------------------------------------- /.github/workflows/omero_plugin.yml: -------------------------------------------------------------------------------- 1 | # Install and test and OMERO plugin e.g. a Web app, a CLI plugin or a library 2 | # 3 | # This workflow will install omero-test-infra, start an OMERO environment 4 | # including database, server and web deployment, configure the OMERO plugin 5 | # and run integration tests. 6 | # 7 | # 1. Set up the stage variable depending on the plugin. Supported stages 8 | # are: app, cli, scripts, lib, srv 9 | # 10 | # 2. Adjust the cron schedule as necessary 11 | 12 | name: OMERO 13 | 14 | on: 15 | push: 16 | pull_request: 17 | schedule: 18 | - cron: "0 0 * * 0" 19 | 20 | jobs: 21 | unit_test: 22 | name: Run unit tests (py${{ matrix.python-version }}) 23 | runs-on: ubuntu-latest 24 | strategy: 25 | matrix: 26 | python-version: ["3.10", "3.11", "3.12"] 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Set up Python ${{ matrix.python-version}} 31 | uses: actions/setup-python@v6 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | 35 | - run: pip install ".[tests]" 36 | - run: pytest test/unit 37 | 38 | lint: 39 | name: Run basic linting via pre-commit 40 | runs-on: ubuntu-latest 41 | strategy: 42 | matrix: 43 | python-version: ["3.10", "3.11", "3.12"] 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: Set up Python ${{ matrix.python-version}} 48 | uses: actions/setup-python@v6 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | 52 | - run: pip install pre-commit 53 | - run: pre-commit run --all-files 54 | 55 | test: 56 | name: Run integration tests against OMERO 57 | runs-on: ubuntu-latest 58 | env: 59 | STAGE: cli 60 | PLUGIN: rdf 61 | steps: 62 | - uses: actions/checkout@v4 63 | - name: Checkout omero-test-infra 64 | uses: actions/checkout@master 65 | with: 66 | repository: ome/omero-test-infra 67 | path: .omero 68 | ref: ${{ secrets.OMERO_TEST_INFRA_REF }} 69 | - name: Build and run OMERO tests 70 | run: .omero/docker $STAGE 71 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://github.com/German-BioImaging/omero-rdf/workflows/OMERO/badge.svg 2 | :target: https://github.com/german-bioimaging/omero-rdf/actions 3 | 4 | .. image:: https://badge.fury.io/py/omero-rdf.svg 5 | :target: https://badge.fury.io/py/omero-rdf 6 | 7 | omero-rdf 8 | ========= 9 | 10 | A plugin for exporting RDF from OMERO 11 | 12 | 13 | Requirements 14 | ============ 15 | 16 | * OMERO 5.6.0 or newer 17 | * Python 3.10, 3.11, or 3.12 (only versions with ``zeroc-ice`` binary wheels are supported; newer versions will be added when wheels are available) 18 | 19 | 20 | Installing from PyPI 21 | ==================== 22 | 23 | This section assumes that an `OMERO.py `_ is already installed. 24 | 25 | Install the command-line tool using `pip `_: 26 | 27 | :: 28 | 29 | $ pip install -U omero-rdf 30 | 31 | 32 | Developer guidelines 33 | ==================== 34 | 35 | Using `uv` (recommended): 36 | 37 | 1. Fork/clone the repository (e.g. ``gh repo fork https://github.com/German-BioImaging/omero-rdf``). 38 | 2. Create a virtualenv and activate it: 39 | 40 | :: 41 | 42 | uv venv .venv 43 | source .venv/bin/activate 44 | 45 | (or prefix commands with ``uv run`` instead of activating). 46 | 47 | 3. Install in editable mode with test dependencies (pulls the correct platform-specific ``zeroc-ice`` wheel): 48 | 49 | :: 50 | 51 | uv pip install -e ".[tests,dev]" 52 | 53 | 4. Run the test suite: 54 | 55 | :: 56 | 57 | pytest 58 | 59 | 5. Lint and format: 60 | 61 | :: 62 | 63 | ruff check 64 | ruff format --check 65 | ruff format 66 | 67 | To run pre-commit hooks: 68 | 69 | :: 70 | 71 | uv tool install pre-commit 72 | pre-commit run --all-files 73 | 74 | Quick check against IDR 75 | ----------------------- 76 | 77 | Assuming you have the `uv` environment active (`source .venv/bin/activate`), use 78 | the public IDR server to confirm the CLI works (public/public credentials): 79 | 80 | 1. Log in once to create a session: 81 | 82 | :: 83 | 84 | omero login -s idr.openmicroscopy.org -u public -w public 85 | 86 | 2. Export RDF for a project on IDR (2902) and inspect the first triples: 87 | 88 | :: 89 | 90 | omero rdf -F=turtle Project:2902 -S=flat | head -n 10 91 | 92 | Release process 93 | --------------- 94 | 95 | This repository uses `versioneer `_ 96 | to manage version numbers. A tag prefixed with `v` will be detected by 97 | the library and used as the current version at runtime. 98 | 99 | Remember to ``git push`` all commits and tags. 100 | 101 | Funding 102 | ------- 103 | 104 | Funded by the `Delta Tissue `_ 105 | Program of `Wellcome Leap `_. 106 | 107 | License 108 | ------- 109 | 110 | This project, similar to many Open Microscopy Environment (OME) projects, is 111 | licensed under the terms of the GNU General Public License (GPL) v2 or later. 112 | 113 | Copyright 114 | --------- 115 | 116 | 2022-2024, German BioImaging 117 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=60", 4 | "setuptools-scm>=8.0", 5 | ] 6 | 7 | [tool.setuptools_scm] 8 | 9 | [tools.setuptools.dynamic] 10 | version = {attr = "omero_rdf.__version__"} 11 | 12 | [project] 13 | name = "omero-rdf" 14 | readme = "README.rst" 15 | dynamic = ["version"] 16 | description="A plugin for exporting RDF from OMERO" 17 | 18 | requires-python = ">=3.10,<3.13" 19 | 20 | dependencies = [ 21 | "omero-py>=5.8", 22 | "importlib-metadata", 23 | "future", 24 | "rdflib", 25 | "pyld", 26 | "rdflib-pyld-compat", 27 | "omero-marshal", 28 | "packaging", 29 | 'zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/download/20240202/zeroc_ice-3.6.5-cp310-cp310-manylinux_2_28_x86_64.whl ; platform_system=="Linux" and python_version=="3.10"', 30 | 'zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/download/20240202/zeroc_ice-3.6.5-cp311-cp311-manylinux_2_28_x86_64.whl ; platform_system=="Linux" and python_version=="3.11"', 31 | 'zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-linux-x86_64/releases/download/20240202/zeroc_ice-3.6.5-cp312-cp312-manylinux_2_28_x86_64.whl ; platform_system=="Linux" and python_version=="3.12"', 32 | 'zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-macos-universal2/releases/download/20240131/zeroc_ice-3.6.5-cp310-cp310-macosx_11_0_universal2.whl ; platform_system!="Windows" and platform_system!="Linux" and python_version=="3.10"', 33 | 'zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-macos-universal2/releases/download/20240131/zeroc_ice-3.6.5-cp311-cp311-macosx_11_0_universal2.whl ; platform_system!="Windows" and platform_system!="Linux" and python_version=="3.11"', 34 | 'zeroc-ice @ https://github.com/glencoesoftware/zeroc-ice-py-macos-universal2/releases/download/20240131/zeroc_ice-3.6.5-cp312-cp312-macosx_11_0_universal2.whl ; platform_system!="Windows" and platform_system!="Linux" and python_version=="3.12"' 35 | ] 36 | 37 | classifiers = [ 38 | "Development Status :: 2 - Pre-Alpha", 39 | "Environment :: Plugins", 40 | "Intended Audience :: Developers", 41 | "Intended Audience :: End Users/Desktop", 42 | "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", 43 | "Natural Language :: English", 44 | "Operating System :: OS Independent", 45 | "Programming Language :: Python :: 3", 46 | "Programming Language :: Python :: 3.10", 47 | "Programming Language :: Python :: 3.11", 48 | "Programming Language :: Python :: 3.12", 49 | "Topic :: Software Development :: Libraries :: Python Modules", 50 | ] 51 | 52 | keywords = [ 53 | "OMERO.cli", "plugin", 54 | ] 55 | 56 | authors = [ 57 | {name = "The Open Microscopy Team"}, 58 | ] 59 | 60 | [project.optional-dependencies] 61 | tests = [ 62 | "pytest", 63 | "restview", 64 | "mox3", 65 | ] 66 | dev = [ 67 | "ruff", 68 | ] 69 | 70 | [project.urls] 71 | Repository = "https://github.com/German-BioImaging/omero-rdf" 72 | Changelog = "https://github.com/German-BioImaging/omero-rdf/blob/master/CHANGES.txt" 73 | 74 | # Subplugins should register there entrypoints like this: 75 | # [project.entry-points."omero_rdf.annotation_handler"] 76 | # idr_annotations = "omero_rdf_wikidata.idr_annotations:IDRAnnotationHandler" 77 | 78 | [tool.setuptools] 79 | package-dir = {""= "src"} 80 | 81 | [tools.mypy] 82 | check_untyped_defs = true 83 | disallow_incomplete_defs = true 84 | disallow_untyped_defs = true 85 | no_implicit_optional = true 86 | disallow_any_generics = false 87 | ignore_missing_imports = true 88 | -------------------------------------------------------------------------------- /test/integration/clitest/test_rdf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # 4 | # Copyright (c) 2022 German BioImaging 5 | # All rights reserved. 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | 22 | from omero.testlib.cli import CLITest 23 | from omero_rdf import RdfControl 24 | from omero.model import LabelI, RoiI, CommentAnnotationI 25 | from omero.rtypes import rstring 26 | 27 | from rdflib import Graph, Literal, Namespace, RDF 28 | from rdflib.namespace import DCTERMS 29 | 30 | 31 | class TestRdf(CLITest): 32 | def setup_method(self, method): 33 | super().setup_method(method) 34 | self.cli.register("rdf", RdfControl, "TEST") 35 | self.args += ["rdf"] 36 | 37 | def rdf(self, capfd): 38 | self.cli.invoke(self.args, strict=True) 39 | return capfd.readouterr()[0] 40 | 41 | def test_rdf(self, capfd): 42 | name = self.uuid() 43 | object_type = "Project" 44 | oid = self.create_object(object_type, name=f"{name}") 45 | obj_arg = f"{object_type}:{oid}" 46 | self.args += [obj_arg] 47 | out = self.rdf(capfd) 48 | assert out 49 | 50 | def test_rois(self, capfd): 51 | update = self.client.sf.getUpdateService() 52 | 53 | # Setup a test image with a roi 54 | pix = self.create_pixels() 55 | img = pix.image 56 | roi_ann = CommentAnnotationI() 57 | roi_ann.setTextValue(rstring("my roi annotation")) 58 | roi = RoiI() 59 | roi.setDescription(rstring("please check me")) 60 | roi.linkAnnotation(roi_ann) 61 | label_ann = CommentAnnotationI() 62 | label_ann.setTextValue(rstring("my label annotation")) 63 | label = LabelI() 64 | label.setTextValue(rstring("this is the label")) 65 | label.linkAnnotation(label_ann) 66 | roi.addShape(label) 67 | img.addRoi(roi) 68 | img = update.saveAndReturnObject(img) 69 | 70 | # Export the test image 71 | object_type = "Image" 72 | obj_arg = f"{object_type}:{img.id.val}" 73 | self.args += ["-Fturtle", obj_arg] 74 | out = self.rdf(capfd) 75 | 76 | # Check that it contains the roi linked to the image (issue#42) 77 | g = Graph() 78 | g.parse(data=out, format="ttl") 79 | 80 | xml = Namespace("http://www.openmicroscopy.org/Schemas/OME/2016-06#") 81 | self.assert_contained_type(g, xml.ROI, xml.Pixels) 82 | for x in ( 83 | "my roi annotation", 84 | "please check me", 85 | "my label annotation", 86 | "this is the label", 87 | ): 88 | self.assert_string_found(g, x) 89 | 90 | def assert_contained_type(self, g, child_type, parent_type): 91 | found = False 92 | for s, p, o in g.triples((None, DCTERMS.isPartOf, None)): 93 | if (s, RDF.type, child_type) in g and (o, RDF.type, parent_type) in g: 94 | found = True 95 | break 96 | assert found, f"no link between {parent_type} and {child_type}:" + g.serialize() 97 | 98 | def assert_string_found(self, g, search_string): 99 | found = False 100 | search_literal = Literal(search_string) 101 | for s, p, o in g.triples((None, None, search_literal)): 102 | found = True 103 | break 104 | assert found, f"string not found '{search_string}':\n" + g.serialize() 105 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /src/omero_rdf/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # 4 | # Copyright (c) 2022 German BioImaging 5 | # All rights reserved. 6 | # 7 | # This program is free software; you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation; either version 2 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | 22 | import contextlib 23 | import gzip 24 | import sys 25 | import json 26 | import logging 27 | from argparse import Namespace 28 | from functools import wraps 29 | from typing import Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union 30 | 31 | from importlib.metadata import entry_points 32 | from omero.cli import BaseControl, Parser, ProxyStringType 33 | from omero.gateway import BlitzGateway, BlitzObjectWrapper 34 | from omero.model import Dataset, Image, IObject, Plate, Project, Screen 35 | from omero.sys import ParametersI 36 | from omero_marshal import get_encoder 37 | from pyld import jsonld 38 | from rdflib import BNode, Graph, Literal, URIRef 39 | from rdflib.namespace import DCTERMS, RDF 40 | from rdflib_pyld_compat import pyld_jsonld_from_rdflib_graph 41 | 42 | HELP = """A plugin for exporting RDF from OMERO 43 | 44 | omero-rdf creates a stream of RDF triples from the starting object that 45 | it is given. This may be one of: Image, Dataset, Project, Plate, and Screen. 46 | 47 | Examples: 48 | 49 | omero rdf Image:123 # Streams each triple found in N-Triples format 50 | 51 | omero rdf -F=jsonld Image:123 # Collects all triples and prints formatted output 52 | omero rdf -S=flat Project:123 # Do not recurse into containers ("flat-strategy") 53 | omero rdf --trim-whitespace ... # Strip leading and trailing whitespace from text 54 | omero rdf --first-handler-wins ... # First mapping wins; others will be ignored 55 | 56 | omero rdf --file - ... # Write RDF triples to stdout 57 | omero rdf --file output.nt ... # Write RDF triples to the specified file 58 | omero rdf --file output.nt.gz # Write RDF triples to the specified file, gzipping 59 | 60 | """ 61 | 62 | # TYPE DEFINITIONS 63 | 64 | Data = Dict[str, Any] 65 | Subj = Union[BNode, URIRef] 66 | Obj = Union[BNode, Literal, URIRef] 67 | Triple = Tuple[Subj, URIRef, Obj] 68 | Handlers = List[Callable[[URIRef, URIRef, Data], Generator[Triple, None, bool]]] 69 | 70 | 71 | @contextlib.contextmanager 72 | def open_with_default(filename=None, filehandle=None): 73 | """ 74 | Open a file for writing if given and close on completion. 75 | 76 | No closing will happen if the file name is "-" since stdout will be used. 77 | If no filehandle is given, stdout will also be used. 78 | Otherwise return the given filehandle will be used. 79 | """ 80 | close = False 81 | if filename: 82 | if filename == "-": 83 | fh = sys.stdout 84 | else: 85 | if filename.endswith(".gz"): 86 | fh = gzip.open(filename, "wt") 87 | else: 88 | fh = open(filename, "w") 89 | close = True 90 | else: 91 | if filehandle is None: 92 | filehandle = sys.stdout 93 | fh = filehandle 94 | 95 | try: 96 | yield fh 97 | finally: 98 | if close: 99 | fh.close() 100 | 101 | 102 | def gateway_required(func: Callable) -> Callable: # type: ignore 103 | """ 104 | Decorator which initializes a client (self.client), 105 | a BlitzGateway (self.gateway), and makes sure that 106 | all services of the Blitzgateway are closed again. 107 | 108 | FIXME: copied from omero-cli-render. move upstream 109 | """ 110 | 111 | @wraps(func) 112 | def _wrapper(self, *args: Any, **kwargs: Any): # type: ignore 113 | self.client = self.ctx.conn(*args) 114 | self.gateway = BlitzGateway(client_obj=self.client) 115 | 116 | try: 117 | return func(self, *args, **kwargs) 118 | finally: 119 | if self.gateway is not None: 120 | self.gateway.close(hard=False) 121 | self.gateway = None 122 | self.client = None 123 | 124 | return _wrapper 125 | 126 | 127 | class Format: 128 | """ 129 | Output mechanisms split into two types: streaming and non-streaming. 130 | Critical methods include: 131 | 132 | - streaming: 133 | - serialize_triple: return a representation of the triple 134 | - non-streaming: 135 | - add: store a triple for later serialization 136 | - serialize_graph: return a representation of the graph 137 | 138 | See the subclasses for more information. 139 | """ 140 | 141 | def __init__(self): 142 | self.streaming = None 143 | 144 | def __str__(self): 145 | return self.__class__.__name__[:-6].lower() 146 | 147 | def __lt__(self, other): 148 | return str(self) < str(other) 149 | 150 | def add(self, triple): 151 | raise NotImplementedError() 152 | 153 | def serialize_triple(self, triple): 154 | raise NotImplementedError() 155 | 156 | def serialize_graph(self): 157 | raise NotImplementedError() 158 | 159 | 160 | class StreamingFormat(Format): 161 | def __init__(self): 162 | super().__init__() 163 | self.streaming = True 164 | 165 | def add(self, triple): 166 | raise RuntimeError("adding not supported during streaming") 167 | 168 | def serialize_graph(self): 169 | raise RuntimeError("graph serialization not supported during streaming") 170 | 171 | 172 | class NTriplesFormat(StreamingFormat): 173 | def __init__(self): 174 | super().__init__() 175 | 176 | def serialize_triple(self, triple): 177 | s, p, o = triple 178 | escaped = o.n3().encode("unicode_escape").decode("utf-8") 179 | return f"""{s.n3()}\t{p.n3()}\t{escaped} .""" 180 | 181 | 182 | class NonStreamingFormat(Format): 183 | def __init__(self): 184 | super().__init__() 185 | self.streaming = False 186 | self.graph = Graph() 187 | self.graph.bind("wd", "http://www.wikidata.org/prop/direct/") 188 | self.graph.bind("ome", "http://www.openmicroscopy.org/rdf/2016-06/ome_core/") 189 | self.graph.bind( 190 | "ome-xml", "http://www.openmicroscopy.org/Schemas/OME/2016-06#" 191 | ) # FIXME 192 | self.graph.bind("omero", "http://www.openmicroscopy.org/TBD/omero/") 193 | # self.graph.bind("xs", XMLSCHEMA) 194 | # TODO: Allow handlers to register namespaces 195 | 196 | def add(self, triple): 197 | self.graph.add(triple) 198 | 199 | def serialize_triple(self, triple): 200 | raise RuntimeError("triple serialization not supported during streaming") 201 | 202 | 203 | class TurtleFormat(NonStreamingFormat): 204 | def __init__(self): 205 | super().__init__() 206 | 207 | def serialize_graph(self) -> None: 208 | return self.graph.serialize() 209 | 210 | 211 | class JSONLDFormat(NonStreamingFormat): 212 | def __init__(self): 213 | super().__init__() 214 | 215 | def context(self): 216 | # TODO: allow handlers to add to this 217 | return { 218 | "@wd": "http://www.wikidata.org/prop/direct/", 219 | "@ome": "http://www.openmicroscopy.org/rdf/2016-06/ome_core/", 220 | "@ome-xml": "http://www.openmicroscopy.org/Schemas/OME/2016-06#", 221 | "@omero": "http://www.openmicroscopy.org/TBD/omero/", 222 | "@idr": "https://idr.openmicroscopy.org/", 223 | } 224 | 225 | def serialize_graph(self) -> None: 226 | return self.graph.serialize( 227 | format="json-ld", 228 | context=self.context(), 229 | indent=4, 230 | ) 231 | 232 | 233 | class ROCrateFormat(JSONLDFormat): 234 | def __init__(self): 235 | super().__init__() 236 | 237 | def context(self): 238 | ctx = super().context() 239 | ctx["@rocrate"] = "https://w3id.org/ro/crate/1.1/context" 240 | return ctx 241 | 242 | def serialize_graph(self): 243 | ctx = self.context() 244 | j = pyld_jsonld_from_rdflib_graph(self.graph) 245 | j = jsonld.flatten(j, ctx) 246 | j = jsonld.compact(j, ctx) 247 | if "@graph" not in j: 248 | raise Exception(j) 249 | j["@graph"][0:0] = [ 250 | { 251 | "@id": "./", 252 | "@type": "Dataset", 253 | "rocrate:license": "https://creativecommons.org/licenses/by/4.0/", 254 | }, 255 | { 256 | "@id": "ro-crate-metadata.json", 257 | "@type": "CreativeWork", 258 | "rocrate:conformsTo": {"@id": "https://w3id.org/ro/crate/1.1"}, 259 | "rocrate:about": {"@id": "./"}, 260 | }, 261 | ] 262 | return json.dumps(j, indent=4) 263 | 264 | 265 | def format_mapping(): 266 | return { 267 | "ntriples": NTriplesFormat(), 268 | "jsonld": JSONLDFormat(), 269 | "turtle": TurtleFormat(), 270 | "ro-crate": ROCrateFormat(), 271 | } 272 | 273 | 274 | def format_list(): 275 | return format_mapping().keys() 276 | 277 | 278 | class Handler: 279 | """ 280 | Instances are used to generate triples. 281 | 282 | Methods which can be subclassed: 283 | TBD 284 | 285 | """ 286 | 287 | OME = "http://www.openmicroscopy.org/rdf/2016-06/ome_core/" 288 | OMERO = "http://www.openmicroscopy.org/TBD/omero/" 289 | 290 | def __init__( 291 | self, 292 | gateway: BlitzGateway, 293 | formatter: Format, 294 | trim_whitespace=False, 295 | use_ellide=False, 296 | first_handler_wins=False, 297 | descent="recursive", 298 | filehandle=sys.stdout, 299 | ) -> None: 300 | self.gateway = gateway 301 | self.cache: Set[URIRef] = set() 302 | self.bnode = 0 303 | self.formatter = formatter 304 | self.trim_whitespace = trim_whitespace 305 | self.use_ellide = use_ellide 306 | self.first_handler_wins = first_handler_wins 307 | self.descent = descent 308 | self._descent_level = 0 309 | self.annotation_handlers = self.load_handlers() 310 | self.info = self.load_server() 311 | self.filehandle = filehandle 312 | 313 | def skip_descent(self): 314 | return self.descent != "recursive" and self._descent_level > 0 315 | 316 | def descending(self): 317 | self._descent_level += 1 318 | 319 | def load_handlers(self) -> Handlers: 320 | annotation_handlers: Handlers = [] 321 | eps = entry_points() 322 | 323 | # Extensions to OMERO rdf can provide custom annotation handling. 324 | # They can be accessed through entry points. 325 | # See https://github.com/German-BioImaging/omero-rdf-wikidata/ 326 | 327 | # Python 3.10 deprecated eps.get(), changing to eps.select() 328 | for ep in eps.select(group="omero_rdf.annotation_handler"): 329 | ah_loader = ep.load() 330 | annotation_handlers.append(ah_loader(self)) 331 | return annotation_handlers 332 | 333 | def load_server(self) -> Any: 334 | # Attempt to auto-detect server 335 | comm = self.gateway.c.getCommunicator() 336 | return self.gateway.c.getRouter(comm).ice_getEndpoints()[0].getInfo() 337 | 338 | def get_identity(self, _type: str, _id: Any) -> URIRef: 339 | if _type.endswith("I") and _type != ("ROI"): 340 | _type = _type[0:-1] 341 | return URIRef(f"https://{self.info.host}/{_type}/{_id}") 342 | 343 | def get_bnode(self) -> BNode: 344 | try: 345 | return BNode() 346 | # return f":b{self.bnode}" 347 | finally: 348 | self.bnode += 1 349 | 350 | def get_key(self, key: str) -> Optional[URIRef]: 351 | if key in ("@type", "@id", "omero:details", "Annotations"): 352 | # Types that we want to omit fo 353 | return None 354 | else: 355 | if key.startswith("omero:"): 356 | return URIRef(f"{self.OMERO}{key[6:]}") 357 | else: 358 | return URIRef(f"{self.OME}{key}") 359 | 360 | def get_type(self, data: Data) -> str: 361 | return data.get("@type", "UNKNOWN").split("#")[-1] 362 | 363 | def literal(self, v: Any) -> Literal: 364 | """ 365 | Prepare Python objects for use as literals 366 | """ 367 | if isinstance(v, str): 368 | v = str(v) 369 | if self.use_ellide and len(v) > 50: 370 | v = f"{v[0:24]}...{v[-20:-1]}" 371 | elif v.startswith(" ") or v.endswith(" "): 372 | if self.trim_whitespace: 373 | v = v.strip() 374 | else: 375 | logging.warning( 376 | "string has whitespace that needs trimming: '%s'", v 377 | ) 378 | return Literal(v) 379 | 380 | def get_class(self, o): 381 | if isinstance(o, IObject): 382 | c = o.__class__ 383 | else: # Wrapper 384 | c = o._obj.__class__ 385 | return c 386 | 387 | def __call__(self, o: BlitzObjectWrapper) -> URIRef: 388 | c = self.get_class(o) 389 | encoder = get_encoder(c) 390 | if encoder is None: 391 | raise Exception(f"unknown: {c}") 392 | else: 393 | data = encoder.encode(o) 394 | return self.handle(data) 395 | 396 | def annotations(self, obj, objid): 397 | """ 398 | Loop through all annotations and handle them individually. 399 | """ 400 | if isinstance(obj, IObject): 401 | # Not a wrapper object 402 | for annotation in obj.linkedAnnotationList(): 403 | annid = self(annotation) 404 | self.contains(objid, annid) 405 | else: 406 | for annotation in obj.listAnnotations(None): 407 | obj._loadAnnotationLinks() 408 | annid = self(annotation) 409 | self.contains(objid, annid) 410 | 411 | def handle(self, data: Data) -> URIRef: 412 | """ 413 | Parses the data object into RDF triples. 414 | 415 | Returns the id for the data object itself 416 | """ 417 | # TODO: Add quad representation as an option 418 | 419 | str_id = data.get("@id") 420 | if not str_id: 421 | raise Exception(f"missing id: {data}") 422 | 423 | # TODO: this call is likely redundant 424 | _type = self.get_type(data) 425 | _id = self.get_identity(_type, str_id) 426 | 427 | for triple in self.rdf(_id, data): 428 | if triple: 429 | if None in triple: 430 | logging.debug("skipping None value: %s %s %s", triple) 431 | else: 432 | self.emit(triple) 433 | 434 | return _id 435 | 436 | def contains(self, parent, child): 437 | """ 438 | Use emit to generate isPartOf and hasPart triples 439 | 440 | TODO: add an option to only choose one of the two directions. 441 | """ 442 | self.emit((child, DCTERMS.isPartOf, parent)) 443 | self.emit((parent, DCTERMS.hasPart, child)) 444 | 445 | def emit(self, triple: Triple): 446 | if self.formatter.streaming: 447 | print(self.formatter.serialize_triple(triple), file=self.filehandle) 448 | else: 449 | self.formatter.add(triple) 450 | 451 | def close(self): 452 | if not self.formatter.streaming: 453 | print(self.formatter.serialize_graph(), file=self.filehandle) 454 | 455 | def rdf( 456 | self, 457 | _id: Subj, 458 | data: Data, 459 | ) -> Generator[Triple, None, None]: 460 | _type = self.get_type(data) 461 | 462 | # Temporary workaround while deciding how to pass annotations 463 | if "Annotation" in str(_type): 464 | for ah in self.annotation_handlers: 465 | handled = yield from ah( 466 | None, 467 | None, 468 | data, 469 | ) 470 | if self.first_handler_wins and handled: 471 | return 472 | # End workaround 473 | 474 | if _id in self.cache: 475 | logging.debug("# skipping previously seen %s", _id) 476 | return 477 | else: 478 | self.cache.add(_id) 479 | 480 | for k, v in sorted(data.items()): 481 | if k == "@type": 482 | yield (_id, RDF.type, URIRef(v)) 483 | elif k in ("@id", "omero:details", "Annotations"): 484 | # Types that we want to omit for now 485 | pass 486 | else: 487 | if k.startswith("omero:"): 488 | key = URIRef(f"{self.OMERO}{k[6:]}") 489 | else: 490 | key = URIRef(f"{self.OME}{k}") 491 | 492 | if isinstance(v, dict): 493 | # This is an object 494 | if "@id" in v: 495 | yield from self.yield_object_with_id(_id, key, v) 496 | else: 497 | # Without an identity, use a bnode 498 | # TODO: store by value for re-use? 499 | bnode = self.get_bnode() 500 | yield (_id, key, bnode) 501 | yield from self.rdf(bnode, v) 502 | 503 | elif isinstance(v, list): 504 | # This is likely the [[key, value], ...] structure? 505 | # can also be shapes 506 | for item in v: 507 | if isinstance(item, dict) and "@id" in item: 508 | yield from self.yield_object_with_id(_id, key, item) 509 | elif isinstance(item, list) and len(item) == 2: 510 | bnode = self.get_bnode() 511 | # TODO: KVPs need ordering info, also no use of "key" here. 512 | yield (_id, URIRef(f"{self.OME}Map"), bnode) 513 | yield ( 514 | bnode, 515 | URIRef(f"{self.OME}Key"), 516 | self.literal(item[0]), 517 | ) 518 | yield ( 519 | bnode, 520 | URIRef(f"{self.OME}Value"), 521 | self.literal(item[1]), 522 | ) 523 | else: 524 | raise Exception(f"unknown list item: {item}") 525 | else: 526 | yield (_id, key, self.literal(v)) 527 | 528 | # Special handling for Annotations 529 | annotations = data.get("Annotations", []) 530 | for annotation in annotations: 531 | handled = False 532 | for ah in self.annotation_handlers: 533 | handled = yield from ah( 534 | _id, URIRef(f"{self.OME}annotation"), annotation 535 | ) 536 | if handled: 537 | break 538 | 539 | if not handled: # TODO: could move to a default handler 540 | aid = self.get_identity("AnnotationTBD", annotation["@id"]) 541 | yield (_id, URIRef(f"{self.OME}annotation"), aid) 542 | yield from self.rdf(aid, annotation) 543 | 544 | def yield_object_with_id(self, _id, key, v): 545 | """ 546 | Yields a link to the object as well as its representation. 547 | """ 548 | v_type = self.get_type(v) 549 | val = self.get_identity(v_type, v["@id"]) 550 | yield (_id, key, val) 551 | yield from self.rdf(_id, v) 552 | 553 | 554 | class RdfControl(BaseControl): 555 | def _configure(self, parser: Parser) -> None: 556 | parser.add_login_arguments() 557 | rdf_type = ProxyStringType("Image") 558 | rdf_help = "Object to be exported to RDF" 559 | parser.add_argument("target", type=rdf_type, nargs="+", help=rdf_help) 560 | format_group = parser.add_mutually_exclusive_group() 561 | format_group.add_argument( 562 | "--pretty", 563 | action="store_true", 564 | default=False, 565 | help="Shortcut for --format=turtle", 566 | ) 567 | format_group.add_argument( 568 | "--format", 569 | "-F", 570 | default="ntriples", 571 | choices=format_list(), 572 | ) 573 | parser.add_argument( 574 | "--descent", 575 | "-S", 576 | default="recursive", 577 | help="Descent strategy to use: recursive, flat", 578 | ) 579 | parser.add_argument( 580 | "--ellide", action="store_true", default=False, help="Shorten strings" 581 | ) 582 | parser.add_argument( 583 | "--first-handler-wins", 584 | "-1", 585 | action="store_true", 586 | default=False, 587 | help="Don't duplicate annotations", 588 | ) 589 | parser.add_argument( 590 | "--trim-whitespace", 591 | action="store_true", 592 | default=False, 593 | help="Remove leading and trailing whitespace from literals", 594 | ) 595 | parser.add_argument( 596 | "--file", 597 | type=str, 598 | default=None, 599 | help="Write RDF triples to the specified file", 600 | ) 601 | parser.set_defaults(func=self.action) 602 | 603 | @gateway_required 604 | def action(self, args: Namespace) -> None: 605 | self._validate_extensions(args) 606 | 607 | # Support hidden --pretty flag 608 | if args.pretty: 609 | args.format = TurtleFormat() 610 | else: 611 | args.format = format_mapping()[args.format] 612 | 613 | with open_with_default(args.file) as fh: 614 | handler = Handler( 615 | self.gateway, 616 | formatter=args.format, 617 | use_ellide=args.ellide, 618 | trim_whitespace=args.trim_whitespace, 619 | first_handler_wins=args.first_handler_wins, 620 | descent=args.descent, 621 | filehandle=fh, 622 | ) 623 | self.descend(self.gateway, args.target, handler) 624 | handler.close() 625 | 626 | def _validate_extensions(self, args): 627 | extension_map = { 628 | "ntriples": ["nt"], 629 | "turtle": ["ttl"], 630 | "jsonld": ["jsonld", "json"], 631 | "ro-crate": ["jsonld", "json"], 632 | } 633 | 634 | if args.file and args.file != "-": 635 | filename = args.file.lower() 636 | 637 | if filename.endswith(".gz"): 638 | filename = filename.replace(".gz", "") 639 | file_extension = filename.split(".")[-1] 640 | 641 | format_string = str(args.format) 642 | valid_exts = extension_map.get(format_string, []) 643 | 644 | if args.pretty: 645 | if format_string != "turtle" or file_extension != "ttl": 646 | logging.warning( 647 | "--pretty sets output format to Turtle." 648 | " This may be conflicting with the " 649 | "'--format' or '--file. settings" 650 | ) 651 | 652 | if valid_exts and file_extension not in valid_exts: 653 | logging.warning( 654 | f".{file_extension}' does not match format '{format_string}'" 655 | f"(expected: {', '.join(f'.{e}' for e in valid_exts)})", 656 | ) 657 | 658 | if not getattr(args, "yes", False): # hidden --yes 659 | self.ctx.out("This may cause incorrect output formatting.") 660 | reply = input("Continue anyway? [y/N]: ").strip().lower() 661 | if reply not in ("y", "yes"): 662 | self.ctx.err("Aborted by user.") 663 | return 664 | 665 | # TODO: move to handler? 666 | def descend( 667 | self, 668 | gateway: BlitzGateway, 669 | target: IObject, 670 | handler: Handler, 671 | ) -> URIRef: 672 | """ 673 | Copied from omero-cli-render. Should be moved upstream 674 | """ 675 | 676 | if isinstance(target, list): 677 | return [self.descend(gateway, t, handler) for t in target] 678 | 679 | # "descent" doesn't apply to a list 680 | if handler.skip_descent(): 681 | objid = handler(target) 682 | logging.debug("skip descent: %s", objid) 683 | return objid 684 | else: 685 | handler.descending() 686 | 687 | if isinstance(target, Screen): 688 | scr = self._lookup(gateway, "Screen", target.id) 689 | scrid = handler(scr) 690 | for plate in scr.listChildren(): 691 | pltid = self.descend(gateway, plate._obj, handler) 692 | handler.contains(scrid, pltid) 693 | handler.annotations(scr, scrid) 694 | return scrid 695 | 696 | elif isinstance(target, Plate): 697 | plt = self._lookup(gateway, "Plate", target.id) 698 | pltid = handler(plt) 699 | handler.annotations(plt, pltid) 700 | for well in plt.listChildren(): 701 | wid = handler(well) # No descend 702 | handler.contains(pltid, wid) 703 | for idx in range(0, well.countWellSample()): 704 | img = well.getImage(idx) 705 | imgid = self.descend(gateway, img._obj, handler) 706 | handler.contains(wid, imgid) 707 | return pltid 708 | 709 | elif isinstance(target, Project): 710 | prj = self._lookup(gateway, "Project", target.id) 711 | prjid = handler(prj) 712 | handler.annotations(prj, prjid) 713 | for ds in prj.listChildren(): 714 | dsid = self.descend(gateway, ds._obj, handler) 715 | handler.contains(prjid, dsid) 716 | return prjid 717 | 718 | elif isinstance(target, Dataset): 719 | ds = self._lookup(gateway, "Dataset", target.id) 720 | dsid = handler(ds) 721 | handler.annotations(ds, dsid) 722 | for img in ds.listChildren(): 723 | imgid = self.descend(gateway, img._obj, handler) 724 | handler.contains(dsid, imgid) 725 | return dsid 726 | 727 | elif isinstance(target, Image): 728 | img = self._lookup(gateway, "Image", target.id) 729 | imgid = handler(img) 730 | if img.getPrimaryPixels() is not None: 731 | pixid = handler(img.getPrimaryPixels()) 732 | handler.contains(imgid, pixid) 733 | handler.annotations(img, imgid) 734 | for roi in self._get_rois(gateway, img): 735 | roiid = handler(roi) 736 | handler.annotations(roi, roiid) 737 | handler.contains(pixid, roiid) 738 | for shape in roi.iterateShapes(): 739 | shapeid = handler(shape) 740 | handler.annotations(shape, shapeid) 741 | handler.contains(roiid, shapeid) 742 | return imgid 743 | 744 | else: 745 | self.ctx.die(111, "unknown target: %s" % target.__class__.__name__) 746 | 747 | def _get_rois(self, gateway, img): 748 | params = ParametersI() 749 | params.addId(img.id) 750 | query = """select r from Roi r 751 | left outer join fetch r.annotationLinks as ral 752 | left outer join fetch ral.child as rann 753 | left outer join fetch r.shapes as s 754 | left outer join fetch s.annotationLinks as sal 755 | left outer join fetch sal.child as sann 756 | where r.image.id = :id""" 757 | return gateway.getQueryService().findAllByQuery( 758 | query, params, {"omero.group": str(img.details.group.id.val)} 759 | ) 760 | 761 | def _lookup( 762 | self, gateway: BlitzGateway, _type: str, oid: int 763 | ) -> BlitzObjectWrapper: 764 | # TODO: move _lookup to a _configure type 765 | gateway.SERVICE_OPTS.setOmeroGroup("-1") 766 | obj = gateway.getObject(_type, oid) 767 | if not obj: 768 | self.ctx.die(110, f"No such {_type}: {oid}") 769 | return obj 770 | --------------------------------------------------------------------------------