almapy 9.1.1__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.
almapy/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """An API wrapper library for Ex Libris' Alma."""
2
+
3
+ from almapy._client import AlmaClient
4
+ from almapy.exceptions import AlmapyError, ThrottleTimeoutError
5
+
6
+ __all__ = ["AlmaClient", "AlmapyError", "ThrottleTimeoutError"]
almapy/_acq.py ADDED
@@ -0,0 +1,134 @@
1
+ from datetime import date
2
+ from typing import Any, Literal, overload
3
+
4
+ from almapy._base import BaseNamespace
5
+ from almapy._endpoints import AlmaEndpoint
6
+ from almapy._utils import RESP_TYPE, Body, _ModelT
7
+
8
+
9
+ class AlmaClientAcqNS(BaseNamespace):
10
+ """Namespace for acquisitions functionality."""
11
+
12
+ @overload
13
+ async def get_po_line(self, po_line_id: str, *, model: type[_ModelT]) -> _ModelT: ...
14
+
15
+ @overload
16
+ async def get_po_line(self, po_line_id: str, *, model: None = ...) -> RESP_TYPE: ...
17
+
18
+ async def get_po_line(self, po_line_id: str, *, model: Any = None) -> Any:
19
+ return await self._get(AlmaEndpoint.PO_LINE, {"PO_LINE_ID": po_line_id}, model=model)
20
+
21
+ @overload
22
+ async def update_po_line(
23
+ self,
24
+ po_line_id: str,
25
+ updated_po_line: Body,
26
+ *,
27
+ update_inventory: bool = ...,
28
+ redistribute_funds: bool = ...,
29
+ model: type[_ModelT],
30
+ ) -> _ModelT: ...
31
+
32
+ @overload
33
+ async def update_po_line(
34
+ self,
35
+ po_line_id: str,
36
+ updated_po_line: Body,
37
+ *,
38
+ update_inventory: bool = ...,
39
+ redistribute_funds: bool = ...,
40
+ model: None = ...,
41
+ ) -> RESP_TYPE: ...
42
+
43
+ async def update_po_line(
44
+ self,
45
+ po_line_id: str,
46
+ updated_po_line: Body,
47
+ *,
48
+ update_inventory: bool = False,
49
+ redistribute_funds: bool = False,
50
+ model: Any = None,
51
+ ) -> Any:
52
+ return await self._put(
53
+ AlmaEndpoint.PO_LINE,
54
+ {"PO_LINE_ID": po_line_id},
55
+ model=model,
56
+ params={"update_inventory": update_inventory, "redistribute_funds": redistribute_funds},
57
+ json=updated_po_line,
58
+ )
59
+
60
+ @overload
61
+ async def receive_existing_item(
62
+ self,
63
+ po_line_id: str,
64
+ item_pid: str,
65
+ *,
66
+ receive_date: date | None = ...,
67
+ department: str | None = ...,
68
+ department_library: str | None = ...,
69
+ updated_item: Body | None = ...,
70
+ model: type[_ModelT],
71
+ ) -> _ModelT: ...
72
+
73
+ @overload
74
+ async def receive_existing_item(
75
+ self,
76
+ po_line_id: str,
77
+ item_pid: str,
78
+ *,
79
+ receive_date: date | None = ...,
80
+ department: str | None = ...,
81
+ department_library: str | None = ...,
82
+ updated_item: Body | None = ...,
83
+ model: None = ...,
84
+ ) -> RESP_TYPE: ...
85
+
86
+ async def receive_existing_item(
87
+ self,
88
+ po_line_id: str,
89
+ item_pid: str,
90
+ *,
91
+ receive_date: date | None = None,
92
+ department: str | None = None,
93
+ department_library: str | None = None,
94
+ updated_item: Body | None = None,
95
+ model: Any = None,
96
+ ) -> Any:
97
+ if updated_item is None:
98
+ updated_item = {}
99
+ params: dict[str, Any] = {"op": "receive"}
100
+ if receive_date:
101
+ params["receive_date"] = receive_date.strftime("%Y-%m-%dZ")
102
+ if department:
103
+ params["department"] = department
104
+ if department_library:
105
+ params["department_library"] = department_library
106
+ return await self._post(
107
+ AlmaEndpoint.PO_LINE_ITEM,
108
+ {"PO_LINE_ID": po_line_id, "ITEM_PID": item_pid},
109
+ model=model,
110
+ params=params,
111
+ json=updated_item,
112
+ )
113
+
114
+ async def cancel_po_line(
115
+ self,
116
+ po_line_id: str,
117
+ reason_code: str,
118
+ *,
119
+ comment: str | None = None,
120
+ inform_vendor: bool = False,
121
+ override: bool = False,
122
+ bib_handling: Literal["retain", "delete", "suppress"] = "retain",
123
+ ) -> None:
124
+ params: dict[str, Any] = {
125
+ "reason": reason_code,
126
+ "inform_vendor": inform_vendor,
127
+ "override": override,
128
+ "bib": bib_handling,
129
+ }
130
+ if comment:
131
+ params["comment"] = comment
132
+ await self._delete(
133
+ AlmaEndpoint.PO_LINE, {"PO_LINE_ID": po_line_id}, parser="none", params=params
134
+ )
almapy/_analytics.py ADDED
@@ -0,0 +1,85 @@
1
+ from typing import Any
2
+
3
+ import xmltodict
4
+
5
+ from almapy._base import BaseNamespace
6
+ from almapy._endpoints import AlmaEndpoint
7
+
8
+
9
+ def _as_list(value: Any) -> list[Any]:
10
+ """Normalise xmltodict's repeated-element handling.
11
+
12
+ xmltodict returns a list for repeated elements, a bare dict when exactly one
13
+ is present, and nothing at all when there are none. Every site that iterates
14
+ rows or columns has to go through here: iterating the dict form yields its
15
+ keys as strings, which fails later with a confusing AttributeError rather
16
+ than at the point of the mistake.
17
+ """
18
+ if value is None:
19
+ return []
20
+ return value if isinstance(value, list) else [value]
21
+
22
+
23
+ def headers_to_dict(headers: list[dict[str, Any]] | dict[str, Any]) -> dict[str, Any]:
24
+ """Map Alma's internal column names to their report headings.
25
+
26
+ Accepts the single-column dict form as well as a list — a report with one
27
+ non-Column0 column previously raised TypeError here.
28
+ """
29
+ return {header["@name"]: header["@saw-sql:columnHeading"] for header in _as_list(headers)}
30
+
31
+
32
+ class AlmaClientAnalyticsNS(BaseNamespace):
33
+ """Namespace for analytics functionality."""
34
+
35
+ async def get_raw_report(
36
+ self,
37
+ path: str,
38
+ limit: int = 100,
39
+ *,
40
+ token: str | None = None,
41
+ report_filter: str | None = None,
42
+ ) -> str:
43
+ params: dict[str, Any] = {"path": path, "limit": limit}
44
+ if report_filter:
45
+ params["filter"] = report_filter
46
+ if token:
47
+ params["token"] = token
48
+ return await self._get_text(
49
+ AlmaEndpoint.REPORTS,
50
+ params=params,
51
+ headers={"Accept": "application/xml"},
52
+ )
53
+
54
+ async def get_full_report(
55
+ self,
56
+ path: str,
57
+ limit: int = 100,
58
+ header_override: dict[str, str] | None = None,
59
+ report_filter: str | None = None,
60
+ ) -> list[dict[str, str]]:
61
+ initial_response = await self.get_raw_report(path, limit, report_filter=report_filter)
62
+ parsed_resp = xmltodict.parse(initial_response)
63
+ finished = parsed_resp["report"]["QueryResult"]["IsFinished"]
64
+ rowset = parsed_resp["report"]["QueryResult"]["ResultXml"]["rowset"]
65
+ result = _as_list(rowset.get("Row"))
66
+ headers = headers_to_dict(
67
+ rowset["xsd:schema"]["xsd:complexType"]["xsd:sequence"]["xsd:element"]
68
+ )
69
+ if header_override:
70
+ headers.update(header_override)
71
+ token = parsed_resp["report"]["QueryResult"]["ResumptionToken"]
72
+
73
+ while finished == "false":
74
+ resp = await self.get_raw_report(path, limit, token=token, report_filter=report_filter)
75
+ parsed_resp = xmltodict.parse(resp)
76
+ finished = parsed_resp["report"]["QueryResult"]["IsFinished"]
77
+ # Same coercion as the first page. Indexing ["Row"] directly meant a
78
+ # final page with one row raised AttributeError and one with no rows
79
+ # raised KeyError — reachable by any report whose total is just over
80
+ # a multiple of `limit`.
81
+ page = parsed_resp["report"]["QueryResult"]["ResultXml"]["rowset"]
82
+ result.extend(_as_list(page.get("Row")))
83
+
84
+ data = [{headers[k]: v for k, v in row.items() if k != "Column0"} for row in result]
85
+ return data
almapy/_base.py ADDED
@@ -0,0 +1,223 @@
1
+ """Base namespace for Alma API client namespaces."""
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, overload
5
+
6
+ from almapy._endpoints import AlmaEndpoint
7
+ from almapy._utils import RESP_TYPE, _ModelT
8
+
9
+ if TYPE_CHECKING:
10
+ import niquests
11
+
12
+ Parser = Literal["json", "xml", "none", "text"]
13
+
14
+
15
+ class _AlmaExecutable(Protocol):
16
+ """Protocol for the execute method AlmaClient provides."""
17
+
18
+ _gateway: str
19
+ """Regional gateway root (e.g. https://api-eu.hosted.exlibrisgroup.com)."""
20
+
21
+ @overload
22
+ async def execute(
23
+ self, method: str, url: str, *, parser: Parser, model: type[_ModelT], **kwargs: Any
24
+ ) -> _ModelT: ...
25
+
26
+ @overload
27
+ async def execute(
28
+ self, method: str, url: str, *, parser: Parser, model: None = ..., **kwargs: Any
29
+ ) -> RESP_TYPE: ...
30
+
31
+ async def execute(
32
+ self,
33
+ method: str,
34
+ url: str,
35
+ *,
36
+ parser: Parser,
37
+ model: Any = None,
38
+ validate: "Callable[[niquests.Response], None]" = ...,
39
+ retry: bool | None = None,
40
+ **kwargs: Any,
41
+ ) -> Any: ...
42
+
43
+
44
+ class BaseNamespace: # ruff: ignore[class-as-data-structure]
45
+ """Base class for AlmaClient namespace objects.
46
+
47
+ Owns URL building via AlmaEndpoint.build() and delegates HTTP execution to
48
+ AlmaClient.execute(). Namespace methods pass ``retry=`` straight through to
49
+ control whether a write is replayed on an ambiguous transport failure.
50
+ """
51
+
52
+ def __init__(self, client: _AlmaExecutable) -> None:
53
+ self._client = client
54
+
55
+ @overload
56
+ async def _get(
57
+ self,
58
+ endpoint: AlmaEndpoint,
59
+ path: Mapping[str, str] | None = None,
60
+ *,
61
+ model: type[_ModelT],
62
+ parser: Parser = ...,
63
+ **kwargs: Any,
64
+ ) -> _ModelT: ...
65
+
66
+ @overload
67
+ async def _get(
68
+ self,
69
+ endpoint: AlmaEndpoint,
70
+ path: Mapping[str, str] | None = None,
71
+ *,
72
+ model: None = ...,
73
+ parser: Parser = ...,
74
+ **kwargs: Any,
75
+ ) -> RESP_TYPE: ...
76
+
77
+ async def _get(
78
+ self,
79
+ endpoint: AlmaEndpoint,
80
+ path: Mapping[str, str] | None = None,
81
+ *,
82
+ model: Any = None,
83
+ parser: Parser = "json",
84
+ **kwargs: Any,
85
+ ) -> Any:
86
+ return await self._client.execute(
87
+ "GET", endpoint.build(path), parser=parser, model=model, **kwargs
88
+ )
89
+
90
+ @overload
91
+ async def _post(
92
+ self,
93
+ endpoint: AlmaEndpoint,
94
+ path: Mapping[str, str] | None = None,
95
+ *,
96
+ model: type[_ModelT],
97
+ parser: Parser = ...,
98
+ **kwargs: Any,
99
+ ) -> _ModelT: ...
100
+
101
+ @overload
102
+ async def _post(
103
+ self,
104
+ endpoint: AlmaEndpoint,
105
+ path: Mapping[str, str] | None = None,
106
+ *,
107
+ model: None = ...,
108
+ parser: Parser = ...,
109
+ **kwargs: Any,
110
+ ) -> RESP_TYPE: ...
111
+
112
+ async def _post(
113
+ self,
114
+ endpoint: AlmaEndpoint,
115
+ path: Mapping[str, str] | None = None,
116
+ *,
117
+ model: Any = None,
118
+ parser: Parser = "json",
119
+ **kwargs: Any,
120
+ ) -> Any:
121
+ return await self._client.execute(
122
+ "POST", endpoint.build(path), parser=parser, model=model, **kwargs
123
+ )
124
+
125
+ @overload
126
+ async def _put(
127
+ self,
128
+ endpoint: AlmaEndpoint,
129
+ path: Mapping[str, str] | None = None,
130
+ *,
131
+ model: type[_ModelT],
132
+ parser: Parser = ...,
133
+ **kwargs: Any,
134
+ ) -> _ModelT: ...
135
+
136
+ @overload
137
+ async def _put(
138
+ self,
139
+ endpoint: AlmaEndpoint,
140
+ path: Mapping[str, str] | None = None,
141
+ *,
142
+ model: None = ...,
143
+ parser: Parser = ...,
144
+ **kwargs: Any,
145
+ ) -> RESP_TYPE: ...
146
+
147
+ async def _put(
148
+ self,
149
+ endpoint: AlmaEndpoint,
150
+ path: Mapping[str, str] | None = None,
151
+ *,
152
+ model: Any = None,
153
+ parser: Parser = "json",
154
+ **kwargs: Any,
155
+ ) -> Any:
156
+ return await self._client.execute(
157
+ "PUT", endpoint.build(path), parser=parser, model=model, **kwargs
158
+ )
159
+
160
+ @overload
161
+ async def _delete(
162
+ self,
163
+ endpoint: AlmaEndpoint,
164
+ path: Mapping[str, str] | None = None,
165
+ *,
166
+ model: type[_ModelT],
167
+ parser: Parser = ...,
168
+ **kwargs: Any,
169
+ ) -> _ModelT: ...
170
+
171
+ @overload
172
+ async def _delete(
173
+ self,
174
+ endpoint: AlmaEndpoint,
175
+ path: Mapping[str, str] | None = None,
176
+ *,
177
+ model: None = ...,
178
+ parser: Parser = ...,
179
+ **kwargs: Any,
180
+ ) -> RESP_TYPE: ...
181
+
182
+ async def _delete(
183
+ self,
184
+ endpoint: AlmaEndpoint,
185
+ path: Mapping[str, str] | None = None,
186
+ *,
187
+ model: Any = None,
188
+ parser: Parser = "json",
189
+ **kwargs: Any,
190
+ ) -> Any:
191
+ return await self._client.execute(
192
+ "DELETE", endpoint.build(path), parser=parser, model=model, **kwargs
193
+ )
194
+
195
+ async def _get_text(
196
+ self,
197
+ endpoint: AlmaEndpoint,
198
+ path: Mapping[str, str] | None = None,
199
+ **kwargs: Any,
200
+ ) -> str:
201
+ """GET returning raw response text (e.g., MARC XML records)."""
202
+ result: Any = await self._get(endpoint, path, parser="text", **kwargs)
203
+ return cast(str, result)
204
+
205
+ async def _post_text(
206
+ self,
207
+ endpoint: AlmaEndpoint,
208
+ path: Mapping[str, str] | None = None,
209
+ **kwargs: Any,
210
+ ) -> str:
211
+ """POST returning raw response text."""
212
+ result: Any = await self._post(endpoint, path, parser="text", **kwargs)
213
+ return cast(str, result)
214
+
215
+ async def _put_text(
216
+ self,
217
+ endpoint: AlmaEndpoint,
218
+ path: Mapping[str, str] | None = None,
219
+ **kwargs: Any,
220
+ ) -> str:
221
+ """PUT returning raw response text."""
222
+ result: Any = await self._put(endpoint, path, parser="text", **kwargs)
223
+ return cast(str, result)