ose-plugin-hbcp 0.2.5__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.
@@ -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
@@ -0,0 +1,9 @@
1
+ import dataclasses
2
+ from typing import Any
3
+
4
+
5
+ @dataclasses.dataclass
6
+ class HttpError(Exception):
7
+ status_code: int
8
+ message: str
9
+ response: Any
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,10 @@
1
+ ose_plugin_hbcp/__init__.py,sha256=z4YuujZ0zcVLHuMlgXkJOtKJNusl-57jPiMom_712JM,283
2
+ ose_plugin_hbcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ ose_plugin_hbcp/search_api/APIClient.py,sha256=Kl1Xck06FCbQ2V8vlSZAQi7QYfHlwnymAXmW4C-iQyE,15239
4
+ ose_plugin_hbcp/search_api/APIService.py,sha256=2zYhciN5oM4Bng-wmsOmVij6OoWKdzDUgMWbMCpAh54,7303
5
+ ose_plugin_hbcp/search_api/HttpError.py,sha256=UhSsF47PX7XEuHCFaGwUi4IFSlWJ3YJRndJTlhnc_ww,151
6
+ ose_plugin_hbcp/search_api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ ose_plugin_hbcp-0.2.5.dist-info/METADATA,sha256=hhPJcV8WTHBaKzPDVj9cslyW5GuEh2ZgK4YGrjHNwbo,752
8
+ ose_plugin_hbcp-0.2.5.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
9
+ ose_plugin_hbcp-0.2.5.dist-info/top_level.txt,sha256=7QS5dsHVKSRq0_1ibGbZbFS292DMDHDCyY_pSd34fkg,16
10
+ ose_plugin_hbcp-0.2.5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ ose_plugin_hbcp