python-openarch-api 0.1.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.
- python_openarch_api/__init__.py +29 -0
- python_openarch_api/_async/__init__.py +0 -0
- python_openarch_api/_async/client.py +660 -0
- python_openarch_api/_sync/__init__.py +0 -0
- python_openarch_api/_sync/client.py +660 -0
- python_openarch_api/_version.py +1 -0
- python_openarch_api/auth.py +48 -0
- python_openarch_api/errors.py +52 -0
- python_openarch_api/models/__init__.py +7 -0
- python_openarch_api/models/document.py +91 -0
- python_openarch_api/models/generated.py +1380 -0
- python_openarch_api/py.typed +0 -0
- python_openarch_api-0.1.0.dist-info/METADATA +260 -0
- python_openarch_api-0.1.0.dist-info/RECORD +16 -0
- python_openarch_api-0.1.0.dist-info/WHEEL +4 -0
- python_openarch_api-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Authentication for OpenArch.
|
|
2
|
+
|
|
3
|
+
A local OpenArch (its openarch-users.yml users) takes HTTP Basic; a hosted one takes a bearer JWT from
|
|
4
|
+
its identity provider. A token can be a string or a callable returning the current token, so a
|
|
5
|
+
caller that refreshes tokens hands the client a function instead of a value.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Generator
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
TokenSource = str | Callable[[], str]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BearerAuth(httpx.Auth):
|
|
18
|
+
"""``Authorization: Bearer <token>``, the token read on every request."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, token: TokenSource):
|
|
21
|
+
self._token = token
|
|
22
|
+
|
|
23
|
+
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
24
|
+
token = self._token() if callable(self._token) else self._token
|
|
25
|
+
request.headers["Authorization"] = f"Bearer {token}"
|
|
26
|
+
yield request
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_auth(
|
|
30
|
+
*,
|
|
31
|
+
username: str | None = None,
|
|
32
|
+
password: str | None = None,
|
|
33
|
+
token: TokenSource | None = None,
|
|
34
|
+
auth: httpx.Auth | None = None,
|
|
35
|
+
) -> httpx.Auth | None:
|
|
36
|
+
"""One of: an ``httpx.Auth``, a bearer ``token``, or ``username`` + ``password`` (Basic)."""
|
|
37
|
+
given = [x for x in (auth, token, username) if x is not None]
|
|
38
|
+
if len(given) > 1:
|
|
39
|
+
raise ValueError("give one of auth, token, or username/password")
|
|
40
|
+
if auth is not None:
|
|
41
|
+
return auth
|
|
42
|
+
if token is not None:
|
|
43
|
+
return BearerAuth(token)
|
|
44
|
+
if username is not None:
|
|
45
|
+
if password is None:
|
|
46
|
+
raise ValueError("username needs a password")
|
|
47
|
+
return httpx.BasicAuth(username, password)
|
|
48
|
+
return None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Errors raised by the client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .models.generated import RepositoryErrorResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OpenArchError(Exception):
|
|
11
|
+
"""A non-2xx answer from OpenArch. ``error`` is the parsed error body when it had one."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, response: httpx.Response, error: RepositoryErrorResponse | None = None):
|
|
14
|
+
self.response = response
|
|
15
|
+
self.status_code = response.status_code
|
|
16
|
+
self.error = error
|
|
17
|
+
detail = (error.message if error and error.message else response.text[:300]) or response.reason_phrase
|
|
18
|
+
super().__init__(f"{response.request.method} {response.request.url.path}: HTTP {self.status_code}: {detail}")
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_response(cls, response: httpx.Response) -> OpenArchError:
|
|
22
|
+
error = None
|
|
23
|
+
try:
|
|
24
|
+
error = RepositoryErrorResponse.model_validate(response.json())
|
|
25
|
+
except ValueError: # not JSON, or not the error shape (ValidationError is a ValueError)
|
|
26
|
+
pass
|
|
27
|
+
if response.status_code in (401, 403):
|
|
28
|
+
return OpenArchAuthError(response, error)
|
|
29
|
+
if response.status_code == 404:
|
|
30
|
+
return OpenArchNotFoundError(response, error)
|
|
31
|
+
if response.status_code == 409:
|
|
32
|
+
return OpenArchConflictError(response, error)
|
|
33
|
+
if response.status_code == 412:
|
|
34
|
+
return OpenArchPreconditionError(response, error)
|
|
35
|
+
return cls(response, error)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OpenArchAuthError(OpenArchError):
|
|
39
|
+
"""401 / 403: missing, expired or insufficient credentials."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpenArchNotFoundError(OpenArchError):
|
|
43
|
+
"""404: no such document, path, user, ..."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OpenArchConflictError(OpenArchError):
|
|
47
|
+
"""409: e.g. a ``set_*_v2`` whose ``version`` is stale (optimistic lock failure)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OpenArchPreconditionError(OpenArchError):
|
|
51
|
+
"""412: a write's ``condition`` / ``if_`` did not match, or the document cannot do that
|
|
52
|
+
(checkin of a non-versionable document)."""
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Pydantic v2 models: ``generated`` (from docs/openapi/openarch-api.yaml) and the hand-written
|
|
2
|
+
``OpenArchDocument`` / ``OpenArchQueryResult`` (``document``)."""
|
|
3
|
+
|
|
4
|
+
from .document import MIXIN_CIN_REMOTE, OpenArchDocument, OpenArchQueryResult, TermsBucket
|
|
5
|
+
from .generated import * # noqa: F403
|
|
6
|
+
|
|
7
|
+
__all__ = ["MIXIN_CIN_REMOTE", "OpenArchDocument", "OpenArchQueryResult", "TermsBucket"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Hand-written Document and QueryResult.
|
|
2
|
+
|
|
3
|
+
OpenArch documents carry dynamic properties beyond the ``sys_*`` ones in the OpenAPI schema -- the
|
|
4
|
+
``cin_*`` fields of the ``CinRemote`` mixin, ``sysembed_*`` embeddings, and whatever the
|
|
5
|
+
repository model adds. The generated ``Document`` keeps them only as untyped extras, and the
|
|
6
|
+
generated ``QueryResult`` types its documents as that ``Document``. As in content-lake-app
|
|
7
|
+
(``HxprDocument`` / ``HxprDocument.QueryResult`` in content-lake-core), these two are written by
|
|
8
|
+
hand: ``OpenArchDocument`` types the known ``cin_*`` fields and still accepts any other property;
|
|
9
|
+
``OpenArchQueryResult`` returns ``OpenArchDocument`` instances.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
17
|
+
|
|
18
|
+
from .generated import Document, QueryResult
|
|
19
|
+
|
|
20
|
+
# Mixin that owns the cin_* fields: OpenArch rejects a document carrying any of them (400, naming the
|
|
21
|
+
# field) unless sys_mixinTypes includes it.
|
|
22
|
+
MIXIN_CIN_REMOTE = "CinRemote"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpenArchDocument(Document):
|
|
26
|
+
"""A document: the schema's ``sys_*`` fields, the ``CinRemote`` ``cin_*`` fields, and any
|
|
27
|
+
other property (``doc.properties`` / ``doc.get("my_prop")``)."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
30
|
+
|
|
31
|
+
# CinRemote: the source-system identity and metadata of an ingested document
|
|
32
|
+
cin_id: str | None = None
|
|
33
|
+
"""Id of the document in its source system (e.g. an Alfresco node id)."""
|
|
34
|
+
cin_source_id: Annotated[str | None, Field(alias="cin_sourceId")] = None
|
|
35
|
+
"""Id of the source system (e.g. an Alfresco repository id)."""
|
|
36
|
+
cin_paths: list[str] | None = None
|
|
37
|
+
cin_ingest_properties: Annotated[dict[str, Any] | None, Field(alias="cin_ingestProperties")] = None
|
|
38
|
+
cin_ingest_property_names: Annotated[list[str] | None, Field(alias="cin_ingestPropertyNames")] = None
|
|
39
|
+
cin_read: list[str] | None = None
|
|
40
|
+
"""Authorities that may read the document in its source system."""
|
|
41
|
+
cin_deny: list[str] | None = None
|
|
42
|
+
"""Authorities denied reading it."""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def properties(self) -> dict[str, Any]:
|
|
46
|
+
"""The properties not declared on this class (repository-model fields, sysembed_*, ...),
|
|
47
|
+
under their wire names."""
|
|
48
|
+
return dict(self.model_extra or {})
|
|
49
|
+
|
|
50
|
+
def get(self, name: str, default: Any = None) -> Any:
|
|
51
|
+
"""A property by its wire name (``sys_primaryType``, ``cin_sourceId``, ``my_field``)."""
|
|
52
|
+
for field_name, info in type(self).model_fields.items():
|
|
53
|
+
if name in (field_name, info.alias):
|
|
54
|
+
value = getattr(self, field_name)
|
|
55
|
+
return default if value is None else value
|
|
56
|
+
return (self.model_extra or {}).get(name, default)
|
|
57
|
+
|
|
58
|
+
def with_cin_remote(self) -> OpenArchDocument:
|
|
59
|
+
"""This document with ``CinRemote`` in ``sys_mixinTypes`` (needed to write cin_* fields)."""
|
|
60
|
+
mixins = list(self.sys_mixin_types or [])
|
|
61
|
+
if MIXIN_CIN_REMOTE not in mixins:
|
|
62
|
+
mixins.append(MIXIN_CIN_REMOTE)
|
|
63
|
+
return self.model_copy(update={"sys_mixin_types": mixins})
|
|
64
|
+
|
|
65
|
+
def to_json(self) -> dict[str, Any]:
|
|
66
|
+
"""The request body: wire names, unset and None fields left out."""
|
|
67
|
+
return self.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TermsBucket(BaseModel):
|
|
71
|
+
"""One terms-aggregation bucket. The spec's ``StringTermsBucket`` has no properties (springdoc
|
|
72
|
+
cannot see into the OpenSearch class), so the generated model would drop them."""
|
|
73
|
+
|
|
74
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
75
|
+
|
|
76
|
+
key: str | None = None
|
|
77
|
+
doc_count: Annotated[int | None, Field(alias="docCount")] = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class OpenArchQueryResult(QueryResult):
|
|
81
|
+
"""A query result whose documents are :class:`OpenArchDocument`."""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
84
|
+
|
|
85
|
+
documents: list[OpenArchDocument] | None = None # type: ignore[assignment]
|
|
86
|
+
aggregations_buckets: Annotated[ # type: ignore[assignment]
|
|
87
|
+
list[TermsBucket] | None, Field(alias="aggregationsBuckets")
|
|
88
|
+
] = None
|
|
89
|
+
# The Query schemas describe this cursor ("use the nextSearchAfter value from a previous
|
|
90
|
+
# response") but QueryResult does not declare it: pass it back as ``search_after``.
|
|
91
|
+
next_search_after: Annotated[str | None, Field(alias="nextSearchAfter")] = None
|