fairgraph 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. fairgraph/__init__.py +61 -0
  2. fairgraph/base.py +104 -0
  3. fairgraph/caching.py +52 -0
  4. fairgraph/client.py +867 -0
  5. fairgraph/collection.py +104 -0
  6. fairgraph/embedded.py +122 -0
  7. fairgraph/errors.py +47 -0
  8. fairgraph/fields.py +11 -0
  9. fairgraph/kgobject.py +1087 -0
  10. fairgraph/kgproxy.py +178 -0
  11. fairgraph/kgquery.py +151 -0
  12. fairgraph/node.py +488 -0
  13. fairgraph/openminds/__init__.py +1 -0
  14. fairgraph/openminds/chemicals/__init__.py +40 -0
  15. fairgraph/openminds/chemicals/amount_of_chemical.py +23 -0
  16. fairgraph/openminds/chemicals/chemical_mixture.py +72 -0
  17. fairgraph/openminds/chemicals/chemical_substance.py +67 -0
  18. fairgraph/openminds/chemicals/product_source.py +57 -0
  19. fairgraph/openminds/computation/__init__.py +53 -0
  20. fairgraph/openminds/computation/data_analysis.py +104 -0
  21. fairgraph/openminds/computation/data_copy.py +104 -0
  22. fairgraph/openminds/computation/environment.py +66 -0
  23. fairgraph/openminds/computation/generic_computation.py +104 -0
  24. fairgraph/openminds/computation/hardware_system.py +53 -0
  25. fairgraph/openminds/computation/launch_configuration.py +68 -0
  26. fairgraph/openminds/computation/local_file.py +83 -0
  27. fairgraph/openminds/computation/model_validation.py +107 -0
  28. fairgraph/openminds/computation/optimization.py +104 -0
  29. fairgraph/openminds/computation/simulation.py +104 -0
  30. fairgraph/openminds/computation/software_agent.py +81 -0
  31. fairgraph/openminds/computation/validation_test.py +105 -0
  32. fairgraph/openminds/computation/validation_test_version.py +180 -0
  33. fairgraph/openminds/computation/visualization.py +104 -0
  34. fairgraph/openminds/computation/workflow_execution.py +44 -0
  35. fairgraph/openminds/computation/workflow_recipe.py +95 -0
  36. fairgraph/openminds/computation/workflow_recipe_version.py +185 -0
  37. fairgraph/openminds/controlled_terms/__init__.py +116 -0
  38. fairgraph/openminds/controlled_terms/action_status_type.py +97 -0
  39. fairgraph/openminds/controlled_terms/age_category.py +89 -0
  40. fairgraph/openminds/controlled_terms/analysis_technique.py +108 -0
  41. fairgraph/openminds/controlled_terms/anatomical_axes_orientation.py +89 -0
  42. fairgraph/openminds/controlled_terms/anatomical_identification_type.py +89 -0
  43. fairgraph/openminds/controlled_terms/anatomical_plane.py +89 -0
  44. fairgraph/openminds/controlled_terms/annotation_criteria_type.py +89 -0
  45. fairgraph/openminds/controlled_terms/annotation_type.py +89 -0
  46. fairgraph/openminds/controlled_terms/atlas_type.py +88 -0
  47. fairgraph/openminds/controlled_terms/auditory_stimulus_type.py +127 -0
  48. fairgraph/openminds/controlled_terms/biological_order.py +117 -0
  49. fairgraph/openminds/controlled_terms/biological_process.py +79 -0
  50. fairgraph/openminds/controlled_terms/biological_sex.py +132 -0
  51. fairgraph/openminds/controlled_terms/breeding_type.py +127 -0
  52. fairgraph/openminds/controlled_terms/cell_culture_type.py +117 -0
  53. fairgraph/openminds/controlled_terms/cell_type.py +151 -0
  54. fairgraph/openminds/controlled_terms/chemical_mixture_type.py +89 -0
  55. fairgraph/openminds/controlled_terms/colormap.py +79 -0
  56. fairgraph/openminds/controlled_terms/contribution_type.py +79 -0
  57. fairgraph/openminds/controlled_terms/cranial_window_construction_type.py +89 -0
  58. fairgraph/openminds/controlled_terms/cranial_window_reinforcement_type.py +89 -0
  59. fairgraph/openminds/controlled_terms/criteria_quality_type.py +89 -0
  60. fairgraph/openminds/controlled_terms/data_type.py +89 -0
  61. fairgraph/openminds/controlled_terms/device_type.py +94 -0
  62. fairgraph/openminds/controlled_terms/difference_measure.py +89 -0
  63. fairgraph/openminds/controlled_terms/disease.py +142 -0
  64. fairgraph/openminds/controlled_terms/disease_model.py +142 -0
  65. fairgraph/openminds/controlled_terms/educational_level.py +79 -0
  66. fairgraph/openminds/controlled_terms/electrical_stimulus_type.py +137 -0
  67. fairgraph/openminds/controlled_terms/ethics_assessment.py +79 -0
  68. fairgraph/openminds/controlled_terms/experimental_approach.py +79 -0
  69. fairgraph/openminds/controlled_terms/file_bundle_grouping.py +99 -0
  70. fairgraph/openminds/controlled_terms/file_repository_type.py +89 -0
  71. fairgraph/openminds/controlled_terms/file_usage_role.py +89 -0
  72. fairgraph/openminds/controlled_terms/genetic_strain_type.py +127 -0
  73. fairgraph/openminds/controlled_terms/gustatory_stimulus_type.py +127 -0
  74. fairgraph/openminds/controlled_terms/handedness.py +127 -0
  75. fairgraph/openminds/controlled_terms/language.py +88 -0
  76. fairgraph/openminds/controlled_terms/laterality.py +94 -0
  77. fairgraph/openminds/controlled_terms/learning_resource_type.py +88 -0
  78. fairgraph/openminds/controlled_terms/measured_quantity.py +89 -0
  79. fairgraph/openminds/controlled_terms/measured_signal_type.py +79 -0
  80. fairgraph/openminds/controlled_terms/meta_data_model_type.py +88 -0
  81. fairgraph/openminds/controlled_terms/model_abstraction_level.py +89 -0
  82. fairgraph/openminds/controlled_terms/model_scope.py +89 -0
  83. fairgraph/openminds/controlled_terms/molecular_entity.py +142 -0
  84. fairgraph/openminds/controlled_terms/mri_pulse_sequence.py +98 -0
  85. fairgraph/openminds/controlled_terms/mri_weighting.py +98 -0
  86. fairgraph/openminds/controlled_terms/olfactory_stimulus_type.py +127 -0
  87. fairgraph/openminds/controlled_terms/operating_device.py +79 -0
  88. fairgraph/openminds/controlled_terms/operating_system.py +88 -0
  89. fairgraph/openminds/controlled_terms/optical_stimulus_type.py +127 -0
  90. fairgraph/openminds/controlled_terms/organ.py +161 -0
  91. fairgraph/openminds/controlled_terms/organism_substance.py +151 -0
  92. fairgraph/openminds/controlled_terms/organism_system.py +117 -0
  93. fairgraph/openminds/controlled_terms/patch_clamp_variation.py +89 -0
  94. fairgraph/openminds/controlled_terms/preparation_type.py +98 -0
  95. fairgraph/openminds/controlled_terms/product_accessibility.py +79 -0
  96. fairgraph/openminds/controlled_terms/programming_language.py +88 -0
  97. fairgraph/openminds/controlled_terms/qualitative_overlap.py +79 -0
  98. fairgraph/openminds/controlled_terms/semantic_data_type.py +79 -0
  99. fairgraph/openminds/controlled_terms/service.py +89 -0
  100. fairgraph/openminds/controlled_terms/setup_type.py +89 -0
  101. fairgraph/openminds/controlled_terms/software_application_category.py +79 -0
  102. fairgraph/openminds/controlled_terms/software_feature.py +79 -0
  103. fairgraph/openminds/controlled_terms/species.py +143 -0
  104. fairgraph/openminds/controlled_terms/stimulation_approach.py +98 -0
  105. fairgraph/openminds/controlled_terms/stimulation_technique.py +98 -0
  106. fairgraph/openminds/controlled_terms/subcellular_entity.py +143 -0
  107. fairgraph/openminds/controlled_terms/subject_attribute.py +89 -0
  108. fairgraph/openminds/controlled_terms/tactile_stimulus_type.py +127 -0
  109. fairgraph/openminds/controlled_terms/technique.py +108 -0
  110. fairgraph/openminds/controlled_terms/term_suggestion.py +121 -0
  111. fairgraph/openminds/controlled_terms/terminology.py +89 -0
  112. fairgraph/openminds/controlled_terms/tissue_sample_attribute.py +89 -0
  113. fairgraph/openminds/controlled_terms/tissue_sample_type.py +127 -0
  114. fairgraph/openminds/controlled_terms/type_of_uncertainty.py +89 -0
  115. fairgraph/openminds/controlled_terms/uberon_parcellation.py +153 -0
  116. fairgraph/openminds/controlled_terms/unit_of_measurement.py +108 -0
  117. fairgraph/openminds/controlled_terms/visual_stimulus_type.py +127 -0
  118. fairgraph/openminds/controlledterms.py +6 -0
  119. fairgraph/openminds/core/__init__.py +107 -0
  120. fairgraph/openminds/core/actors/__init__.py +7 -0
  121. fairgraph/openminds/core/actors/account_information.py +44 -0
  122. fairgraph/openminds/core/actors/affiliation.py +30 -0
  123. fairgraph/openminds/core/actors/consortium.py +175 -0
  124. fairgraph/openminds/core/actors/contact_information.py +43 -0
  125. fairgraph/openminds/core/actors/contribution.py +23 -0
  126. fairgraph/openminds/core/actors/organization.py +199 -0
  127. fairgraph/openminds/core/actors/person.py +236 -0
  128. fairgraph/openminds/core/data/__init__.py +13 -0
  129. fairgraph/openminds/core/data/content_type.py +107 -0
  130. fairgraph/openminds/core/data/content_type_pattern.py +53 -0
  131. fairgraph/openminds/core/data/copyright.py +23 -0
  132. fairgraph/openminds/core/data/file.py +275 -0
  133. fairgraph/openminds/core/data/file_archive.py +71 -0
  134. fairgraph/openminds/core/data/file_bundle.py +150 -0
  135. fairgraph/openminds/core/data/file_path_pattern.py +23 -0
  136. fairgraph/openminds/core/data/file_repository.py +99 -0
  137. fairgraph/openminds/core/data/file_repository_structure.py +51 -0
  138. fairgraph/openminds/core/data/hash.py +23 -0
  139. fairgraph/openminds/core/data/license.py +77 -0
  140. fairgraph/openminds/core/data/measurement.py +45 -0
  141. fairgraph/openminds/core/data/service_link.py +49 -0
  142. fairgraph/openminds/core/digital_identifier/__init__.py +11 -0
  143. fairgraph/openminds/core/digital_identifier/doi.py +98 -0
  144. fairgraph/openminds/core/digital_identifier/gridid.py +41 -0
  145. fairgraph/openminds/core/digital_identifier/handle.py +52 -0
  146. fairgraph/openminds/core/digital_identifier/identifiers_dot_org_id.py +41 -0
  147. fairgraph/openminds/core/digital_identifier/isbn.py +88 -0
  148. fairgraph/openminds/core/digital_identifier/issn.py +63 -0
  149. fairgraph/openminds/core/digital_identifier/orcid.py +41 -0
  150. fairgraph/openminds/core/digital_identifier/rorid.py +41 -0
  151. fairgraph/openminds/core/digital_identifier/rrid.py +55 -0
  152. fairgraph/openminds/core/digital_identifier/stock_number.py +23 -0
  153. fairgraph/openminds/core/digital_identifier/swhid.py +48 -0
  154. fairgraph/openminds/core/miscellaneous/__init__.py +7 -0
  155. fairgraph/openminds/core/miscellaneous/comment.py +47 -0
  156. fairgraph/openminds/core/miscellaneous/funding.py +70 -0
  157. fairgraph/openminds/core/miscellaneous/quantitative_value.py +43 -0
  158. fairgraph/openminds/core/miscellaneous/quantitative_value_array.py +49 -0
  159. fairgraph/openminds/core/miscellaneous/quantitative_value_range.py +43 -0
  160. fairgraph/openminds/core/miscellaneous/research_product_group.py +26 -0
  161. fairgraph/openminds/core/miscellaneous/web_resource.py +104 -0
  162. fairgraph/openminds/core/products/__init__.py +12 -0
  163. fairgraph/openminds/core/products/dataset.py +95 -0
  164. fairgraph/openminds/core/products/dataset_version.py +240 -0
  165. fairgraph/openminds/core/products/meta_data_model.py +95 -0
  166. fairgraph/openminds/core/products/meta_data_model_version.py +168 -0
  167. fairgraph/openminds/core/products/model.py +103 -0
  168. fairgraph/openminds/core/products/model_version.py +235 -0
  169. fairgraph/openminds/core/products/project.py +56 -0
  170. fairgraph/openminds/core/products/setup.py +69 -0
  171. fairgraph/openminds/core/products/software.py +95 -0
  172. fairgraph/openminds/core/products/software_version.py +226 -0
  173. fairgraph/openminds/core/products/web_service.py +103 -0
  174. fairgraph/openminds/core/products/web_service_version.py +182 -0
  175. fairgraph/openminds/core/research/__init__.py +17 -0
  176. fairgraph/openminds/core/research/behavioral_protocol.py +69 -0
  177. fairgraph/openminds/core/research/configuration.py +67 -0
  178. fairgraph/openminds/core/research/custom_property_set.py +27 -0
  179. fairgraph/openminds/core/research/numerical_property.py +23 -0
  180. fairgraph/openminds/core/research/property_value_list.py +71 -0
  181. fairgraph/openminds/core/research/protocol.py +67 -0
  182. fairgraph/openminds/core/research/protocol_execution.py +76 -0
  183. fairgraph/openminds/core/research/strain.py +90 -0
  184. fairgraph/openminds/core/research/string_property.py +23 -0
  185. fairgraph/openminds/core/research/subject.py +79 -0
  186. fairgraph/openminds/core/research/subject_group.py +91 -0
  187. fairgraph/openminds/core/research/subject_group_state.py +113 -0
  188. fairgraph/openminds/core/research/subject_state.py +138 -0
  189. fairgraph/openminds/core/research/tissue_sample.py +87 -0
  190. fairgraph/openminds/core/research/tissue_sample_collection.py +99 -0
  191. fairgraph/openminds/core/research/tissue_sample_collection_state.py +109 -0
  192. fairgraph/openminds/core/research/tissue_sample_state.py +127 -0
  193. fairgraph/openminds/ephys/__init__.py +39 -0
  194. fairgraph/openminds/ephys/activity/__init__.py +3 -0
  195. fairgraph/openminds/ephys/activity/cell_patching.py +73 -0
  196. fairgraph/openminds/ephys/activity/electrode_placement.py +67 -0
  197. fairgraph/openminds/ephys/activity/recording_activity.py +67 -0
  198. fairgraph/openminds/ephys/device/__init__.py +6 -0
  199. fairgraph/openminds/ephys/device/electrode.py +81 -0
  200. fairgraph/openminds/ephys/device/electrode_array.py +85 -0
  201. fairgraph/openminds/ephys/device/electrode_array_usage.py +105 -0
  202. fairgraph/openminds/ephys/device/electrode_usage.py +101 -0
  203. fairgraph/openminds/ephys/device/pipette.py +81 -0
  204. fairgraph/openminds/ephys/device/pipette_usage.py +123 -0
  205. fairgraph/openminds/ephys/entity/__init__.py +2 -0
  206. fairgraph/openminds/ephys/entity/channel.py +23 -0
  207. fairgraph/openminds/ephys/entity/recording.py +63 -0
  208. fairgraph/openminds/publications/__init__.py +47 -0
  209. fairgraph/openminds/publications/book.py +106 -0
  210. fairgraph/openminds/publications/chapter.py +100 -0
  211. fairgraph/openminds/publications/learning_resource.py +90 -0
  212. fairgraph/openminds/publications/live_paper.py +95 -0
  213. fairgraph/openminds/publications/live_paper_resource_item.py +58 -0
  214. fairgraph/openminds/publications/live_paper_section.py +57 -0
  215. fairgraph/openminds/publications/live_paper_version.py +177 -0
  216. fairgraph/openminds/publications/periodical.py +53 -0
  217. fairgraph/openminds/publications/publication_issue.py +44 -0
  218. fairgraph/openminds/publications/publication_volume.py +44 -0
  219. fairgraph/openminds/publications/scholarly_article.py +146 -0
  220. fairgraph/openminds/sands/__init__.py +57 -0
  221. fairgraph/openminds/sands/atlas/__init__.py +9 -0
  222. fairgraph/openminds/sands/atlas/atlas_annotation.py +52 -0
  223. fairgraph/openminds/sands/atlas/brain_atlas.py +113 -0
  224. fairgraph/openminds/sands/atlas/brain_atlas_version.py +213 -0
  225. fairgraph/openminds/sands/atlas/common_coordinate_space.py +121 -0
  226. fairgraph/openminds/sands/atlas/common_coordinate_space_version.py +243 -0
  227. fairgraph/openminds/sands/atlas/parcellation_entity.py +133 -0
  228. fairgraph/openminds/sands/atlas/parcellation_entity_version.py +162 -0
  229. fairgraph/openminds/sands/atlas/parcellation_terminology.py +38 -0
  230. fairgraph/openminds/sands/atlas/parcellation_terminology_version.py +38 -0
  231. fairgraph/openminds/sands/mathematical_shapes/__init__.py +3 -0
  232. fairgraph/openminds/sands/mathematical_shapes/circle.py +23 -0
  233. fairgraph/openminds/sands/mathematical_shapes/ellipse.py +27 -0
  234. fairgraph/openminds/sands/mathematical_shapes/rectangle.py +23 -0
  235. fairgraph/openminds/sands/miscellaneous/__init__.py +6 -0
  236. fairgraph/openminds/sands/miscellaneous/anatomical_target_position.py +40 -0
  237. fairgraph/openminds/sands/miscellaneous/coordinate_point.py +23 -0
  238. fairgraph/openminds/sands/miscellaneous/qualitative_relation_assessment.py +34 -0
  239. fairgraph/openminds/sands/miscellaneous/quantitative_relation_assessment.py +38 -0
  240. fairgraph/openminds/sands/miscellaneous/single_color.py +24 -0
  241. fairgraph/openminds/sands/miscellaneous/viewer_specification.py +40 -0
  242. fairgraph/openminds/sands/non_atlas/__init__.py +3 -0
  243. fairgraph/openminds/sands/non_atlas/custom_anatomical_entity.py +110 -0
  244. fairgraph/openminds/sands/non_atlas/custom_annotation.py +54 -0
  245. fairgraph/openminds/sands/non_atlas/custom_coordinate_space.py +67 -0
  246. fairgraph/openminds/specimen_prep/__init__.py +38 -0
  247. fairgraph/openminds/specimen_prep/activity/__init__.py +3 -0
  248. fairgraph/openminds/specimen_prep/activity/cranial_window_preparation.py +69 -0
  249. fairgraph/openminds/specimen_prep/activity/tissue_culture_preparation.py +67 -0
  250. fairgraph/openminds/specimen_prep/activity/tissue_sample_slicing.py +69 -0
  251. fairgraph/openminds/specimen_prep/device/__init__.py +2 -0
  252. fairgraph/openminds/specimen_prep/device/slicing_device.py +73 -0
  253. fairgraph/openminds/specimen_prep/device/slicing_device_usage.py +117 -0
  254. fairgraph/openminds/specimenprep.py +4 -0
  255. fairgraph/openminds/stimulation/__init__.py +38 -0
  256. fairgraph/openminds/stimulation/activity/__init__.py +1 -0
  257. fairgraph/openminds/stimulation/activity/stimulation_activity.py +67 -0
  258. fairgraph/openminds/stimulation/stimulus/__init__.py +1 -0
  259. fairgraph/openminds/stimulation/stimulus/ephys_stimulus.py +63 -0
  260. fairgraph/queries.py +499 -0
  261. fairgraph/registry.py +123 -0
  262. fairgraph/utility.py +607 -0
  263. fairgraph-0.13.0.dist-info/METADATA +222 -0
  264. fairgraph-0.13.0.dist-info/RECORD +267 -0
  265. fairgraph-0.13.0.dist-info/WHEEL +5 -0
  266. fairgraph-0.13.0.dist-info/licenses/LICENSE.txt +177 -0
  267. fairgraph-0.13.0.dist-info/top_level.txt +1 -0
fairgraph/kgobject.py ADDED
@@ -0,0 +1,1087 @@
1
+ """
2
+ This module provides the KGObject class, which is the base class
3
+ for representations of structured metadata that have a globally
4
+ unique identifier (a URI).
5
+ """
6
+
7
+ # Copyright 2018-2024 CNRS
8
+
9
+ # Licensed under the Apache License, Version 2.0 (the "License");
10
+ # you may not use this file except in compliance with the License.
11
+ # You may obtain a copy of the License at
12
+
13
+ # http://www.apache.org/licenses/LICENSE-2.0
14
+
15
+ # Unless required by applicable law or agreed to in writing, software
16
+ # distributed under the License is distributed on an "AS IS" BASIS,
17
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
+ # See the License for the specific language governing permissions and
19
+ # limitations under the License.
20
+
21
+ from __future__ import annotations
22
+ from collections import defaultdict
23
+ import logging
24
+ from uuid import UUID
25
+ from warnings import warn
26
+ from typing import Any, Tuple, Dict, List, Optional, TYPE_CHECKING, Union
27
+
28
+ from requests.exceptions import HTTPError, ConnectionError
29
+
30
+ try:
31
+ from tabulate import tabulate
32
+
33
+ have_tabulate = True
34
+ except ImportError:
35
+ have_tabulate = False
36
+
37
+ from openminds.registry import lookup_type
38
+ from openminds import IRI, LinkedMetadata
39
+
40
+ from .utility import expand_uri, as_list, expand_filter, ActivityLog, normalize_data, handle_scope_keyword
41
+ from .queries import Query, QueryProperty
42
+ from .errors import AuthorizationError, ResourceExistsError, CannotBuildExistenceQuery
43
+ from .caching import object_cache, save_cache, generate_cache_key
44
+ from .base import RepresentsSingleObject, SupportsQuerying, JSONdict, OPENMINDS_VERSION
45
+ from .node import ContainsMetadata
46
+ from .kgproxy import KGProxy
47
+ from .kgquery import KGQuery
48
+
49
+ if TYPE_CHECKING:
50
+ from .properties import Property
51
+ from .client import KGClient
52
+
53
+
54
+ logger = logging.getLogger("fairgraph")
55
+
56
+
57
+ class KGObject(ContainsMetadata, RepresentsSingleObject, SupportsQuerying):
58
+ """
59
+ Base class for Knowledge Graph objects.
60
+
61
+ Should not be instantiated directly, intended to be subclassed.
62
+ """
63
+
64
+ existence_query_properties: Tuple[str, ...] = ("name",)
65
+ # Note that this default value of existence_query_properties should in
66
+ # many cases be over-ridden.
67
+ # It assumes that "name" is unique within instances of a given type,
68
+ # which may often not be the case.
69
+
70
+ def __init__(
71
+ self,
72
+ id: Optional[str] = None,
73
+ data: Optional[JSONdict] = None,
74
+ space: Optional[str] = None,
75
+ release_status: Optional[str] = None,
76
+ **properties,
77
+ ):
78
+ self.id = id
79
+ self._space = space
80
+ self.release_status = release_status
81
+ self.allow_update = True
82
+ super().__init__(data=data, **properties)
83
+ for prop in self.reverse_properties:
84
+ if not hasattr(self, prop.name):
85
+ query = KGQuery(
86
+ prop.types, {prop.reverse: self.id}, callback=lambda value: setattr(self, prop.name, value)
87
+ )
88
+ setattr(self, prop.name, query)
89
+
90
+ self._raw_remote_data = None
91
+ self.remote_data = {}
92
+ if self.id and self.id.startswith("http"):
93
+ # we store the original remote data in `_raw_remote_data`
94
+ # and a normalized version in `remote_data`
95
+ self._raw_remote_data = data # for debugging
96
+ if data:
97
+ self.remote_data = normalize_data(
98
+ self.to_jsonld(include_empty_properties=False, embed_linked_nodes=False),
99
+ data.get("@context", self.context)
100
+ )
101
+
102
+ def __repr__(self):
103
+ template_parts = (
104
+ "{}={{self.{}!r}}".format(prop.name, prop.name)
105
+ for prop in self.__class__.all_properties
106
+ if getattr(self, prop.name, None) is not None
107
+ )
108
+ template = "{self.__class__.__name__}(" + ", ".join(template_parts) + ", space={self.space}, id={self.id})"
109
+ return template.format(self=self)
110
+
111
+ @property
112
+ def space(self) -> Union[str, None]:
113
+ if self._raw_remote_data:
114
+ if "https://schema.hbp.eu/myQuery/space" in self._raw_remote_data:
115
+ self._space = self._raw_remote_data["https://schema.hbp.eu/myQuery/space"]
116
+ elif "https://core.kg.ebrains.eu/vocab/meta/space" in self._raw_remote_data:
117
+ self._space = self._raw_remote_data["https://core.kg.ebrains.eu/vocab/meta/space"]
118
+ return self._space
119
+
120
+ @classmethod
121
+ def from_jsonld(
122
+ cls,
123
+ data: JSONdict,
124
+ ignore_unexpected_keys: Optional[bool] = False,
125
+ release_status: Optional[str] = None
126
+ ) -> KGObject:
127
+ """Create an instance of the class from a JSON-LD document."""
128
+ # todo: handle ignore_unexpected_keys
129
+ deserialized_data = cls._deserialize_data(data, include_id=True)
130
+ return cls(id=data.get("@id", None), data=data, release_status=release_status, **deserialized_data)
131
+
132
+ # @classmethod
133
+ # def _fix_keys(cls, data):
134
+ # """
135
+ # The KG Query API does not allow the same property name to be used twice in a document.
136
+ # This is a problem when resolving linked nodes which use the same property names
137
+ # as the 'parent'. As a workaround, we prefix the property names in the linked node
138
+ # with the class name.
139
+ # This method removes this prefix.
140
+ # This feels like a kludge, and I'd be happy to find a better solution.
141
+ # """
142
+ # prefix = cls.__name__ + "__"
143
+ # for key in list(data):
144
+ # # need to use list() in previous line to avoid
145
+ # # "dictionary keys changed during iteration" error in Python 3.8+
146
+ # if key.startswith(prefix):
147
+ # fixed_key = key.replace(prefix, "")
148
+ # data[fixed_key] = data.pop(key)
149
+ # return data
150
+
151
+ @classmethod
152
+ def from_uri(
153
+ cls,
154
+ uri: str,
155
+ client: KGClient,
156
+ use_cache: bool = True,
157
+ release_status: str = "released",
158
+ scope: Optional[str] = None,
159
+ follow_links: Optional[Dict[str, Any]] = None,
160
+ with_reverse_properties: Optional[bool] = False,
161
+ ):
162
+ """
163
+ Retrieve an instance from the Knowledge Graph based on its URI.
164
+
165
+ Args:
166
+ uri (str): long-form identifier for the KG instance (a full URI)
167
+ client: a KGClient
168
+ release_status (str, optional): The scope of the lookup. Valid values are "released", "in progress", or "any".
169
+ Defaults to "released".
170
+ use_cache (bool): Whether to use cached data if they exist. Defaults to True.
171
+ follow_links (dict): The links in the graph to follow. Defaults to None.
172
+ with_reverse_properties (bool): Whether to include reverse properties. Defaults to False.
173
+ """
174
+ release_status = handle_scope_keyword(scope, release_status)
175
+ if follow_links:
176
+ query = cls.generate_query(
177
+ space=None,
178
+ client=client,
179
+ filters=None,
180
+ follow_links=follow_links,
181
+ with_reverse_properties=with_reverse_properties,
182
+ )
183
+ results = client.query(
184
+ query, instance_id=client.uuid_from_uri(uri), size=1, release_status=release_status
185
+ ).data
186
+ if results:
187
+ data = results[0]
188
+ else:
189
+ data = None
190
+ else:
191
+ data = client.instance_from_full_uri(uri, use_cache=use_cache, release_status=release_status)
192
+ if data is None:
193
+ return None
194
+ else:
195
+ return cls.from_jsonld(data, release_status=release_status)
196
+
197
+ @classmethod
198
+ def from_uuid(
199
+ cls,
200
+ uuid: str,
201
+ client: KGClient,
202
+ use_cache: bool = True,
203
+ release_status: str = "released",
204
+ scope: Optional[str] = None,
205
+ follow_links: Optional[Dict[str, Any]] = None,
206
+ with_reverse_properties: Optional[bool] = False,
207
+ ):
208
+ """
209
+ Retrieve an instance from the Knowledge Graph based on its UUID.
210
+
211
+ Args:
212
+ uuid (str): short-form identifier for the KG instance (a UUID).
213
+ client: a KGClient
214
+ release_status (str, optional): The scope of the lookup. Valid values are "released", "in progress", or "any".
215
+ Defaults to "released".
216
+ use_cache (bool): Whether to use cached data if they exist. Defaults to True.
217
+ follow_links (dict): The links in the graph to follow. Defaults to None.
218
+ with_reverse_properties (bool): Whether to include reverse properties. Defaults to False.
219
+
220
+ """
221
+ release_status = handle_scope_keyword(scope, release_status)
222
+ logger.info("Attempting to retrieve {} with uuid {}".format(cls.__name__, uuid))
223
+ if len(uuid) == 0:
224
+ raise ValueError("Empty UUID")
225
+ try:
226
+ val = UUID(uuid, version=4) # check validity of uuid
227
+ except ValueError as err:
228
+ raise ValueError("{} - {}".format(err, uuid))
229
+ uri = cls.uri_from_uuid(uuid, client)
230
+ return cls.from_uri(
231
+ uri,
232
+ client,
233
+ use_cache=use_cache,
234
+ release_status=release_status,
235
+ follow_links=follow_links,
236
+ with_reverse_properties=with_reverse_properties,
237
+ )
238
+
239
+ @classmethod
240
+ def from_id(
241
+ cls,
242
+ id: str,
243
+ client: KGClient,
244
+ use_cache: bool = True,
245
+ release_status: str = "released",
246
+ scope: Optional[str] = None,
247
+ follow_links: Optional[Dict[str, Any]] = None,
248
+ with_reverse_properties: Optional[bool] = False,
249
+ ):
250
+ """
251
+ Retrieve an instance from the Knowledge Graph based on either its URI or UUID.
252
+
253
+ Args:
254
+ id (str): short-form (UUID) or long-form (URI) identifier for the KG instance.
255
+ client: a KGClient
256
+ release_status (str, optional): The scope of the lookup. Valid values are "released", "in progress", or "any".
257
+ Defaults to "released".
258
+ use_cache (bool): Whether to use cached data if they exist. Defaults to True.
259
+ follow_links (dict): The links in the graph to follow. Defaults to None.
260
+ with_reverse_properties (bool): Whether to include reverse properties. Defaults to False.
261
+
262
+ Returns:
263
+ Either a KGObject of the correct type, or None.
264
+ A return value of None means either the object doesn't exist
265
+ or the user doesn't have permission to access it.
266
+ """
267
+ release_status = handle_scope_keyword(scope, release_status)
268
+ if hasattr(cls, "type_") and cls.type_:
269
+ if id.startswith("http"):
270
+ fn = cls.from_uri
271
+ else:
272
+ fn = cls.from_uuid
273
+ return fn(
274
+ id,
275
+ client,
276
+ use_cache=use_cache,
277
+ release_status=release_status,
278
+ follow_links=follow_links,
279
+ with_reverse_properties=with_reverse_properties,
280
+ )
281
+ else:
282
+ # if we don't know the type
283
+ if id.startswith("http"):
284
+ uri = id
285
+ else:
286
+ uri = client.uri_from_uuid(id)
287
+ if follow_links is not None:
288
+ raise NotImplementedError
289
+ data = client.instance_from_full_uri(uri, use_cache=use_cache, release_status=release_status)
290
+ type_ = data["@type"]
291
+ if isinstance(type_, list):
292
+ assert len(type_) == 1
293
+ type_ = type_[0]
294
+ cls_from_data = lookup_type(type_, OPENMINDS_VERSION)
295
+ return cls_from_data.from_jsonld(data, release_status=release_status)
296
+
297
+ @classmethod
298
+ def from_alias(
299
+ cls,
300
+ alias: str,
301
+ client: KGClient,
302
+ space: Optional[str] = None,
303
+ release_status: str = "released",
304
+ scope: Optional[str] = None,
305
+ follow_links: Optional[Dict[str, Any]] = None,
306
+ ):
307
+ """
308
+ Retrieve an instance from the Knowledge Graph based on its alias/short name.
309
+
310
+ Note that not all metadata classes have an alias.
311
+
312
+ Args:
313
+ alias (str): a short name used to identify a KG instance.
314
+ client: a KGClient
315
+ space (str, optional): the KG space to look in. Default is to look in all available spaces.
316
+ release_status (str, optional): The scope of the lookup. Valid values are "released", "in progress", or "any".
317
+ Defaults to "released".
318
+ follow_links (dict): The links in the graph to follow. Defaults to None.
319
+
320
+ """
321
+ release_status = handle_scope_keyword(scope, release_status)
322
+ # todo: move this to openminds generation, and include only in those subclasses
323
+ # that have an alias
324
+ # todo: also count 'lookup_name' as an alias
325
+ if "short_name" not in cls.property_names:
326
+ raise AttributeError(f"{cls.__name__} doesn't have an 'alias' or 'short_name' property")
327
+ candidates = as_list(
328
+ cls.list(
329
+ client,
330
+ size=20,
331
+ from_index=0,
332
+ api="query",
333
+ release_status=release_status,
334
+ space=space,
335
+ alias=alias,
336
+ follow_links=follow_links,
337
+ )
338
+ )
339
+ if len(candidates) == 0:
340
+ return None
341
+ elif len(candidates) == 1:
342
+ return candidates[0]
343
+ else: # KG query does a "contains" lookup, so can get multiple results
344
+ for candidate in candidates:
345
+ if candidate.alias == alias:
346
+ return candidate
347
+ warn(
348
+ "Multiple objects found with a similar alias, but none match exactly." "Returning the first one found."
349
+ )
350
+ return candidates[0]
351
+
352
+ @property
353
+ def uuid(self) -> Union[str, None]:
354
+ # todo: consider using client._kg_client.uuid_from_absolute_id
355
+ if self.id is not None:
356
+ value = self.id.split("/")[-1]
357
+ return str(UUID(value))
358
+ else:
359
+ return None
360
+
361
+ @classmethod
362
+ def uri_from_uuid(cls, uuid: str, client: KGClient) -> str:
363
+ """Convert an instances short-form identifier (a UUID) into the long-form (a URI)"""
364
+ return client.uri_from_uuid(uuid)
365
+
366
+ @classmethod
367
+ def list(
368
+ cls,
369
+ client: KGClient,
370
+ size: int = 100,
371
+ from_index: int = 0,
372
+ api: str = "auto",
373
+ release_status: str = "released",
374
+ scope: Optional[str] = None,
375
+ space: Optional[str] = None,
376
+ follow_links: Optional[Dict[str, Any]] = None,
377
+ with_reverse_properties: Optional[bool] = False,
378
+ **filters,
379
+ ) -> List[KGObject]:
380
+ """
381
+ List all objects of this type in the Knowledge Graph
382
+
383
+ Args:
384
+ client: KGClient object that handles the communication with the KG.
385
+ size (int, optional): The maximum number of instances to return. Default is 100.
386
+ from_index (int, optional): The index of the first instance to return. Default is 0.
387
+ api (str): The KG API to use for the query. Can be 'query', 'core', or 'auto'. Default is 'auto'.
388
+ release_status (str, optional): The scope to use for the query. Can be 'released', 'in progress', or 'all'. Default is 'released'.
389
+ space (str, optional): The KG space to be queried. If not specified, results from all accessible spaces will be included.
390
+ follow_links (dict): The links in the graph to follow. Defaults to None.
391
+ with_reverse_properties (bool): Whether to include reverse properties. Defaults to False.
392
+ filters: Optional keyword arguments representing filters to apply to the query.
393
+
394
+ Returns:
395
+ A list of instances of this class representing the objects returned by the KG query.
396
+
397
+ Raises:
398
+ ValueError: If invalid arguments are passed to the method.
399
+ NotImplementedError: If 'follow_links' is used with api='core'.
400
+
401
+ Example:
402
+
403
+ >>> from fairgraph import KGClient
404
+ >>> import fairgraph.openminds.controlled_terms as terms
405
+ >>> interneuron_types = terms.CellType.list(client, name="interneuron")
406
+ >>> for ct in interneuron_types[:4]:
407
+ ... print(f"{ct.name:<30} {ct.definition}")
408
+ cerebellar interneuron None
409
+ cholinergic interneuron An inhibitory interneuron which mainly uses the neurotrasmitter acetylcholine (ACh).
410
+ cortical interneuron None
411
+ fast spiking interneuron A parvalbumin positive GABAergic interneuron with a high-frequency firing pattern.
412
+
413
+ """
414
+ release_status = handle_scope_keyword(scope, release_status)
415
+
416
+ if api == "auto":
417
+ if filters:
418
+ api = "query"
419
+ else:
420
+ api = "core"
421
+
422
+ if api == "query":
423
+ query = cls.generate_query(
424
+ space=space,
425
+ client=client,
426
+ filters=filters,
427
+ follow_links=follow_links,
428
+ with_reverse_properties=with_reverse_properties,
429
+ )
430
+ instances = client.query(
431
+ query=query,
432
+ from_index=from_index,
433
+ size=size,
434
+ release_status=release_status,
435
+ ).data
436
+ elif api == "core":
437
+ if filters:
438
+ raise ValueError("Cannot use filters with api='core'")
439
+ if follow_links:
440
+ raise NotImplementedError("Following links with api='core' not yet implemented")
441
+ instances = client.list(
442
+ cls.type_, space=space, from_index=from_index, size=size, release_status=release_status
443
+ ).data
444
+ else:
445
+ raise ValueError("'api' must be either 'query', 'core', or 'auto'")
446
+ return [cls.from_jsonld(data=instance, release_status=release_status) for instance in instances]
447
+
448
+ @classmethod
449
+ def count(
450
+ cls,
451
+ client: KGClient,
452
+ api: str = "auto",
453
+ release_status: str = "released",
454
+ scope: Optional[str] = None,
455
+ space: Optional[str] = None,
456
+ **filters,
457
+ ) -> int:
458
+ """
459
+ Count the number of objects of a given type and (optionally) matching a given set of filters.
460
+
461
+ Args:
462
+ client: KGClient object that handles the communication with the KG.
463
+ api (str): The KG API to use for the query. Can be 'query', 'core', or 'auto'. Default is 'auto'.
464
+ release_status (str, optional): The scope to use for the query. Can be 'released', 'in progress', or 'all'. Default is 'released'.
465
+ space (str, optional): The KG space to be queried. If not specified, results from all accessible spaces will be counted.
466
+ filters: Optional keyword arguments representing filters to apply to the query.
467
+
468
+ Returns:
469
+ The number of instances of this class in the given space that would match the given filters,
470
+ or the total number of instances if no filters are provided.
471
+
472
+ Raises:
473
+ ValueError: If invalid arguments are passed to the method.
474
+ NotImplementedError: If 'follow_links' is used with api='core'.
475
+
476
+ Example:
477
+
478
+ >>> from fairgraph import KGClient
479
+ >>> import fairgraph.openminds.controlled_terms as terms
480
+ >>> terms.CellType.count(client, name="interneuron")
481
+ 8
482
+
483
+ """
484
+ release_status = handle_scope_keyword(scope, release_status)
485
+ if api == "auto":
486
+ if filters:
487
+ api = "query"
488
+ else:
489
+ api = "core"
490
+ if api == "query":
491
+ query = cls.generate_query(space=space, client=client, filters=filters)
492
+ response = client.query(query=query, from_index=0, size=1, release_status=release_status)
493
+ elif api == "core":
494
+ if filters:
495
+ raise ValueError("Cannot use filters with api='core'")
496
+ response = client.list(cls.type_, space=space, release_status=release_status, from_index=0, size=1)
497
+ return response.total
498
+
499
+ def _update_empty_properties(self, data: JSONdict):
500
+ """Replace any empty properties (value None) with the supplied data"""
501
+ cls = self.__class__
502
+ deserialized_data = cls._deserialize_data(data, include_id=True)
503
+ for prop in cls.all_properties:
504
+ current_value = getattr(self, prop.name, None)
505
+ if current_value is None:
506
+ value = deserialized_data[prop.name]
507
+ if value is not None:
508
+ setattr(self, prop.name, value)
509
+ assert self.remote_data is not None
510
+ for key, value in data.items():
511
+ if not (key.startswith("Q") or key == "@context"):
512
+ expanded_path = expand_uri(key, cls.context)
513
+ assert isinstance(expanded_path, str)
514
+ self.remote_data[expanded_path] = data[key]
515
+ if self.space is None and "https://core.kg.ebrains.eu/vocab/meta/space" in data:
516
+ self._space = data["https://core.kg.ebrains.eu/vocab/meta/space"]
517
+
518
+ def __eq__(self, other):
519
+ return not self.__ne__(other)
520
+
521
+ def __ne__(self, other):
522
+ if not isinstance(other, self.__class__):
523
+ return True
524
+ if self.id and other.id and self.id != other.id:
525
+ return True
526
+ for prop in self.properties:
527
+ val_self = getattr(self, prop.name)
528
+ val_other = getattr(other, prop.name)
529
+ if val_self != val_other:
530
+ return True
531
+ return False
532
+
533
+ def diff(self, other):
534
+ """
535
+ Return a dictionary containing the differences between two metadata objects.
536
+ """
537
+ differences = defaultdict(dict)
538
+ if not isinstance(other, self.__class__):
539
+ differences["type"] = (self.__class__, other.__class__)
540
+ else:
541
+ if self.id != other.id:
542
+ differences["id"] = (self.id, other.id)
543
+ for prop in self.properties:
544
+ val_self = getattr(self, prop.name)
545
+ val_other = getattr(other, prop.name)
546
+ if val_self != val_other:
547
+ differences["properties"][prop.name] = (val_self, val_other)
548
+ return differences
549
+
550
+ def exists(self, client: KGClient, ignore_duplicates: bool = False, in_spaces: Optional[List[str]] = None) -> bool:
551
+ """Check if this object already exists in the KnowledgeGraph"""
552
+
553
+ if self.id and self.id.startswith("http"):
554
+ # Since the KG now allows user-specified IDs we can't assume that the presence of
555
+ # an id means the object exists
556
+ data = client.instance_from_full_uri(
557
+ self.id, use_cache=True, release_status=self.release_status or "any", require_full_data=False
558
+ )
559
+ if self._raw_remote_data is None:
560
+ self._raw_remote_data = data
561
+ obj_exists = bool(data)
562
+ if obj_exists:
563
+ self._update_empty_properties(data) # also updates `remote_data`
564
+ return obj_exists
565
+ else:
566
+ try:
567
+ query_filter = self._build_existence_query()
568
+ except CannotBuildExistenceQuery:
569
+ return False
570
+
571
+ if query_filter is None:
572
+ # if there's no existence query and no ID, we allow
573
+ # duplicate entries
574
+ return False
575
+ else:
576
+ query_cache_key = generate_cache_key(query_filter)
577
+ if query_cache_key in save_cache[self.__class__]:
578
+ # Because the KnowledgeGraph is only eventually consistent, an instance
579
+ # that has just been written to the KG may not appear in the query.
580
+ # Therefore we cache the query when creating an instance and
581
+ # where exists() returns True
582
+ self.id = save_cache[self.__class__][query_cache_key]
583
+ cached_obj = object_cache.get(self.id)
584
+ if cached_obj and cached_obj.remote_data:
585
+ self._raw_remote_data = cached_obj._raw_remote_data
586
+ self.remote_data = cached_obj.remote_data # copy or update needed?
587
+ return True
588
+
589
+ query = self.__class__.generate_minimal_query(
590
+ client=client,
591
+ filters=query_filter,
592
+ )
593
+
594
+ try:
595
+ instances = client.query(query=query, size=2, release_status="any", restrict_to_spaces=in_spaces).data
596
+ except ConnectionError as err:
597
+ if "RemoteDisconnected" in str(err):
598
+ warn(
599
+ f"Timeout when checking for existence of object {self}."
600
+ "Returning False, check for possible creation of duplicate instances."
601
+ )
602
+ return False
603
+
604
+ if instances:
605
+ if len(instances) > 1 and not ignore_duplicates:
606
+ # we might want to consider running a second query with "equals" rather than "contains"
607
+ raise Exception(
608
+ f"Existence query is not specific enough. Type: {self.__class__.__name__}; filters: {query_filter}"
609
+ )
610
+
611
+ # it seems that sometimes the "query" endpoint returns instances
612
+ # which the "instances" endpoint doesn't know about, so here we double check that
613
+ # the instance can be found
614
+ instance = client.instance_from_full_uri(instances[0]["@id"], release_status="any")
615
+ if instance is None:
616
+ return False
617
+
618
+ self.id = instance["@id"]
619
+ assert isinstance(self.id, str)
620
+ save_cache[self.__class__][query_cache_key] = self.id
621
+ self._update_empty_properties(instance) # also updates `remote_data`
622
+ return bool(instances)
623
+
624
+ def modified_data(self) -> JSONdict:
625
+ """
626
+ Return a dict containing the properties that have been modified locally
627
+ from the values originally obtained from the Knowledge Graph.
628
+ """
629
+
630
+ def values_are_equal(local, remote):
631
+ if type(local) != type(remote):
632
+ return False
633
+ if isinstance(local, list):
634
+ if len(local) != len(remote):
635
+ return False
636
+ return all(values_are_equal(a, b) for a, b in zip(local, remote))
637
+ elif isinstance(local, dict):
638
+ return all(
639
+ values_are_equal(local[key], remote.get(key, None))
640
+ for key in local.keys()
641
+ if not (local[key] is None and key not in remote)
642
+ )
643
+ else:
644
+ return local == remote
645
+
646
+ current_data = normalize_data(
647
+ self.to_jsonld(include_empty_properties=True, embed_linked_nodes=False), self.context
648
+ )
649
+ modified_data = {}
650
+ for key, current_value in current_data.items():
651
+ if not key.startswith("@"):
652
+ assert key.startswith("http") # keys should all be expanded by this point
653
+ assert self.remote_data is not None
654
+ remote_value = self.remote_data.get(key, None)
655
+ if not values_are_equal(current_value, remote_value):
656
+ modified_data[key] = current_value
657
+ return modified_data
658
+
659
+ def save(
660
+ self,
661
+ client: KGClient,
662
+ space: Optional[str] = None,
663
+ recursive: bool = True,
664
+ activity_log: Optional[ActivityLog] = None,
665
+ replace: bool = False,
666
+ ignore_auth_errors: bool = False,
667
+ ignore_duplicates: bool = False,
668
+ ):
669
+ """
670
+ Store the current object in the Knowledge Graph, either updating an existing instance
671
+ or creating a new one as appropriate.
672
+
673
+ Args:
674
+ client: KGClient object that handles the communication with the KG.
675
+ space (str, optional): The KG space to save the object in. If not provided, a default space is used depending on the object type.
676
+ recursive (bool, optional): Whether to recursively save any children of this object. Defaults to True.
677
+ activity_log (ActivityLog, optional): An `ActivityLog` instance to log the operations performed during the save operation.
678
+ This is particularly helpful with `recursive=True`.
679
+ replace (bool, optional): Whether to completely replace an existing KG instance with this one, or just update the existing object
680
+ with any modified properties. Defaults to False.
681
+ ignore_auth_errors (bool, optional): Whether to continue silently when encountering authentication errors. Defaults to False.
682
+ ignore_duplicates (bool, optional): Whether to ignore the existence of multiple objects with the same properties
683
+ (and consider only the first in the list), or to raise an Exception. Defaults to False.
684
+
685
+ Raises:
686
+ - An `AuthorizationError` if the current user is not authorized to perform the requested operation.
687
+
688
+ """
689
+ if recursive:
690
+ for prop in self.properties:
691
+ # We do not save reverse properties, those objects must be saved separately.
692
+ # This could be revisited, but we'll have to be careful about loops
693
+ # if saving recursively
694
+ values = getattr(self, prop.name)
695
+ for value in as_list(values):
696
+ if isinstance(value, ContainsMetadata):
697
+ target_space: Optional[str]
698
+ if (
699
+ isinstance(value, KGObject)
700
+ and value.__class__.default_space == "controlled"
701
+ and value.exists(client, ignore_duplicates=ignore_duplicates)
702
+ and value.space == "controlled"
703
+ ):
704
+ continue
705
+ elif value.space:
706
+ target_space = value.space
707
+ elif space is None and self.space is not None:
708
+ target_space = self.space
709
+ else:
710
+ target_space = space
711
+ if target_space == "controlled":
712
+ assert isinstance(value, KGObject) # for type checking
713
+ if (
714
+ value.exists(client, ignore_duplicates=ignore_duplicates)
715
+ and value.space == "controlled"
716
+ ):
717
+ continue
718
+ else:
719
+ raise AuthorizationError("Cannot write to controlled space")
720
+ value.save(
721
+ client,
722
+ space=target_space,
723
+ recursive=True,
724
+ activity_log=activity_log,
725
+ ignore_duplicates=ignore_duplicates,
726
+ )
727
+ if space is None:
728
+ if self.space is None:
729
+ space = self.__class__.default_space
730
+ else:
731
+ space = self.space
732
+ logger.info(f"Saving a {self.__class__.__name__} in space {space}")
733
+ if self.exists(client, ignore_duplicates=ignore_duplicates, in_spaces=[space]):
734
+ if not self.allow_update:
735
+ logger.info(f" - not updating {self.__class__.__name__}(id={self.id}), update not allowed by user")
736
+ if activity_log:
737
+ activity_log.update(item=self, delta=None, space=space, entry_type="no-op")
738
+ else:
739
+ # update
740
+ local_data = normalize_data(
741
+ self.to_jsonld(include_empty_properties=False, embed_linked_nodes=False),
742
+ self.context
743
+ )
744
+ if replace:
745
+ logger.info(f" - replacing - {self.__class__.__name__}(id={self.id})")
746
+ if activity_log:
747
+ activity_log.update(item=self, delta=local_data, space=space, entry_type="replacement")
748
+ try:
749
+ client.replace_instance(self.uuid, local_data)
750
+ # what does this return? Can we use it to update `remote_data`?
751
+ except AuthorizationError as err:
752
+ if ignore_auth_errors:
753
+ logger.error(str(err))
754
+ else:
755
+ raise
756
+ else:
757
+ self.remote_data = local_data
758
+ else:
759
+ modified_data = self.modified_data()
760
+ if modified_data:
761
+ logger.info(
762
+ f" - updating - {self.__class__.__name__}(id={self.id}) - properties changed: {modified_data.keys()}"
763
+ )
764
+ skip_update = False
765
+ if "storageSize" in modified_data:
766
+ warn("Removing storage size from update because this prop is currently locked by the KG")
767
+ modified_data.pop("storageSize")
768
+ skip_update = len(modified_data) == 0
769
+
770
+ if skip_update:
771
+ if activity_log:
772
+ activity_log.update(item=self, delta=None, space=space, entry_type="no-op")
773
+ else:
774
+ try:
775
+ # Note: if modified_data includes embedded objects
776
+ # then _all_ fields of the embedded objects must be provided,
777
+ # not only those that have changed.
778
+ client.update_instance(self.uuid, modified_data)
779
+ except AuthorizationError as err:
780
+ if ignore_auth_errors:
781
+ logger.error(str(err))
782
+ else:
783
+ raise
784
+ else:
785
+ self.remote_data = local_data
786
+ if activity_log:
787
+ activity_log.update(
788
+ item=self,
789
+ delta=modified_data,
790
+ space=space,
791
+ entry_type="update",
792
+ )
793
+ else:
794
+ logger.info(f" - not updating {self.__class__.__name__}(id={self.id}), unchanged")
795
+ if activity_log:
796
+ activity_log.update(item=self, delta=None, space=space, entry_type="no-op")
797
+ else:
798
+ # create new
799
+ local_data = normalize_data(
800
+ self.to_jsonld(include_empty_properties=False, embed_linked_nodes=False),
801
+ self.context
802
+ )
803
+ logger.info(" - creating instance with data {}".format(local_data))
804
+ if self.id and self.id.startswith("http"):
805
+ instance_id = self.uuid
806
+ else:
807
+ instance_id = None
808
+ try:
809
+ instance_data = client.create_new_instance(
810
+ local_data, space or self.__class__.default_space, instance_id=instance_id
811
+ )
812
+ except (AuthorizationError, ResourceExistsError) as err:
813
+ if ignore_auth_errors:
814
+ logger.error(str(err))
815
+ if activity_log:
816
+ activity_log.update(
817
+ item=self,
818
+ delta=local_data,
819
+ space=self.space,
820
+ entry_type="create-error",
821
+ )
822
+ else:
823
+ raise
824
+ else:
825
+ self.id = instance_data["@id"]
826
+ self._raw_remote_data = instance_data
827
+ self.remote_data = local_data
828
+ if activity_log:
829
+ activity_log.update(item=self, delta=instance_data, space=self.space, entry_type="create")
830
+
831
+ # not handled yet: if an existing object is in a different space to the one specified here,
832
+ # should we move it to the new space, or raise an Exception?
833
+ if self.id:
834
+ logger.debug(
835
+ "Updating cache for object {}. Current state: {}".format(
836
+ self.id, self.to_jsonld(embed_linked_nodes=False)
837
+ )
838
+ )
839
+ object_cache[self.id] = self
840
+ else:
841
+ logger.warning("Object has no id - see log for the underlying error")
842
+ return self.id
843
+
844
+ def delete(self, client: KGClient, ignore_not_found: bool = True):
845
+ """Delete the current metadata object from the KG.
846
+
847
+ If `ignore_not_found` is False, an exception will be raised if the object does
848
+ not exist. Otherwise, the method will finish silently.
849
+ """
850
+ client.delete_instance(self.uuid, ignore_not_found=ignore_not_found)
851
+ if self.id in object_cache:
852
+ object_cache.pop(self.id)
853
+
854
+ def dump(self, file_path, indent=2):
855
+ """
856
+ Save this object to a file in JSON-LD format.
857
+ """
858
+ LinkedMetadata.save(self, file_path, indent)
859
+
860
+ @classmethod
861
+ def by_name(
862
+ cls,
863
+ name: str,
864
+ client: Optional[KGClient] = None,
865
+ match: str = "equals",
866
+ all: bool = False,
867
+ space: Optional[str] = None,
868
+ release_status: str = "released",
869
+ scope: Optional[str] = None,
870
+ follow_links: Optional[Dict[str, Any]] = None,
871
+ ) -> Union[KGObject, List[KGObject], None]:
872
+ """
873
+ Retrieve an instance from the Knowledge Graph based on its name.
874
+
875
+ This includes properties "name", "lookup_label", "family_name", "full_name", "short_name", "abbreviation", and "synonyms".
876
+
877
+ Note that not all metadata classes have a name.
878
+
879
+ Args:
880
+ name (str): a string to search for.
881
+ client: a KGClient
882
+ match (str, optional): either "equals" (exact match - default) or "contains".
883
+ all (bool, optional): Whether to return all objects that match the name, or only the first. Defaults to False.
884
+ space (str, optional): the KG space to search in. Default is to search in all available spaces.
885
+ release_status (str, optional): The scope of the search. Valid values are "released", "in progress", or "any".
886
+ Defaults to "released".
887
+ follow_links (dict): The links in the graph to follow. Defaults to None.
888
+
889
+ """
890
+ release_status = handle_scope_keyword(scope, release_status)
891
+ # todo: move this to openminds generation, and include only in those subclasses
892
+ # that have a name-like property
893
+ namelike_properties = ("name", "lookup_label", "family_name", "full_name", "short_name", "abbreviation", "synonyms")
894
+ objects = []
895
+ if client:
896
+ kwargs = dict(space=space, release_status=release_status, api="query", follow_links=follow_links)
897
+ for prop_name in namelike_properties:
898
+ if prop_name in cls.property_names:
899
+ kwargs[prop_name] = name
900
+ break
901
+ objects = cls.list(client, **kwargs)
902
+ if match == "equals":
903
+ objects = [
904
+ obj for obj in objects
905
+ if any(
906
+ getattr(obj, prop_name, None) == name
907
+ for prop_name in namelike_properties
908
+ )
909
+ ]
910
+ elif hasattr(cls, "instances"): # controlled terms, etc.
911
+ if cls._instance_lookup is None:
912
+ cls._instance_lookup = {}
913
+ for instance in cls.instances():
914
+ keys = []
915
+ for prop_name in namelike_properties[:-1]: # handle 'synonyms' separately
916
+ if hasattr(instance, prop_name):
917
+ keys.append(getattr(instance, prop_name))
918
+ if hasattr(instance, "synonyms"):
919
+ for synonym in instance.synonyms or []:
920
+ keys.append(synonym)
921
+ for key in keys:
922
+ if key in cls._instance_lookup:
923
+ cls._instance_lookup[key].append(instance)
924
+ else:
925
+ cls._instance_lookup[key] = [instance]
926
+ if match == "equals":
927
+ objects = cls._instance_lookup.get(name, None)
928
+ elif match == "contains":
929
+ objects = []
930
+ for key, instances in cls._instance_lookup.items():
931
+ if name in key:
932
+ objects.extend(instances)
933
+ else:
934
+ raise ValueError("'match' must be either 'exact' or 'contains'")
935
+ if len(objects) == 0:
936
+ return None
937
+ elif all:
938
+ return objects
939
+ elif len(objects) == 1:
940
+ return objects[0]
941
+ else:
942
+ warn("Multiple objects with the same name, returning the first. " "Use 'all=True' to retrieve them all")
943
+ return objects[0]
944
+
945
+ def show(self, max_width: Optional[int] = 120, include_empty_properties=False):
946
+ """
947
+ Print a table showing the metadata contained in this object.
948
+ """
949
+ if not have_tabulate:
950
+ raise Exception("You need to install the tabulate module to use the `show()` method")
951
+ data = [
952
+ ("id", str(self.id)),
953
+ ("space", str(self.space)),
954
+ ("type", self.type_),
955
+ ]
956
+ for prop in self.__class__.all_properties:
957
+ value = getattr(self, prop.name, None)
958
+ if include_empty_properties or not isinstance(value, (type(None), KGQuery)):
959
+ data.append((prop.name, str(value)))
960
+ if max_width:
961
+ value_column_width = max_width - max(len(item[0]) for item in data)
962
+
963
+ def fit_column(value):
964
+ strv = value
965
+ if len(strv) > value_column_width:
966
+ strv = strv[: value_column_width - 4] + " ..."
967
+ return strv
968
+
969
+ data = [(k, fit_column(v)) for k, v in data]
970
+ print(tabulate(data, tablefmt="plain"))
971
+ # return tabulate(data, tablefmt='html') - also see https://bitbucket.org/astanin/python-tabulate/issues/57/html-class-options-for-tables
972
+
973
+ @classmethod
974
+ def generate_query(
975
+ cls,
976
+ client: KGClient,
977
+ space: Union[str, None],
978
+ filters: Optional[Dict[str, Any]] = None,
979
+ follow_links: Optional[Dict[str, Any]] = None,
980
+ with_reverse_properties: Optional[bool] = False,
981
+ label: Optional[str] = None,
982
+ ) -> Union[Dict[str, Any], None]:
983
+ """
984
+ Generate a KG query definition as a JSON-LD document.
985
+
986
+ Args:
987
+ client: KGClient object that handles the communication with the KG.
988
+ space (str, optional): if provided, restrict the query to metadata stored in the given KG space.
989
+ filters (dict): A dictonary defining search parameters for the query.
990
+ follow_links (dict): The links in the graph to follow. Defaults to None.
991
+ with_reverse_properties (dict): Whether to include reverse properties. Default False.
992
+ label (str, optional): a label for the query
993
+
994
+ Returns:
995
+ A JSON-LD document containing the KG query definition.
996
+
997
+ """
998
+ if space == "myspace":
999
+ real_space = client._private_space
1000
+ else:
1001
+ real_space = space
1002
+
1003
+ if filters:
1004
+ normalized_filters = cls.normalize_filter(expand_filter(filters))
1005
+ else:
1006
+ normalized_filters = None
1007
+ # first pass, we build the basic structure
1008
+ query = Query(
1009
+ node_type=cls.type_,
1010
+ label=label,
1011
+ space=real_space,
1012
+ properties=cls.generate_query_properties(follow_links, with_reverse_properties),
1013
+ )
1014
+ # second pass, we add filters
1015
+ query.properties.extend(cls.generate_query_filter_properties(normalized_filters))
1016
+ # third pass, we add sorting, which can only happen at the top level
1017
+ for prop in query.properties:
1018
+ if prop.name in ("name", "fullName", "lookupLabel"):
1019
+ prop.sorted = True
1020
+ # implementation note: the three-pass approach generates queries that are sometimes more verbose
1021
+ # than necessary, but it makes the logic easier to understand.
1022
+ return query.serialize()
1023
+
1024
+ @classmethod
1025
+ def generate_minimal_query(
1026
+ cls,
1027
+ client: KGClient,
1028
+ filters: Optional[Dict[str, Any]] = None,
1029
+ label: Optional[str] = None,
1030
+ ) -> Union[Dict[str, Any], None]:
1031
+ """
1032
+ Generate a minimal KG query definition as a JSON-LD document.
1033
+ Such a query returns only the @id of any instances that are found.
1034
+
1035
+ Args:
1036
+ client: KGClient object that handles the communication with the KG.
1037
+ filters (dict): A dictonary defining search parameters for the query.
1038
+ label (str, optional): a label for the query
1039
+
1040
+ Returns:
1041
+ A JSON-LD document containing the KG query definition.
1042
+
1043
+ """
1044
+ if filters:
1045
+ normalized_filters = cls.normalize_filter(expand_filter(filters))
1046
+ else:
1047
+ normalized_filters = None
1048
+ # first pass, we build the basic structure
1049
+ query = Query(
1050
+ node_type=cls.type_,
1051
+ label=label,
1052
+ space=None,
1053
+ properties=[QueryProperty("@type")],
1054
+ )
1055
+ # second pass, we add filters
1056
+ query.properties.extend(cls.generate_query_filter_properties(normalized_filters))
1057
+ return query.serialize()
1058
+
1059
+ def children(
1060
+ self, client: KGClient, follow_links: Optional[Dict[str, Any]] = None
1061
+ ) -> List[RepresentsSingleObject]:
1062
+ """Return a list of child objects."""
1063
+ if follow_links:
1064
+ self.resolve(client, follow_links=follow_links)
1065
+ all_children = []
1066
+ for prop in self.properties:
1067
+ if prop.is_link:
1068
+ children = as_list(getattr(self, prop.name))
1069
+ all_children.extend(children)
1070
+ if follow_links:
1071
+ for child in children:
1072
+ all_children.extend(child.children(client))
1073
+ return all_children
1074
+
1075
+ def export(self, path: str, single_file: bool = False):
1076
+ """
1077
+ Export metadata as files in JSON-LD format.
1078
+
1079
+ If any objects do not have IDs, these will be generated.
1080
+
1081
+ If `single_file` is False, then `path` must be the path to a directory,
1082
+ and each object will be exported as a file named for the object ID.
1083
+
1084
+ If `single_file` is True, then `path` should be the path to a file
1085
+ with extension ".jsonld". This file will contain metadata for all objects.
1086
+ """
1087
+ raise NotImplementedError("todo")