ose-plugin-hbcp 0.2.5__tar.gz
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.
- ose_plugin_hbcp-0.2.5/PKG-INFO +35 -0
- ose_plugin_hbcp-0.2.5/README.md +27 -0
- ose_plugin_hbcp-0.2.5/pyproject.toml +19 -0
- ose_plugin_hbcp-0.2.5/setup.cfg +4 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp/__init__.py +11 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp/py.typed +0 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp/search_api/APIClient.py +351 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp/search_api/APIService.py +191 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp/search_api/HttpError.py +9 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp/search_api/__init__.py +0 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp.egg-info/PKG-INFO +35 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp.egg-info/SOURCES.txt +13 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp.egg-info/dependency_links.txt +1 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp.egg-info/requires.txt +1 -0
- ose_plugin_hbcp-0.2.5/src/ose_plugin_hbcp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ose-plugin-hbcp
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: OntoSpreadEd plugin for HBCP common services
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: ose-core==0.2.5
|
|
8
|
+
|
|
9
|
+
# OSE Plugin: HBCP
|
|
10
|
+
|
|
11
|
+
OntoSpreadEd plugin for HBCP (Human Behaviour Change Project) services.
|
|
12
|
+
|
|
13
|
+
## Description
|
|
14
|
+
|
|
15
|
+
This plugin provides common functionality for HBCP-related ontology projects. It serves as a base plugin with shared services used by derived plugins like AddictO and BCIO.
|
|
16
|
+
|
|
17
|
+
Features:
|
|
18
|
+
- Search API client for external vocabulary services
|
|
19
|
+
- HTTP error handling utilities
|
|
20
|
+
- Common service infrastructure
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ose-plugin-hbcp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Python 3.12+
|
|
31
|
+
- ose-core
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
LGPL-3.0-or-later
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# OSE Plugin: HBCP
|
|
2
|
+
|
|
3
|
+
OntoSpreadEd plugin for HBCP (Human Behaviour Change Project) services.
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
This plugin provides common functionality for HBCP-related ontology projects. It serves as a base plugin with shared services used by derived plugins like AddictO and BCIO.
|
|
8
|
+
|
|
9
|
+
Features:
|
|
10
|
+
- Search API client for external vocabulary services
|
|
11
|
+
- HTTP error handling utilities
|
|
12
|
+
- Common service infrastructure
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install ose-plugin-hbcp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- Python 3.12+
|
|
23
|
+
- ose-core
|
|
24
|
+
|
|
25
|
+
## License
|
|
26
|
+
|
|
27
|
+
LGPL-3.0-or-later
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ose-plugin-hbcp"
|
|
3
|
+
version = "0.2.5"
|
|
4
|
+
description = "OntoSpreadEd plugin for HBCP common services"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"ose-core==0.2.5",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["setuptools >= 61.0"]
|
|
13
|
+
build-backend = "setuptools.build_meta"
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
ose-core = { workspace = true }
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from ose.model.Plugin import Plugin
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
HBCPPlugin = Plugin(
|
|
5
|
+
id="org.bssofoundry.hbcp",
|
|
6
|
+
name="HBCP Plugin",
|
|
7
|
+
version="0.1.0",
|
|
8
|
+
description="Plugin for HBCP services and workflows. Provides common functionality for derived plugins.",
|
|
9
|
+
contents=[],
|
|
10
|
+
components=[],
|
|
11
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import urllib
|
|
7
|
+
from abc import ABCMeta
|
|
8
|
+
from itertools import groupby
|
|
9
|
+
from typing import Union, List, Literal, Optional, Dict, Tuple, Any, TypeVar
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
import async_lru
|
|
13
|
+
from attr import dataclass
|
|
14
|
+
|
|
15
|
+
from ose.model.Result import Result
|
|
16
|
+
from ose.model.Term import Term
|
|
17
|
+
from ose.model.TermIdentifier import TermIdentifier
|
|
18
|
+
from .HttpError import HttpError
|
|
19
|
+
|
|
20
|
+
T = TypeVar('T')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class APIPage:
|
|
25
|
+
items: List[T]
|
|
26
|
+
page: int
|
|
27
|
+
total_items: int
|
|
28
|
+
next_page: int
|
|
29
|
+
first_page: int
|
|
30
|
+
last_page: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class APIClient(abc.ABC, metaclass=ABCMeta):
|
|
34
|
+
_logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_term_link_to_relation_mapping: Dict[str, Tuple[TermIdentifier, Literal["single", "multiple", "multiple-per-line"]]]
|
|
37
|
+
|
|
38
|
+
def __init__(self, api_url: str, session: aiohttp.ClientSession, auth_token: Optional[str] = None, debug=False):
|
|
39
|
+
self._auth_token = auth_token
|
|
40
|
+
self._base = api_url
|
|
41
|
+
self._session = session
|
|
42
|
+
self._default_params = {"test": "1"} if debug else {}
|
|
43
|
+
|
|
44
|
+
def _request(self,
|
|
45
|
+
sub_url: Union[str, List[str]],
|
|
46
|
+
method: Literal["get", "post", "put", "patch", "delete"],
|
|
47
|
+
data: Optional[Dict] = None,
|
|
48
|
+
headers: Optional[Dict] = None,
|
|
49
|
+
query_params: Optional[Dict[str, str]] = None) -> aiohttp.client._RequestContextManager:
|
|
50
|
+
if data is None:
|
|
51
|
+
data = {}
|
|
52
|
+
if headers is None:
|
|
53
|
+
headers = {}
|
|
54
|
+
if query_params is None:
|
|
55
|
+
query_params = {} if method == "get" else self._default_params
|
|
56
|
+
|
|
57
|
+
default_headers = {
|
|
58
|
+
"accept": "application/ld+json",
|
|
59
|
+
"Content-Type": "application/ld+json"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if self._auth_token is not None:
|
|
63
|
+
default_headers["X-AUTH-TOKEN"] = self._auth_token
|
|
64
|
+
|
|
65
|
+
url = self._base
|
|
66
|
+
if isinstance(sub_url, list):
|
|
67
|
+
url = urllib.parse.urljoin(url, "/".join(sub_url))
|
|
68
|
+
else:
|
|
69
|
+
url = urllib.parse.urljoin(url, sub_url)
|
|
70
|
+
|
|
71
|
+
query_string = urllib.parse.urlencode(query_params)
|
|
72
|
+
if query_string != "":
|
|
73
|
+
url += "?" + query_string
|
|
74
|
+
|
|
75
|
+
headers = {**default_headers, **headers}
|
|
76
|
+
if method != "get":
|
|
77
|
+
self._logger.info(f"{method} {json.dumps(data)}")
|
|
78
|
+
|
|
79
|
+
# return self._session.request("get", "https://example.com/")
|
|
80
|
+
# else:
|
|
81
|
+
return self._session.request(method, url, headers=headers, json=data)
|
|
82
|
+
|
|
83
|
+
def convert_to_api_term(self, term: Term, with_references=True) -> Dict:
|
|
84
|
+
data = {
|
|
85
|
+
"id": term.id.strip(),
|
|
86
|
+
"uri": f"/terms/{term.id.strip()}",
|
|
87
|
+
"label": term.label.strip(),
|
|
88
|
+
**dict([(k,
|
|
89
|
+
(((lambda x: x) if multiplicity == "multiple" else "\n".join)(term.get_relation_values(i)))
|
|
90
|
+
if multiplicity.startswith("multiple") else term.get_relation_value(i))
|
|
91
|
+
for k, (i, multiplicity) in self._term_link_to_relation_mapping.items()]),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if data['curationStatus'] in ['To Be Discussed', 'In Discussion', None]:
|
|
95
|
+
data['curationStatus'] = 'Proposed'
|
|
96
|
+
|
|
97
|
+
definition_source = self._merge_definition_source_and_id(term)
|
|
98
|
+
if definition_source:
|
|
99
|
+
data["definitionSource"] = definition_source
|
|
100
|
+
|
|
101
|
+
data['eCigO'] = data.get('eCigO', 'false') in ["True", "true", "TRUE", "1"]
|
|
102
|
+
data['fuzzySet'] = data.get('fuzzySet', 'false') in ["True", "true", "TRUE", "1"]
|
|
103
|
+
|
|
104
|
+
if data.get('curationStatus', None) is None:
|
|
105
|
+
data['curationStatus'] = "Proposed"
|
|
106
|
+
data['curationStatus'] = data['curationStatus'].lower()
|
|
107
|
+
|
|
108
|
+
if with_references:
|
|
109
|
+
def key_fn(r: Union[Tuple[TermIdentifier, Any], TermIdentifier]) -> str:
|
|
110
|
+
return (r[0] if isinstance(r, tuple) else r).label
|
|
111
|
+
|
|
112
|
+
relations: List[Tuple[TermIdentifier, TermIdentifier]] = [(r, v) for r, v in term.relations if
|
|
113
|
+
isinstance(v, TermIdentifier) and v.is_resolved()]
|
|
114
|
+
relations.sort(key=key_fn)
|
|
115
|
+
|
|
116
|
+
data = {
|
|
117
|
+
**data,
|
|
118
|
+
"parentTerm": f"/terms/{term.sub_class_of[0].id}",
|
|
119
|
+
"logicalDefinition": next(iter(term.equivalent_to), None),
|
|
120
|
+
"termLinks": [{
|
|
121
|
+
"type": r,
|
|
122
|
+
"linkedTerms": [f"/terms/{e[1].id}" for e in g]
|
|
123
|
+
} for r, g in groupby(relations, key_fn)]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
async def convert_from_api_term(self, data: Dict, with_references=True) -> Term:
|
|
129
|
+
rev = data["termRevisions"][0]
|
|
130
|
+
|
|
131
|
+
i: str = data["id"]
|
|
132
|
+
label: str = rev["label"]
|
|
133
|
+
|
|
134
|
+
parent_term = rev.get("parentTerm", None)
|
|
135
|
+
parents: List[TermIdentifier] = [TermIdentifier(id=parent_term.split("/")[-1])] if parent_term else []
|
|
136
|
+
|
|
137
|
+
logical_definition = rev.get("logicalDefinition", None)
|
|
138
|
+
equivalent_to: List[str] = [logical_definition] if logical_definition else []
|
|
139
|
+
|
|
140
|
+
disjoint_with = []
|
|
141
|
+
|
|
142
|
+
relations: List[Tuple[TermIdentifier, Any]] = []
|
|
143
|
+
if with_references:
|
|
144
|
+
async with self._request([rev["@id"], "term_links"], "get") as response:
|
|
145
|
+
term_links = (await response.json())["hydra:member"]
|
|
146
|
+
relations = [
|
|
147
|
+
(TermIdentifier(label=link["type"]),
|
|
148
|
+
TermIdentifier(linked["id"], linked["termRevisions"][0]["label"]))
|
|
149
|
+
for link in term_links
|
|
150
|
+
for linked in link["linkedTerms"]
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
relations += [(identifier, v)
|
|
154
|
+
for key, (identifier, multiplicity) in self._term_link_to_relation_mapping.items() if key in rev
|
|
155
|
+
for v in (
|
|
156
|
+
((lambda x: x) if multiplicity == "multiple" else str.splitlines)(rev[key])
|
|
157
|
+
if multiplicity.startswith("multiple") else [rev[key]])
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
relations += [(identifier, v)
|
|
161
|
+
for key, (identifier, multiplicity) in self._term_link_to_relation_mapping.items() if
|
|
162
|
+
key in data and key not in rev
|
|
163
|
+
for v in (
|
|
164
|
+
((lambda x: x) if multiplicity == "multiple" else str.splitlines)(data[key])
|
|
165
|
+
if multiplicity.startswith("multiple") else [data[key]])
|
|
166
|
+
]
|
|
167
|
+
definition_source = rev.get("definitionSource", None)
|
|
168
|
+
if definition_source is not None:
|
|
169
|
+
parts = definition_source.split("\n")
|
|
170
|
+
if len(parts) == 1:
|
|
171
|
+
relations.append((TermIdentifier(id="IAO:0000119", label="definition source"), parts[0]))
|
|
172
|
+
elif len(parts) == 2:
|
|
173
|
+
relations += [
|
|
174
|
+
(TermIdentifier(id="rdfs:isDefinedBy", label="rdfs:isDefinedBy"), parts[0]),
|
|
175
|
+
(TermIdentifier(id="IAO:0000119", label="definition source"), parts[1])
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
return Term(i, label, ("web", -1), relations, parents, equivalent_to, disjoint_with)
|
|
179
|
+
|
|
180
|
+
@async_lru.alru_cache(maxsize=None)
|
|
181
|
+
async def _get_term(self, term: str) -> Optional[Dict]:
|
|
182
|
+
async with self._request(["terms", term], "get") as r:
|
|
183
|
+
if r.status == 404:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
if not r.ok:
|
|
187
|
+
raise HttpError(r.status,
|
|
188
|
+
f"{r.status}, message={r.reason!r}, url={r.request_info.real_url!r}",
|
|
189
|
+
await r.json())
|
|
190
|
+
|
|
191
|
+
return await r.json()
|
|
192
|
+
|
|
193
|
+
async def get_term(self, term: Union[Term, str, TermIdentifier], with_references=True) -> Optional[Term]:
|
|
194
|
+
term_id = term.id if isinstance(term, Term) or isinstance(term, TermIdentifier) else term
|
|
195
|
+
|
|
196
|
+
data = await self._get_term(term_id)
|
|
197
|
+
|
|
198
|
+
if data is None:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
return await self.convert_from_api_term(data, with_references)
|
|
202
|
+
|
|
203
|
+
async def create_term(self, term: Term):
|
|
204
|
+
data = self.convert_to_api_term(term)
|
|
205
|
+
|
|
206
|
+
async with self._request("terms", "post", data) as r:
|
|
207
|
+
if not r.ok:
|
|
208
|
+
raise HttpError(r.status,
|
|
209
|
+
f"{r.status}, message={r.reason!r}, url={r.request_info.real_url!r}",
|
|
210
|
+
await r.json())
|
|
211
|
+
|
|
212
|
+
async def declare_term(self, term: Union[Term, TermIdentifier]) -> Result[Union[bool]]:
|
|
213
|
+
exists = (await self.get_term(term, with_references=False)) is not None
|
|
214
|
+
if exists:
|
|
215
|
+
return Result(False)
|
|
216
|
+
|
|
217
|
+
data = self.convert_to_api_term(term, False)
|
|
218
|
+
|
|
219
|
+
async with self._request("terms", "post", data) as r:
|
|
220
|
+
|
|
221
|
+
if not r.ok:
|
|
222
|
+
result = Result()
|
|
223
|
+
error = await r.text()
|
|
224
|
+
result.error(status_code=r.status, body=error)
|
|
225
|
+
self._logger.error(f"Failed to declare term '{term.label} ({term.id})': {error}")
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
return Result(True)
|
|
229
|
+
|
|
230
|
+
async def update_term(self, term: Term, msg: str, with_references=True):
|
|
231
|
+
existing = await self.get_term(term, with_references)
|
|
232
|
+
if existing is not None:
|
|
233
|
+
if not self.terms_equal(existing, term):
|
|
234
|
+
api_term = self.convert_to_api_term(term)
|
|
235
|
+
api_term['revisionMessage'] = msg
|
|
236
|
+
|
|
237
|
+
async with self._request(["terms", term.id], "put", api_term, headers={
|
|
238
|
+
"Content-Type": "application/json"
|
|
239
|
+
}) as r:
|
|
240
|
+
if not r.ok:
|
|
241
|
+
raise HttpError(r.status,
|
|
242
|
+
f"{r.status}, message={r.reason!r}, url={r.request_info.real_url!r}",
|
|
243
|
+
await r.json())
|
|
244
|
+
|
|
245
|
+
def delete_term(self, term: Union[Term, str, TermIdentifier]):
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
def _merge_definition_source_and_id(self, term: Term) -> Optional[str]:
|
|
249
|
+
defined_by = term.get_relation_value(TermIdentifier(id="rdfs:isDefinedBy", label="rdfs:isDefinedBy"))
|
|
250
|
+
definition_source = term.get_relation_value(TermIdentifier(id="IAO:0000119", label="definition source"))
|
|
251
|
+
return "\n".join(d for d in [defined_by, definition_source] if d is not None)
|
|
252
|
+
|
|
253
|
+
def _terms_equal(self, ignore_if_not_exists: List[str], new: Term, old: Term):
|
|
254
|
+
def value_eq(v1, v2) -> bool:
|
|
255
|
+
if isinstance(v1, bool):
|
|
256
|
+
return (str(v2).lower().strip() in ['true', '1']) == v1
|
|
257
|
+
if isinstance(v2, bool):
|
|
258
|
+
return (str(v1).lower().strip() in ['true', '1']) == v2
|
|
259
|
+
if isinstance(v1, str) and isinstance(v2, str):
|
|
260
|
+
return v1.lower().strip() == v2.lower().strip()
|
|
261
|
+
if v1 is None and v2 == 'None' or v2 is None and v1 == 'None':
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
if isinstance(v1, TermIdentifier) and isinstance(v2, TermIdentifier):
|
|
265
|
+
if v1.id is None or v2.id is None:
|
|
266
|
+
return v1.label == v2.label
|
|
267
|
+
else:
|
|
268
|
+
return v1.id == v2.id
|
|
269
|
+
if isinstance(v1, list) and isinstance(v2, list):
|
|
270
|
+
return len(v1) == len(v2) and all(any(value_eq(x1, x2) for x2 in v2) for x1 in v1)
|
|
271
|
+
|
|
272
|
+
return v1 == v2
|
|
273
|
+
|
|
274
|
+
result = True
|
|
275
|
+
relations_old = dict(
|
|
276
|
+
[(k, [e[1] for e in g]) for k, g in groupby(sorted(old.relations), key=lambda x: x[0].label)])
|
|
277
|
+
relations_new = dict(
|
|
278
|
+
[(k, [e[1] for e in g]) for k, g in groupby(sorted(new.relations), key=lambda x: x[0].label)])
|
|
279
|
+
for r, v in relations_old.items():
|
|
280
|
+
if r in ["rdfs:isDefinedBy", "definition source", "example of usage"]:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
if r in relations_new:
|
|
284
|
+
if not value_eq(relations_new[r], v):
|
|
285
|
+
self._logger.debug(f"DIFF <{r}>:\n {v}\n {relations_new[r]}")
|
|
286
|
+
result = False
|
|
287
|
+
else:
|
|
288
|
+
if r not in ignore_if_not_exists:
|
|
289
|
+
self._logger.debug(f"DELETED <{r}>: {v}")
|
|
290
|
+
result = False
|
|
291
|
+
for r, v in relations_new.items():
|
|
292
|
+
if r in ["rdfs:isDefinedBy", "definition source"]:
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
if r not in relations_old:
|
|
296
|
+
if r not in ignore_if_not_exists:
|
|
297
|
+
self._logger.debug(f"CREATED <{r}>: {v}")
|
|
298
|
+
result = False
|
|
299
|
+
old_definition_source = self._merge_definition_source_and_id(old)
|
|
300
|
+
new_definition_source = self._merge_definition_source_and_id(new)
|
|
301
|
+
if not (value_eq(old_definition_source, new_definition_source)):
|
|
302
|
+
self._logger.debug(f"DIFF <definitionSource>\n {[old_definition_source]}\n {[new_definition_source]}")
|
|
303
|
+
result = False
|
|
304
|
+
old_examples = old.get_relation_values(TermIdentifier(label="example of usage"))
|
|
305
|
+
new_examples = new.get_relation_values(TermIdentifier(label="example of usage"))
|
|
306
|
+
if isinstance(old_examples, list):
|
|
307
|
+
old_examples = "\n".join(old_examples)
|
|
308
|
+
if isinstance(new_examples, list):
|
|
309
|
+
new_examples = "\n".join(new_examples)
|
|
310
|
+
if not (value_eq(old_examples, new_examples)):
|
|
311
|
+
self._logger.debug(f"DIFF <examples>\n {[old_examples]}\n {[new_examples]}")
|
|
312
|
+
result = False
|
|
313
|
+
if not (old.id == new.id):
|
|
314
|
+
self._logger.debug(f"DIFF <id>\n {old.id}\n {new.id}")
|
|
315
|
+
result = False
|
|
316
|
+
if not (value_eq(old.label, new.label)):
|
|
317
|
+
self._logger.debug(f"DIFF <label>\n {old.label}\n {new.label}")
|
|
318
|
+
result = False
|
|
319
|
+
if not (value_eq(old.sub_class_of, new.sub_class_of)):
|
|
320
|
+
self._logger.debug(f"DIFF <sub_class_of>\n {old.sub_class_of}\n {new.sub_class_of}")
|
|
321
|
+
result = False
|
|
322
|
+
if not (value_eq(old.equivalent_to, new.equivalent_to)):
|
|
323
|
+
self._logger.debug(f"DIFF <equivalent_to>\n {old.equivalent_to}\n {new.equivalent_to}")
|
|
324
|
+
result = False
|
|
325
|
+
# # Apparently this is not used by BCIOSearch
|
|
326
|
+
# if not (value_eq(old.disjoint_with, new.disjoint_with)):
|
|
327
|
+
# self._logger.debug(f"DIFF <disjoint_with>\n {old.disjoint_with}\n {new.disjoint_with}")
|
|
328
|
+
# result = False
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
async def get_terms(self, page: int, items_per_page: int = 50) -> APIPage:
|
|
332
|
+
r = await self._request(f"/terms?page={page}&itemsPerPage={items_per_page}", "get")
|
|
333
|
+
|
|
334
|
+
r.raise_for_status()
|
|
335
|
+
|
|
336
|
+
data = await r.json()
|
|
337
|
+
|
|
338
|
+
view = data.get("hydra:view", dict())
|
|
339
|
+
next_page_m = re.search(r"page=(\d+)", view.get("hydra:next", ""))
|
|
340
|
+
next_page = int(next_page_m.group(1)) if next_page_m is not None else None
|
|
341
|
+
first_page_m = re.search(r"page=(\d+)", view.get("hydra:first", ""))
|
|
342
|
+
first_page = int(first_page_m.group(1)) if first_page_m is not None else None
|
|
343
|
+
last_page_m = re.search(r"page=(\d+)", view.get("hydra:last", ""))
|
|
344
|
+
last_page = int(last_page_m.group(1)) if last_page_m is not None else None
|
|
345
|
+
|
|
346
|
+
total_items = data.get("hydra:totalItems", -1)
|
|
347
|
+
|
|
348
|
+
items = await asyncio.gather(
|
|
349
|
+
*(self.convert_from_api_term(term, False) for term in data.get("hydra:member", [])))
|
|
350
|
+
|
|
351
|
+
return APIPage(items, page, total_items, next_page, first_page, last_page)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
from typing import List, Callable, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import pyhornedowl
|
|
7
|
+
|
|
8
|
+
from ose.services.ConfigurationService import ConfigurationService
|
|
9
|
+
from .APIClient import APIClient
|
|
10
|
+
from .HttpError import HttpError
|
|
11
|
+
from ose.model.ExcelOntology import ExcelOntology
|
|
12
|
+
from ose.model.Result import Result
|
|
13
|
+
from ose.model.Term import Term
|
|
14
|
+
from ose.model.TermIdentifier import TermIdentifier
|
|
15
|
+
from ose.utils import lower
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class APIService[T: APIClient](abc.ABC):
|
|
19
|
+
_config: ConfigurationService
|
|
20
|
+
_logger: logging.Logger
|
|
21
|
+
_api_client: T
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: ConfigurationService, api_client: T):
|
|
24
|
+
self._config = config
|
|
25
|
+
self._api_client = api_client
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abc.abstractmethod
|
|
29
|
+
def repository_name(self) -> str: ...
|
|
30
|
+
|
|
31
|
+
async def update_api(
|
|
32
|
+
self,
|
|
33
|
+
ontology: ExcelOntology,
|
|
34
|
+
external_ontologies: List[str],
|
|
35
|
+
revision_message: str,
|
|
36
|
+
update_fn: Optional[Callable[[int, int, str], None]] = None,
|
|
37
|
+
) -> Result[Tuple]:
|
|
38
|
+
self._logger.info("Starting update")
|
|
39
|
+
result = Result()
|
|
40
|
+
|
|
41
|
+
config = self._config.get(self.repository_name)
|
|
42
|
+
|
|
43
|
+
if config is None:
|
|
44
|
+
self._logger.error(f"No config for repository '{self.repository_name}'!")
|
|
45
|
+
|
|
46
|
+
external_ontologies_loaded = []
|
|
47
|
+
for external in external_ontologies:
|
|
48
|
+
ext_ontology = pyhornedowl.open_ontology(external)
|
|
49
|
+
|
|
50
|
+
if config is not None:
|
|
51
|
+
for prefix, iri in config.prefixes.items():
|
|
52
|
+
ext_ontology.prefix_mapping.add_prefix(prefix, iri)
|
|
53
|
+
|
|
54
|
+
external_ontologies_loaded.append(ext_ontology)
|
|
55
|
+
|
|
56
|
+
external_classes = [(o, o.get_classes()) for o in external_ontologies_loaded]
|
|
57
|
+
total = sum(len(classes) for o, classes in external_classes) + len(ontology.terms())
|
|
58
|
+
step = 0
|
|
59
|
+
|
|
60
|
+
queue: List[Term] = []
|
|
61
|
+
|
|
62
|
+
# edit lock for modifying the queue or counters
|
|
63
|
+
lock = asyncio.Lock()
|
|
64
|
+
|
|
65
|
+
# Only allow 5 concurrent calls to the API
|
|
66
|
+
sem = asyncio.Semaphore(5)
|
|
67
|
+
|
|
68
|
+
async def handle_external(o: pyhornedowl.PyIndexedOntology, term_iri: str):
|
|
69
|
+
nonlocal step, total
|
|
70
|
+
async with sem:
|
|
71
|
+
async with lock:
|
|
72
|
+
step += 1
|
|
73
|
+
|
|
74
|
+
if update_fn:
|
|
75
|
+
update_fn(step, total, f"External - {term_iri}")
|
|
76
|
+
|
|
77
|
+
term_id = o.get_id_for_iri(term_iri)
|
|
78
|
+
if term_id is None:
|
|
79
|
+
self._logger.warning(f"Term has no id {term_iri}")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
term = ontology.term_by_id(term_id)
|
|
83
|
+
if term is None:
|
|
84
|
+
ext_label = o.get_annotation(term_iri, "http://www.w3.org/2000/01/rdf-schema#label")
|
|
85
|
+
ext_definition = o.get_annotation(term_iri, "http://purl.obolibrary.org/obo/IAO_0000115")
|
|
86
|
+
if ext_definition is None:
|
|
87
|
+
ext_definition = o.get_annotation(term_iri, "http://purl.obolibrary.org/obo/IAO_0000600")
|
|
88
|
+
if ext_definition is None:
|
|
89
|
+
ext_definition = "no definition provided for external entity"
|
|
90
|
+
result.warning(
|
|
91
|
+
type="external-no-definition",
|
|
92
|
+
msg="No definition was provided for the external "
|
|
93
|
+
+ f"entity '{ext_label}' ({term_id}). Using default instead.",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if ext_label is None:
|
|
97
|
+
result.warning(
|
|
98
|
+
type="external-no-label", msg=f'The external term "{term_id}" has no label. Skipping it'
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
ext_parents = o.get_superclasses(term_iri)
|
|
103
|
+
# If multiple parents check if they are (immediate) subclasses of each other and only take the most
|
|
104
|
+
# specific parent.
|
|
105
|
+
ext_parents -= set().union(*[o.get_ancestors(i) for i in ext_parents])
|
|
106
|
+
ext_parents = [TermIdentifier(id=o.get_id_for_iri(cls)) for cls in ext_parents]
|
|
107
|
+
ext_parents = [p for p in ext_parents if p.id is not None]
|
|
108
|
+
|
|
109
|
+
if len(ext_parents) == 0:
|
|
110
|
+
self._logger.warning(f"External term has no parents: {term_id}")
|
|
111
|
+
return
|
|
112
|
+
if len(ext_parents) > 1:
|
|
113
|
+
self._logger.warning(
|
|
114
|
+
f"Multiple parents defined for external term: {term_id}."
|
|
115
|
+
"Using only the lexicographical first entry."
|
|
116
|
+
)
|
|
117
|
+
ext_parents = [sorted(ext_parents)[0]]
|
|
118
|
+
|
|
119
|
+
ext_relations = [
|
|
120
|
+
(TermIdentifier("IAO:0000115", "definition"), ext_definition),
|
|
121
|
+
(TermIdentifier("IAO:0000114", "has curation status"), "External"),
|
|
122
|
+
]
|
|
123
|
+
ext_term = Term(term_id, ext_label, ("<external>", -1), ext_relations, ext_parents, [], [])
|
|
124
|
+
|
|
125
|
+
await self._api_client.declare_term(ext_term)
|
|
126
|
+
|
|
127
|
+
async with lock:
|
|
128
|
+
queue.append(ext_term)
|
|
129
|
+
total += 1
|
|
130
|
+
else:
|
|
131
|
+
await self._api_client.declare_term(term)
|
|
132
|
+
|
|
133
|
+
async with lock:
|
|
134
|
+
queue.append(term)
|
|
135
|
+
total += 1
|
|
136
|
+
|
|
137
|
+
tasks = [handle_external(o, term_iri) for o, classes in external_classes for term_iri in classes]
|
|
138
|
+
await asyncio.gather(*tasks)
|
|
139
|
+
|
|
140
|
+
async def declare_term(term: Term):
|
|
141
|
+
nonlocal step, total
|
|
142
|
+
async with sem:
|
|
143
|
+
async with lock:
|
|
144
|
+
step += 1
|
|
145
|
+
|
|
146
|
+
if update_fn:
|
|
147
|
+
update_fn(step, total, f"declaring '{term.label}' ({term.id})")
|
|
148
|
+
|
|
149
|
+
if lower(term.curation_status()) in ["obsolete", "pre-proposed"]:
|
|
150
|
+
self._logger.info(f"Skipping {term.curation_status()} term '{term.label}'")
|
|
151
|
+
else:
|
|
152
|
+
await self._api_client.declare_term(term)
|
|
153
|
+
|
|
154
|
+
async with lock:
|
|
155
|
+
queue.append(term)
|
|
156
|
+
total += 1
|
|
157
|
+
|
|
158
|
+
tasks = [declare_term(term) for term in ontology.terms()]
|
|
159
|
+
await asyncio.gather(*tasks)
|
|
160
|
+
|
|
161
|
+
async def work_queue(term: Term):
|
|
162
|
+
nonlocal step
|
|
163
|
+
async with sem:
|
|
164
|
+
async with lock:
|
|
165
|
+
step += 1
|
|
166
|
+
|
|
167
|
+
if update_fn:
|
|
168
|
+
update_fn(step, total, f"updating '{term.label}' ({term.id})")
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
await self._api_client.update_term(term, revision_message)
|
|
172
|
+
except HttpError as e:
|
|
173
|
+
result.error(type="http-error", details=e.message, response=e.response)
|
|
174
|
+
|
|
175
|
+
tasks = [work_queue(term) for term in queue]
|
|
176
|
+
await asyncio.gather(*tasks)
|
|
177
|
+
|
|
178
|
+
result.value = ()
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
async def get_all_terms(self) -> List[Term]:
|
|
182
|
+
next_page = 1
|
|
183
|
+
items = []
|
|
184
|
+
|
|
185
|
+
while next_page is not None:
|
|
186
|
+
page = await self._api_client.get_terms(next_page)
|
|
187
|
+
|
|
188
|
+
items.extend(page.items)
|
|
189
|
+
next_page = page.next_page
|
|
190
|
+
|
|
191
|
+
return items
|
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ose-plugin-hbcp
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: OntoSpreadEd plugin for HBCP common services
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: ose-core==0.2.5
|
|
8
|
+
|
|
9
|
+
# OSE Plugin: HBCP
|
|
10
|
+
|
|
11
|
+
OntoSpreadEd plugin for HBCP (Human Behaviour Change Project) services.
|
|
12
|
+
|
|
13
|
+
## Description
|
|
14
|
+
|
|
15
|
+
This plugin provides common functionality for HBCP-related ontology projects. It serves as a base plugin with shared services used by derived plugins like AddictO and BCIO.
|
|
16
|
+
|
|
17
|
+
Features:
|
|
18
|
+
- Search API client for external vocabulary services
|
|
19
|
+
- HTTP error handling utilities
|
|
20
|
+
- Common service infrastructure
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ose-plugin-hbcp
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- Python 3.12+
|
|
31
|
+
- ose-core
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
LGPL-3.0-or-later
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/ose_plugin_hbcp/__init__.py
|
|
4
|
+
src/ose_plugin_hbcp/py.typed
|
|
5
|
+
src/ose_plugin_hbcp.egg-info/PKG-INFO
|
|
6
|
+
src/ose_plugin_hbcp.egg-info/SOURCES.txt
|
|
7
|
+
src/ose_plugin_hbcp.egg-info/dependency_links.txt
|
|
8
|
+
src/ose_plugin_hbcp.egg-info/requires.txt
|
|
9
|
+
src/ose_plugin_hbcp.egg-info/top_level.txt
|
|
10
|
+
src/ose_plugin_hbcp/search_api/APIClient.py
|
|
11
|
+
src/ose_plugin_hbcp/search_api/APIService.py
|
|
12
|
+
src/ose_plugin_hbcp/search_api/HttpError.py
|
|
13
|
+
src/ose_plugin_hbcp/search_api/__init__.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ose-core==0.2.5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ose_plugin_hbcp
|