easyvista-python-client 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.
- easyvista_python_client/__init__.py +74 -0
- easyvista_python_client/_async/__init__.py +13 -0
- easyvista_python_client/_async/_concurrency.py +78 -0
- easyvista_python_client/_async/_transport.py +266 -0
- easyvista_python_client/_async/client.py +790 -0
- easyvista_python_client/_fields.py +26 -0
- easyvista_python_client/_html.py +41 -0
- easyvista_python_client/_sync/__init__.py +13 -0
- easyvista_python_client/_sync/_concurrency.py +50 -0
- easyvista_python_client/_sync/_transport.py +266 -0
- easyvista_python_client/_sync/client.py +790 -0
- easyvista_python_client/_transport.py +29 -0
- easyvista_python_client/config.py +71 -0
- easyvista_python_client/context.py +116 -0
- easyvista_python_client/directory.py +53 -0
- easyvista_python_client/exceptions.py +53 -0
- easyvista_python_client/field_model.py +74 -0
- easyvista_python_client/filters.py +82 -0
- easyvista_python_client/models/__init__.py +1 -0
- easyvista_python_client/models/action.py +65 -0
- easyvista_python_client/models/asset.py +36 -0
- easyvista_python_client/models/common.py +78 -0
- easyvista_python_client/models/department.py +68 -0
- easyvista_python_client/models/document.py +32 -0
- easyvista_python_client/models/employee.py +67 -0
- easyvista_python_client/models/request.py +172 -0
- easyvista_python_client/pagination.py +84 -0
- easyvista_python_client/py.typed +0 -0
- easyvista_python_client/references.py +146 -0
- easyvista_python_client/reporting.py +143 -0
- easyvista_python_client/resources/__init__.py +1 -0
- easyvista_python_client/resources/actions.py +72 -0
- easyvista_python_client/resources/assets.py +47 -0
- easyvista_python_client/resources/departments.py +57 -0
- easyvista_python_client/resources/descriptor.py +99 -0
- easyvista_python_client/resources/documents.py +78 -0
- easyvista_python_client/resources/employees.py +57 -0
- easyvista_python_client/resources/requests.py +91 -0
- easyvista_python_client-0.1.0.dist-info/METADATA +178 -0
- easyvista_python_client-0.1.0.dist-info/RECORD +42 -0
- easyvista_python_client-0.1.0.dist-info/WHEEL +4 -0
- easyvista_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""EasyVista client — a flat facade over the resource builders.
|
|
2
|
+
|
|
3
|
+
The blocking and the coroutine surface are two spellings of one source: they
|
|
4
|
+
differ only in the ``async``/``await`` keywords and in the few names that must
|
|
5
|
+
differ (the client class, its iterator types, ``aclose``/``close``). Every
|
|
6
|
+
docstring and comment in this module therefore describes both, and prose here
|
|
7
|
+
must read true on either surface -- never "see the other client", and never a
|
|
8
|
+
claim that holds on only one of them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import AsyncIterator, Sequence
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
from easyvista_python_client._async._concurrency import Semaphore, settle
|
|
17
|
+
from easyvista_python_client._async._transport import Transport
|
|
18
|
+
from easyvista_python_client._transport import RequestSpec
|
|
19
|
+
from easyvista_python_client.config import EasyvistaConfig
|
|
20
|
+
from easyvista_python_client.context import TicketContext
|
|
21
|
+
from easyvista_python_client.directory import (
|
|
22
|
+
RECENT_TICKETS_SORT,
|
|
23
|
+
DepartmentContext,
|
|
24
|
+
_department_matches,
|
|
25
|
+
_normalize_name,
|
|
26
|
+
)
|
|
27
|
+
from easyvista_python_client.exceptions import EasyvistaAuthError, EasyvistaNotFound
|
|
28
|
+
from easyvista_python_client.field_model import parse_memo
|
|
29
|
+
from easyvista_python_client.filters import ev_equals_filter, is_safe_ev_value
|
|
30
|
+
from easyvista_python_client.models.action import Action, PostAction
|
|
31
|
+
from easyvista_python_client.models.asset import Asset, PostAsset
|
|
32
|
+
from easyvista_python_client.models.department import (
|
|
33
|
+
Department,
|
|
34
|
+
DepartmentUpdate,
|
|
35
|
+
PostDepartment,
|
|
36
|
+
)
|
|
37
|
+
from easyvista_python_client.models.document import Document
|
|
38
|
+
from easyvista_python_client.models.employee import (
|
|
39
|
+
Employee,
|
|
40
|
+
EmployeeUpdate,
|
|
41
|
+
PostEmployee,
|
|
42
|
+
)
|
|
43
|
+
from easyvista_python_client.models.request import PostRequest, Request, RequestUpdate
|
|
44
|
+
from easyvista_python_client.pagination import SearchResult
|
|
45
|
+
from easyvista_python_client.reporting import (
|
|
46
|
+
DEFAULT_DIMENSIONS,
|
|
47
|
+
TicketStatistics,
|
|
48
|
+
aggregate_tickets,
|
|
49
|
+
fields_for_references,
|
|
50
|
+
)
|
|
51
|
+
from easyvista_python_client.resources import actions as actions_res
|
|
52
|
+
from easyvista_python_client.resources import assets as assets_res
|
|
53
|
+
from easyvista_python_client.resources import departments as departments_res
|
|
54
|
+
from easyvista_python_client.resources import documents as documents_res
|
|
55
|
+
from easyvista_python_client.resources import employees as employees_res
|
|
56
|
+
from easyvista_python_client.resources import requests as requests_res
|
|
57
|
+
|
|
58
|
+
# Width of the action-body fan-out: a ceiling on requests in flight at once on
|
|
59
|
+
# the async surface, inert on the sync one. This is the one fan-out here whose
|
|
60
|
+
# width is set by the server (a ticket can carry any number of actions); the
|
|
61
|
+
# department fan-out is a fixed seven and needs no bound. Deliberately not a
|
|
62
|
+
# config field: nobody has asked for it, and measured against a live instance a
|
|
63
|
+
# limit of 8 costs nothing (19 actions took 5.31s at limit 8 vs 5.43s unbounded
|
|
64
|
+
# -- the server, not the client, is the bottleneck).
|
|
65
|
+
_ACTION_FANOUT = 8
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncEasyvistaClient:
|
|
69
|
+
"""Client for the EasyVista Service Manager REST API.
|
|
70
|
+
|
|
71
|
+
Blocking as ``EasyvistaClient``, coroutine-returning as
|
|
72
|
+
``AsyncEasyvistaClient``: same methods, same arguments, same results.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, config: EasyvistaConfig) -> None:
|
|
76
|
+
self.config = config
|
|
77
|
+
self._transport = Transport(config)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_env(cls) -> AsyncEasyvistaClient:
|
|
81
|
+
return cls(EasyvistaConfig.from_env())
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> AsyncEasyvistaClient:
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
87
|
+
await self.aclose()
|
|
88
|
+
|
|
89
|
+
async def aclose(self) -> None:
|
|
90
|
+
await self._transport.aclose()
|
|
91
|
+
|
|
92
|
+
# --- tickets -------------------------------------------------------------
|
|
93
|
+
async def create_ticket(self, ticket: PostRequest) -> Request:
|
|
94
|
+
spec, parse = requests_res.build_create_ticket(ticket)
|
|
95
|
+
return parse(await self._transport.send(spec))
|
|
96
|
+
|
|
97
|
+
async def create_tickets(self, tickets: Sequence[PostRequest]) -> list[Request]:
|
|
98
|
+
# One request per ticket (EasyVista creates only the first item of a
|
|
99
|
+
# multi-item body).
|
|
100
|
+
#
|
|
101
|
+
# Stays SEQUENTIAL on purpose on BOTH surfaces -- unlike the read
|
|
102
|
+
# bundles below, which fan out on the async one. These are writes:
|
|
103
|
+
# EasyVista assigns the RFC number server-side, so concurrent POSTs
|
|
104
|
+
# return in scheduling order, and a failure part-way through would
|
|
105
|
+
# leave the caller holding an exception with no way to say which tickets
|
|
106
|
+
# now exist. Sequentially a failure at item k means 0..k-1 exist and the
|
|
107
|
+
# rest do not, which is a contract a caller can act on. Do not "fix"
|
|
108
|
+
# this into a fan-out.
|
|
109
|
+
return [await self.create_ticket(ticket) for ticket in tickets]
|
|
110
|
+
|
|
111
|
+
async def get_ticket(self, rfc_number: str) -> Request:
|
|
112
|
+
spec, parse = requests_res.build_get_ticket(rfc_number)
|
|
113
|
+
return parse(await self._transport.send(spec))
|
|
114
|
+
|
|
115
|
+
async def search_tickets(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
search: str | None = None,
|
|
119
|
+
fields: str | list[str] | None = None,
|
|
120
|
+
sort: str | None = None,
|
|
121
|
+
max_rows: int | None = None,
|
|
122
|
+
offset: int | None = None,
|
|
123
|
+
) -> SearchResult[Request]:
|
|
124
|
+
if max_rows is None:
|
|
125
|
+
max_rows = self.config.default_max_rows
|
|
126
|
+
spec, parse = requests_res.build_search_tickets(
|
|
127
|
+
search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset
|
|
128
|
+
)
|
|
129
|
+
return parse(await self._transport.send(spec))
|
|
130
|
+
|
|
131
|
+
async def iter_tickets(
|
|
132
|
+
self,
|
|
133
|
+
*,
|
|
134
|
+
search: str | None = None,
|
|
135
|
+
fields: str | list[str] | None = None,
|
|
136
|
+
sort: str | None = None,
|
|
137
|
+
page_size: int | None = None,
|
|
138
|
+
max_records: int | None = None,
|
|
139
|
+
) -> AsyncIterator[Request]:
|
|
140
|
+
"""Yield tickets across pages, following the API's offset pagination.
|
|
141
|
+
|
|
142
|
+
Pages of ``page_size`` (default ``config.default_max_rows``) until the
|
|
143
|
+
server reports no further page (``@next``) or ``max_records`` is reached.
|
|
144
|
+
"""
|
|
145
|
+
if page_size is None:
|
|
146
|
+
page_size = self.config.default_max_rows
|
|
147
|
+
offset = 0
|
|
148
|
+
yielded = 0
|
|
149
|
+
while max_records is None or yielded < max_records:
|
|
150
|
+
result = await self.search_tickets(
|
|
151
|
+
search=search,
|
|
152
|
+
fields=fields,
|
|
153
|
+
sort=sort,
|
|
154
|
+
max_rows=page_size,
|
|
155
|
+
offset=offset,
|
|
156
|
+
)
|
|
157
|
+
if not result.records:
|
|
158
|
+
return
|
|
159
|
+
for record in result.records:
|
|
160
|
+
yield record
|
|
161
|
+
yielded += 1
|
|
162
|
+
if max_records is not None and yielded >= max_records:
|
|
163
|
+
return
|
|
164
|
+
if result.next_url is None:
|
|
165
|
+
return
|
|
166
|
+
offset += len(result.records)
|
|
167
|
+
|
|
168
|
+
async def count_tickets(self, search: str | None = None) -> int:
|
|
169
|
+
"""Return the number of tickets matching ``search`` (one cheap call).
|
|
170
|
+
|
|
171
|
+
Uses ``max_rows=1`` and reads the envelope's ``total_record_count``, so
|
|
172
|
+
it does not fetch the matching records.
|
|
173
|
+
"""
|
|
174
|
+
result = await self.search_tickets(search=search, max_rows=1)
|
|
175
|
+
return result.total_record_count
|
|
176
|
+
|
|
177
|
+
async def ticket_statistics(
|
|
178
|
+
self,
|
|
179
|
+
*,
|
|
180
|
+
search: str | None = None,
|
|
181
|
+
dimensions: Sequence[str] | None = None,
|
|
182
|
+
created_since: datetime | str | None = None,
|
|
183
|
+
created_until: datetime | str | None = None,
|
|
184
|
+
max_records: int | None = 100,
|
|
185
|
+
) -> TicketStatistics:
|
|
186
|
+
"""Aggregate matching tickets into a total plus per-dimension breakdowns.
|
|
187
|
+
|
|
188
|
+
Fetches up to ``max_records`` tickets matching ``search`` (default cap 100;
|
|
189
|
+
pass ``None`` to aggregate all) and groups them by each name in
|
|
190
|
+
``dimensions`` (default: all of ``DEFAULT_DIMENSIONS``). ``created_since`` /
|
|
191
|
+
``created_until`` apply an inclusive client-side window on the ticket's
|
|
192
|
+
creation date. When the cap truncates, the result describes the fetched
|
|
193
|
+
subset — use :meth:`count_tickets` for the true total.
|
|
194
|
+
|
|
195
|
+
Delegates to the same pure :func:`aggregate_tickets` on both surfaces.
|
|
196
|
+
The page is collected into a list first because that function consumes
|
|
197
|
+
a plain iterable, and the async surface's ``iter_tickets`` is an async
|
|
198
|
+
generator it cannot take directly.
|
|
199
|
+
"""
|
|
200
|
+
dims = DEFAULT_DIMENSIONS if dimensions is None else dimensions
|
|
201
|
+
has_date_filter = created_since is not None or created_until is not None
|
|
202
|
+
fields = fields_for_references(dims, include_creation_date=has_date_filter)
|
|
203
|
+
tickets = [
|
|
204
|
+
t
|
|
205
|
+
async for t in self.iter_tickets(
|
|
206
|
+
search=search, fields=fields, max_records=max_records
|
|
207
|
+
)
|
|
208
|
+
]
|
|
209
|
+
return aggregate_tickets(
|
|
210
|
+
tickets,
|
|
211
|
+
dimensions=dims,
|
|
212
|
+
created_since=created_since,
|
|
213
|
+
created_until=created_until,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def update_ticket(self, rfc_number: str, update: RequestUpdate) -> Request:
|
|
217
|
+
spec, parse = requests_res.build_update_ticket(rfc_number, update)
|
|
218
|
+
return parse(await self._transport.send(spec))
|
|
219
|
+
|
|
220
|
+
async def close_ticket(
|
|
221
|
+
self,
|
|
222
|
+
rfc_number: str,
|
|
223
|
+
*,
|
|
224
|
+
status_guid: str | None = None,
|
|
225
|
+
delete_actions: int | None = None,
|
|
226
|
+
comment: str | None = None,
|
|
227
|
+
) -> Request:
|
|
228
|
+
spec, parse = requests_res.build_close_ticket(
|
|
229
|
+
rfc_number,
|
|
230
|
+
status_guid=status_guid,
|
|
231
|
+
delete_actions=delete_actions,
|
|
232
|
+
comment=comment,
|
|
233
|
+
)
|
|
234
|
+
return parse(await self._transport.send(spec))
|
|
235
|
+
|
|
236
|
+
# --- actions -------------------------------------------------------------
|
|
237
|
+
async def create_action(self, rfc_number: str, action: PostAction) -> Action:
|
|
238
|
+
"""Create one action on a ticket.
|
|
239
|
+
|
|
240
|
+
The returned :class:`Action` carries **no usable ``action_id``**: the
|
|
241
|
+
live create response is an HREF naming the parent request, with no
|
|
242
|
+
``ACTION_ID`` (verified live). To address the action you just created,
|
|
243
|
+
diff :meth:`list_actions` across the call — see
|
|
244
|
+
``integration_tests/test_live_ticket_history.py`` for the pattern.
|
|
245
|
+
"""
|
|
246
|
+
spec, parse = actions_res.build_create_action(rfc_number, action)
|
|
247
|
+
return parse(await self._transport.send(spec))
|
|
248
|
+
|
|
249
|
+
async def list_actions(self, rfc_number: str) -> list[Action]:
|
|
250
|
+
spec, parse = actions_res.build_list_actions(rfc_number)
|
|
251
|
+
return parse(await self._transport.send(spec))
|
|
252
|
+
|
|
253
|
+
async def get_action(self, action_id: str | int) -> Action:
|
|
254
|
+
"""Fetch one action, including the Memo links ``list_actions`` omits.
|
|
255
|
+
|
|
256
|
+
The note text lives behind :attr:`Action.description`'s href on this
|
|
257
|
+
record; :meth:`get_ticket_context` resolves it for you.
|
|
258
|
+
"""
|
|
259
|
+
spec, parse = actions_res.build_get_action(action_id)
|
|
260
|
+
return parse(await self._transport.send(spec))
|
|
261
|
+
|
|
262
|
+
async def _resolve_action_body(self, action: Action) -> Action:
|
|
263
|
+
"""Return ``action`` with its note text resolved onto ``description``.
|
|
264
|
+
|
|
265
|
+
Costs two requests per action (item fetch, then the Memo), so callers
|
|
266
|
+
that do not need bodies pass ``resolve_action_bodies=False``. Degrades
|
|
267
|
+
to the unresolved record on 403/404 rather than failing the bundle.
|
|
268
|
+
"""
|
|
269
|
+
if action.action_id is None:
|
|
270
|
+
return action
|
|
271
|
+
try:
|
|
272
|
+
full = await self.get_action(action.action_id)
|
|
273
|
+
except (EasyvistaNotFound, EasyvistaAuthError):
|
|
274
|
+
return action
|
|
275
|
+
if isinstance(full.description, dict):
|
|
276
|
+
href = full.description.get("HREF")
|
|
277
|
+
full.description = await self._safe_memo(href) if href else None
|
|
278
|
+
return full
|
|
279
|
+
|
|
280
|
+
# --- assets --------------------------------------------------------------
|
|
281
|
+
async def create_asset(self, asset: PostAsset) -> Asset:
|
|
282
|
+
spec, parse = assets_res.build_create_asset(asset)
|
|
283
|
+
return parse(await self._transport.send(spec))
|
|
284
|
+
|
|
285
|
+
async def get_asset(self, asset_id: str) -> Asset:
|
|
286
|
+
spec, parse = assets_res.build_get_asset(asset_id)
|
|
287
|
+
return parse(await self._transport.send(spec))
|
|
288
|
+
|
|
289
|
+
async def search_assets(
|
|
290
|
+
self,
|
|
291
|
+
*,
|
|
292
|
+
search: str | None = None,
|
|
293
|
+
fields: str | list[str] | None = None,
|
|
294
|
+
sort: str | None = None,
|
|
295
|
+
max_rows: int | None = None,
|
|
296
|
+
offset: int | None = None,
|
|
297
|
+
) -> SearchResult[Asset]:
|
|
298
|
+
if max_rows is None:
|
|
299
|
+
max_rows = self.config.default_max_rows
|
|
300
|
+
spec, parse = assets_res.build_search_assets(
|
|
301
|
+
search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset
|
|
302
|
+
)
|
|
303
|
+
return parse(await self._transport.send(spec))
|
|
304
|
+
|
|
305
|
+
async def iter_assets(
|
|
306
|
+
self,
|
|
307
|
+
*,
|
|
308
|
+
search: str | None = None,
|
|
309
|
+
fields: str | list[str] | None = None,
|
|
310
|
+
sort: str | None = None,
|
|
311
|
+
page_size: int | None = None,
|
|
312
|
+
max_records: int | None = None,
|
|
313
|
+
) -> AsyncIterator[Asset]:
|
|
314
|
+
"""Yield assets across pages (see :meth:`iter_tickets`)."""
|
|
315
|
+
if page_size is None:
|
|
316
|
+
page_size = self.config.default_max_rows
|
|
317
|
+
offset = 0
|
|
318
|
+
yielded = 0
|
|
319
|
+
while max_records is None or yielded < max_records:
|
|
320
|
+
result = await self.search_assets(
|
|
321
|
+
search=search,
|
|
322
|
+
fields=fields,
|
|
323
|
+
sort=sort,
|
|
324
|
+
max_rows=page_size,
|
|
325
|
+
offset=offset,
|
|
326
|
+
)
|
|
327
|
+
if not result.records:
|
|
328
|
+
return
|
|
329
|
+
for record in result.records:
|
|
330
|
+
yield record
|
|
331
|
+
yielded += 1
|
|
332
|
+
if max_records is not None and yielded >= max_records:
|
|
333
|
+
return
|
|
334
|
+
if result.next_url is None:
|
|
335
|
+
return
|
|
336
|
+
offset += len(result.records)
|
|
337
|
+
|
|
338
|
+
# --- documents -----------------------------------------------------------
|
|
339
|
+
async def add_document(
|
|
340
|
+
self, rfc_number: str, *, filename: str, content: bytes
|
|
341
|
+
) -> Document:
|
|
342
|
+
spec, parse = documents_res.build_add_document(
|
|
343
|
+
rfc_number, filename=filename, content=content
|
|
344
|
+
)
|
|
345
|
+
return parse(await self._transport.send(spec))
|
|
346
|
+
|
|
347
|
+
async def list_documents(self, rfc_number: str) -> list[Document]:
|
|
348
|
+
spec, parse = documents_res.build_list_documents(rfc_number)
|
|
349
|
+
return parse(await self._transport.send(spec))
|
|
350
|
+
|
|
351
|
+
async def download_document(self, document: Document | str) -> bytes:
|
|
352
|
+
"""Fetch an attachment's bytes.
|
|
353
|
+
|
|
354
|
+
``document`` is a :class:`Document` from :meth:`list_documents` or a raw
|
|
355
|
+
href/path. Raises :class:`ValueError` when the record carries no
|
|
356
|
+
download URL, and :class:`EasyvistaError` when that URL points outside
|
|
357
|
+
the configured instance (see
|
|
358
|
+
:meth:`~easyvista_python_client._async._transport.BaseTransport.resolve_url`).
|
|
359
|
+
"""
|
|
360
|
+
return await self._transport.get_bytes(documents_res.download_href(document))
|
|
361
|
+
|
|
362
|
+
# --- departments ----------------------------------------------------------
|
|
363
|
+
async def get_department(self, department_id: str | int) -> Department:
|
|
364
|
+
spec, parse = departments_res.build_get_department(department_id)
|
|
365
|
+
return parse(await self._transport.send(spec))
|
|
366
|
+
|
|
367
|
+
async def search_departments(
|
|
368
|
+
self,
|
|
369
|
+
*,
|
|
370
|
+
search: str | None = None,
|
|
371
|
+
fields: str | list[str] | None = None,
|
|
372
|
+
sort: str | None = None,
|
|
373
|
+
max_rows: int | None = None,
|
|
374
|
+
offset: int | None = None,
|
|
375
|
+
) -> SearchResult[Department]:
|
|
376
|
+
if max_rows is None:
|
|
377
|
+
max_rows = self.config.default_max_rows
|
|
378
|
+
spec, parse = departments_res.build_search_departments(
|
|
379
|
+
search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset
|
|
380
|
+
)
|
|
381
|
+
return parse(await self._transport.send(spec))
|
|
382
|
+
|
|
383
|
+
async def iter_departments(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
search: str | None = None,
|
|
387
|
+
fields: str | list[str] | None = None,
|
|
388
|
+
sort: str | None = None,
|
|
389
|
+
page_size: int | None = None,
|
|
390
|
+
max_records: int | None = None,
|
|
391
|
+
) -> AsyncIterator[Department]:
|
|
392
|
+
"""Yield departments across pages (see :meth:`iter_tickets`)."""
|
|
393
|
+
if page_size is None:
|
|
394
|
+
page_size = self.config.default_max_rows
|
|
395
|
+
offset = 0
|
|
396
|
+
yielded = 0
|
|
397
|
+
while max_records is None or yielded < max_records:
|
|
398
|
+
result = await self.search_departments(
|
|
399
|
+
search=search,
|
|
400
|
+
fields=fields,
|
|
401
|
+
sort=sort,
|
|
402
|
+
max_rows=page_size,
|
|
403
|
+
offset=offset,
|
|
404
|
+
)
|
|
405
|
+
if not result.records:
|
|
406
|
+
return
|
|
407
|
+
for record in result.records:
|
|
408
|
+
yield record
|
|
409
|
+
yielded += 1
|
|
410
|
+
if max_records is not None and yielded >= max_records:
|
|
411
|
+
return
|
|
412
|
+
if result.next_url is None:
|
|
413
|
+
return
|
|
414
|
+
offset += len(result.records)
|
|
415
|
+
|
|
416
|
+
async def get_department_comment(self, department_id: str | int) -> str | None:
|
|
417
|
+
"""Return the department's note (a Memo).
|
|
418
|
+
|
|
419
|
+
``""`` for an empty note; propagates transport errors so a 403/404 is
|
|
420
|
+
distinguishable from an empty note (uses the generic ``resolve_memo``).
|
|
421
|
+
"""
|
|
422
|
+
return await self.resolve_memo(
|
|
423
|
+
f"departments/{department_id}/comment_department"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
async def find_departments(
|
|
427
|
+
self, name: str, *, limit: int | None = None
|
|
428
|
+
) -> list[Department]:
|
|
429
|
+
"""Resolve departments by a fuzzy, language-agnostic ``name``.
|
|
430
|
+
|
|
431
|
+
Fast path (neutral): an all-digit ``name`` matches ``DEPARTMENT_ID`` exactly,
|
|
432
|
+
otherwise ``DEPARTMENT_CODE`` exactly; a hit returns immediately. Fuzzy
|
|
433
|
+
fallback: scan every department and match ``name`` — normalized so
|
|
434
|
+
``"Acme Corp" == "ACME-CORP" == "acmecorp"`` — as a substring of any
|
|
435
|
+
string field. ``limit`` caps the result count. Returns ``[]`` on no match.
|
|
436
|
+
|
|
437
|
+
A ``name`` that cannot be expressed in EasyVista's search grammar (see
|
|
438
|
+
:func:`~easyvista_python_client.is_safe_ev_value`) skips the server fast
|
|
439
|
+
path and falls back directly to the client-side scan, so this method
|
|
440
|
+
returns correct results rather than raising.
|
|
441
|
+
"""
|
|
442
|
+
# The server fast path is only an optimization, and a name that cannot be
|
|
443
|
+
# expressed server-side would otherwise be interpolated raw — where a ','
|
|
444
|
+
# silently widens the result set. Such names skip straight to the local
|
|
445
|
+
# scan below, which handles any characters.
|
|
446
|
+
field = "DEPARTMENT_ID" if name.isdigit() else "DEPARTMENT_CODE"
|
|
447
|
+
if is_safe_ev_value(name):
|
|
448
|
+
search = ev_equals_filter(field, name)
|
|
449
|
+
if search is not None:
|
|
450
|
+
fast = await self.search_departments(search=search)
|
|
451
|
+
if fast.records:
|
|
452
|
+
return fast.records if limit is None else fast.records[:limit]
|
|
453
|
+
needle = _normalize_name(name)
|
|
454
|
+
if not needle:
|
|
455
|
+
return []
|
|
456
|
+
matches: list[Department] = []
|
|
457
|
+
async for dept in self.iter_departments():
|
|
458
|
+
if _department_matches(dept, needle):
|
|
459
|
+
matches.append(dept)
|
|
460
|
+
if limit is not None and len(matches) >= limit:
|
|
461
|
+
break
|
|
462
|
+
return matches
|
|
463
|
+
|
|
464
|
+
async def create_department(self, department: PostDepartment) -> Department:
|
|
465
|
+
"""Create a department (provisional; profile-gated — spec open item O-DIR-2)."""
|
|
466
|
+
spec, parse = departments_res.build_create_department(department)
|
|
467
|
+
return parse(await self._transport.send(spec))
|
|
468
|
+
|
|
469
|
+
async def update_department(
|
|
470
|
+
self, department_id: str | int, update: DepartmentUpdate
|
|
471
|
+
) -> Department:
|
|
472
|
+
"""Update a department via PUT (provisional; profile-gated)."""
|
|
473
|
+
spec, parse = departments_res.build_update_department(department_id, update)
|
|
474
|
+
return parse(await self._transport.send(spec))
|
|
475
|
+
|
|
476
|
+
# --- employees ------------------------------------------------------------
|
|
477
|
+
async def get_employee(self, employee_id: str | int) -> Employee:
|
|
478
|
+
spec, parse = employees_res.build_get_employee(employee_id)
|
|
479
|
+
return parse(await self._transport.send(spec))
|
|
480
|
+
|
|
481
|
+
async def search_employees(
|
|
482
|
+
self,
|
|
483
|
+
*,
|
|
484
|
+
search: str | None = None,
|
|
485
|
+
fields: str | list[str] | None = None,
|
|
486
|
+
sort: str | None = None,
|
|
487
|
+
max_rows: int | None = None,
|
|
488
|
+
offset: int | None = None,
|
|
489
|
+
) -> SearchResult[Employee]:
|
|
490
|
+
if max_rows is None:
|
|
491
|
+
max_rows = self.config.default_max_rows
|
|
492
|
+
spec, parse = employees_res.build_search_employees(
|
|
493
|
+
search=search, fields=fields, sort=sort, max_rows=max_rows, offset=offset
|
|
494
|
+
)
|
|
495
|
+
return parse(await self._transport.send(spec))
|
|
496
|
+
|
|
497
|
+
async def iter_employees(
|
|
498
|
+
self,
|
|
499
|
+
*,
|
|
500
|
+
search: str | None = None,
|
|
501
|
+
fields: str | list[str] | None = None,
|
|
502
|
+
sort: str | None = None,
|
|
503
|
+
page_size: int | None = None,
|
|
504
|
+
max_records: int | None = None,
|
|
505
|
+
) -> AsyncIterator[Employee]:
|
|
506
|
+
"""Yield employees across pages (see :meth:`iter_tickets`)."""
|
|
507
|
+
if page_size is None:
|
|
508
|
+
page_size = self.config.default_max_rows
|
|
509
|
+
offset = 0
|
|
510
|
+
yielded = 0
|
|
511
|
+
while max_records is None or yielded < max_records:
|
|
512
|
+
result = await self.search_employees(
|
|
513
|
+
search=search,
|
|
514
|
+
fields=fields,
|
|
515
|
+
sort=sort,
|
|
516
|
+
max_rows=page_size,
|
|
517
|
+
offset=offset,
|
|
518
|
+
)
|
|
519
|
+
if not result.records:
|
|
520
|
+
return
|
|
521
|
+
for record in result.records:
|
|
522
|
+
yield record
|
|
523
|
+
yielded += 1
|
|
524
|
+
if max_records is not None and yielded >= max_records:
|
|
525
|
+
return
|
|
526
|
+
if result.next_url is None:
|
|
527
|
+
return
|
|
528
|
+
offset += len(result.records)
|
|
529
|
+
|
|
530
|
+
async def create_employee(self, employee: PostEmployee) -> Employee:
|
|
531
|
+
"""Create an employee (provisional; profile-gated — spec open item O-DIR-2)."""
|
|
532
|
+
spec, parse = employees_res.build_create_employee(employee)
|
|
533
|
+
return parse(await self._transport.send(spec))
|
|
534
|
+
|
|
535
|
+
async def update_employee(
|
|
536
|
+
self, employee_id: str | int, update: EmployeeUpdate
|
|
537
|
+
) -> Employee:
|
|
538
|
+
"""Update an employee via PUT (provisional; profile-gated)."""
|
|
539
|
+
spec, parse = employees_res.build_update_employee(employee_id, update)
|
|
540
|
+
return parse(await self._transport.send(spec))
|
|
541
|
+
|
|
542
|
+
# --- aggregated context --------------------------------------------------
|
|
543
|
+
async def resolve_memo(self, href: str) -> str | None:
|
|
544
|
+
"""Fetch a Memo/link field's text from its sub-resource.
|
|
545
|
+
|
|
546
|
+
``href`` may be a full URL (as returned in a record's link) or a
|
|
547
|
+
resource-relative path. Propagates transport errors so callers can tell
|
|
548
|
+
an empty Memo (``""``) from a 403/404.
|
|
549
|
+
"""
|
|
550
|
+
path = href
|
|
551
|
+
root = self.config.api_root
|
|
552
|
+
if path.startswith(root):
|
|
553
|
+
path = path[len(root) :]
|
|
554
|
+
path = path.lstrip("/")
|
|
555
|
+
field = path.rstrip("/").rsplit("/", 1)[-1]
|
|
556
|
+
return parse_memo(await self._transport.send(RequestSpec("GET", path)), field)
|
|
557
|
+
|
|
558
|
+
async def get_ticket_context(
|
|
559
|
+
self, rfc_number: str, *, resolve_action_bodies: bool = True
|
|
560
|
+
) -> TicketContext:
|
|
561
|
+
"""Fetch a ticket plus its resolved narrative content as a bundle.
|
|
562
|
+
|
|
563
|
+
Resolves the href-only ``description``/``comment`` sub-resources and
|
|
564
|
+
lists actions/documents. Missing sub-resources (404) or
|
|
565
|
+
profile-restricted lists (403) degrade to ``None`` / ``[]`` rather than
|
|
566
|
+
failing the whole call.
|
|
567
|
+
|
|
568
|
+
``resolve_action_bodies`` (default on) additionally fetches each action
|
|
569
|
+
item-level and resolves its note text, because ``list_actions`` does not
|
|
570
|
+
return it — without this the rendered Markdown has empty action bodies.
|
|
571
|
+
It costs two extra requests per action; pass ``False`` to skip it when
|
|
572
|
+
you only need the action list.
|
|
573
|
+
|
|
574
|
+
On the async surface the independent requests (the two memos plus the
|
|
575
|
+
actions and documents lists) are issued concurrently, in up to three
|
|
576
|
+
waves; on the sync surface they run one after another in source order,
|
|
577
|
+
costing ``4 + 2N`` serial round trips for a ticket with ``N`` actions.
|
|
578
|
+
Measured against a live instance on a 19-action ticket: 5.31s
|
|
579
|
+
concurrent, 14.65s serial.
|
|
580
|
+
|
|
581
|
+
Peak in-flight on the async surface is four sub-resource requests, then
|
|
582
|
+
up to ``_ACTION_FANOUT`` action-body resolutions; the sync surface
|
|
583
|
+
issues one request at a time throughout. On a hard failure (5xx, a
|
|
584
|
+
transport error) siblings already in flight on the async surface run to
|
|
585
|
+
completion before the error propagates, so a failing call there can
|
|
586
|
+
issue more requests than the sequential surface does. That is the
|
|
587
|
+
deliberate trade: settling every sibling is what keeps an orphaned
|
|
588
|
+
request from outliving the call that issued it, and what makes the
|
|
589
|
+
exception a caller sees the one the sequential surface would have
|
|
590
|
+
raised rather than whichever branch happened to fail soonest.
|
|
591
|
+
"""
|
|
592
|
+
# Issued first, and deliberately outside the fan-out: this is the one
|
|
593
|
+
# call with no fallback, so a wrong RFC number should cost one request,
|
|
594
|
+
# not five.
|
|
595
|
+
ticket = await self.get_ticket(rfc_number)
|
|
596
|
+
|
|
597
|
+
# The asymmetry between these two except clauses is real and
|
|
598
|
+
# load-bearing: the memos degrade on 404 *and* 403, while the two list
|
|
599
|
+
# calls catch EasyvistaAuthError ONLY, so a 404 there still fails the
|
|
600
|
+
# bundle. Do not tidy them into a shared handler.
|
|
601
|
+
async def _actions() -> list[Action]:
|
|
602
|
+
try:
|
|
603
|
+
return await self.list_actions(rfc_number)
|
|
604
|
+
except EasyvistaAuthError:
|
|
605
|
+
return []
|
|
606
|
+
|
|
607
|
+
async def _documents() -> list[Document]:
|
|
608
|
+
try:
|
|
609
|
+
return await self.list_documents(rfc_number)
|
|
610
|
+
except EasyvistaAuthError:
|
|
611
|
+
return []
|
|
612
|
+
|
|
613
|
+
description, comment, actions, documents = await settle(
|
|
614
|
+
self._safe_memo(f"requests/{rfc_number}/description"),
|
|
615
|
+
self._safe_memo(f"requests/{rfc_number}/comment"),
|
|
616
|
+
_actions(),
|
|
617
|
+
_documents(),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
if resolve_action_bodies:
|
|
621
|
+
actions = await self._resolve_action_bodies(actions)
|
|
622
|
+
|
|
623
|
+
return TicketContext(
|
|
624
|
+
ticket=ticket,
|
|
625
|
+
description=description,
|
|
626
|
+
comment=comment,
|
|
627
|
+
actions=actions,
|
|
628
|
+
documents=documents,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
async def _resolve_action_bodies(self, actions: list[Action]) -> list[Action]:
|
|
632
|
+
"""Resolve every action's note text, preserving ``list_actions`` order.
|
|
633
|
+
|
|
634
|
+
On the async surface the resolutions are issued concurrently, at most
|
|
635
|
+
``_ACTION_FANOUT`` in flight; on the sync surface they run one after
|
|
636
|
+
another and the bound is inert. ``settle`` preserves source order on
|
|
637
|
+
either surface, so the returned list matches ``list_actions`` order
|
|
638
|
+
whichever resolution finishes first.
|
|
639
|
+
|
|
640
|
+
The limiter is built **here, per call**. On the async surface it is an
|
|
641
|
+
``asyncio.Semaphore``, which binds to the first event loop that
|
|
642
|
+
*contends* it -- an uncontended acquire never touches the loop at all --
|
|
643
|
+
so one stored on the client or at module level would pass every
|
|
644
|
+
low-traffic test and then raise ``RuntimeError: bound to a different
|
|
645
|
+
event loop`` the first time a second loop contended it, i.e. in
|
|
646
|
+
production under load (measured on 3.10). A per-call limiter cannot do
|
|
647
|
+
that.
|
|
648
|
+
"""
|
|
649
|
+
limiter = Semaphore(_ACTION_FANOUT)
|
|
650
|
+
|
|
651
|
+
async def _one(action: Action) -> Action:
|
|
652
|
+
async with limiter:
|
|
653
|
+
return await self._resolve_action_body(action)
|
|
654
|
+
|
|
655
|
+
resolved: list[Action] = await settle(*(_one(a) for a in actions))
|
|
656
|
+
return resolved
|
|
657
|
+
|
|
658
|
+
async def get_department_context(
|
|
659
|
+
self,
|
|
660
|
+
department_id: str | int,
|
|
661
|
+
*,
|
|
662
|
+
recent_tickets: int = 10,
|
|
663
|
+
dimensions: Sequence[str] | None = None,
|
|
664
|
+
include_statistics: bool = True,
|
|
665
|
+
include_assets: bool = True,
|
|
666
|
+
resolve_manager: bool = True,
|
|
667
|
+
include_note: bool = True,
|
|
668
|
+
) -> DepartmentContext:
|
|
669
|
+
"""Assemble a department plus its employees, manager, note, tickets and assets.
|
|
670
|
+
|
|
671
|
+
Only :meth:`get_department` is required; every related part is wrapped so a
|
|
672
|
+
403/404 degrades it to ``[]`` / ``None`` / ``0`` (same pattern as
|
|
673
|
+
:meth:`get_ticket_context`). The flags trim the heavier related calls.
|
|
674
|
+
Tickets and assets filter on ``DEPARTMENT_ID:"<id>"``. ``recent_tickets``
|
|
675
|
+
ordering is best-effort: it relies on the server honoring
|
|
676
|
+
``RECENT_TICKETS_SORT`` (open item O-DIR-1) and silently degrades to the
|
|
677
|
+
API's default order otherwise.
|
|
678
|
+
|
|
679
|
+
On the async surface the seven independent branches are issued
|
|
680
|
+
concurrently, costing two waves instead of eight serial steps; on the
|
|
681
|
+
sync surface they run one after another in source order. The three
|
|
682
|
+
paginating branches still page serially within themselves on either
|
|
683
|
+
surface -- offset pagination cannot be parallelised -- but on the async
|
|
684
|
+
surface they page alongside each other.
|
|
685
|
+
"""
|
|
686
|
+
# Issued first, outside the fan-out: the department itself, plus the
|
|
687
|
+
# search guard. Kept outside because the manager lookup needs
|
|
688
|
+
# `department.manager_id`, and because a bad department_id should cost
|
|
689
|
+
# one request, not seven.
|
|
690
|
+
department = await self.get_department(department_id)
|
|
691
|
+
search = ev_equals_filter("DEPARTMENT_ID", department_id)
|
|
692
|
+
if search is None:
|
|
693
|
+
raise ValueError("department_id is required to build a department context")
|
|
694
|
+
|
|
695
|
+
# Every branch here degrades on both 403 and 404, unlike the ticket
|
|
696
|
+
# bundle above, whose two list calls catch EasyvistaAuthError only. The
|
|
697
|
+
# `include_*` / `resolve_*` flags sit inside the branch so a disabled one
|
|
698
|
+
# costs no request at all, exactly as a plain `if` around the call would.
|
|
699
|
+
async def _employees() -> list[Employee]:
|
|
700
|
+
try:
|
|
701
|
+
return [e async for e in self.iter_employees(search=search)]
|
|
702
|
+
except (EasyvistaAuthError, EasyvistaNotFound):
|
|
703
|
+
return []
|
|
704
|
+
|
|
705
|
+
async def _manager() -> Employee | None:
|
|
706
|
+
if not resolve_manager or department.manager_id is None:
|
|
707
|
+
return None
|
|
708
|
+
try:
|
|
709
|
+
return await self.get_employee(department.manager_id)
|
|
710
|
+
except (EasyvistaAuthError, EasyvistaNotFound):
|
|
711
|
+
return None
|
|
712
|
+
|
|
713
|
+
async def _note() -> str | None:
|
|
714
|
+
if not include_note:
|
|
715
|
+
return None
|
|
716
|
+
return await self._safe_memo(
|
|
717
|
+
f"departments/{department_id}/comment_department"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
async def _ticket_count() -> int:
|
|
721
|
+
try:
|
|
722
|
+
return await self.count_tickets(search=search)
|
|
723
|
+
except (EasyvistaAuthError, EasyvistaNotFound):
|
|
724
|
+
return 0
|
|
725
|
+
|
|
726
|
+
async def _recent() -> list[Request]:
|
|
727
|
+
try:
|
|
728
|
+
return [
|
|
729
|
+
t
|
|
730
|
+
async for t in self.iter_tickets(
|
|
731
|
+
search=search,
|
|
732
|
+
sort=RECENT_TICKETS_SORT,
|
|
733
|
+
max_records=recent_tickets,
|
|
734
|
+
)
|
|
735
|
+
]
|
|
736
|
+
except (EasyvistaAuthError, EasyvistaNotFound):
|
|
737
|
+
return []
|
|
738
|
+
|
|
739
|
+
async def _statistics() -> TicketStatistics | None:
|
|
740
|
+
if not include_statistics:
|
|
741
|
+
return None
|
|
742
|
+
try:
|
|
743
|
+
return await self.ticket_statistics(
|
|
744
|
+
search=search, dimensions=dimensions
|
|
745
|
+
)
|
|
746
|
+
except (EasyvistaAuthError, EasyvistaNotFound):
|
|
747
|
+
return None
|
|
748
|
+
|
|
749
|
+
async def _assets() -> list[Asset]:
|
|
750
|
+
if not include_assets:
|
|
751
|
+
return []
|
|
752
|
+
try:
|
|
753
|
+
return [a async for a in self.iter_assets(search=search)]
|
|
754
|
+
except (EasyvistaAuthError, EasyvistaNotFound):
|
|
755
|
+
return []
|
|
756
|
+
|
|
757
|
+
(
|
|
758
|
+
employees,
|
|
759
|
+
manager,
|
|
760
|
+
note,
|
|
761
|
+
ticket_count,
|
|
762
|
+
recent,
|
|
763
|
+
statistics,
|
|
764
|
+
assets,
|
|
765
|
+
) = await settle(
|
|
766
|
+
_employees(),
|
|
767
|
+
_manager(),
|
|
768
|
+
_note(),
|
|
769
|
+
_ticket_count(),
|
|
770
|
+
_recent(),
|
|
771
|
+
_statistics(),
|
|
772
|
+
_assets(),
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
return DepartmentContext(
|
|
776
|
+
department=department,
|
|
777
|
+
employees=employees,
|
|
778
|
+
manager=manager,
|
|
779
|
+
note=note,
|
|
780
|
+
ticket_count=ticket_count,
|
|
781
|
+
recent_tickets=recent,
|
|
782
|
+
ticket_statistics=statistics,
|
|
783
|
+
assets=assets,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
async def _safe_memo(self, path: str) -> str | None:
|
|
787
|
+
try:
|
|
788
|
+
return await self.resolve_memo(path)
|
|
789
|
+
except (EasyvistaNotFound, EasyvistaAuthError):
|
|
790
|
+
return None
|