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.
@@ -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