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.
Files changed (42) hide show
  1. easyvista_python_client/__init__.py +74 -0
  2. easyvista_python_client/_async/__init__.py +13 -0
  3. easyvista_python_client/_async/_concurrency.py +78 -0
  4. easyvista_python_client/_async/_transport.py +266 -0
  5. easyvista_python_client/_async/client.py +790 -0
  6. easyvista_python_client/_fields.py +26 -0
  7. easyvista_python_client/_html.py +41 -0
  8. easyvista_python_client/_sync/__init__.py +13 -0
  9. easyvista_python_client/_sync/_concurrency.py +50 -0
  10. easyvista_python_client/_sync/_transport.py +266 -0
  11. easyvista_python_client/_sync/client.py +790 -0
  12. easyvista_python_client/_transport.py +29 -0
  13. easyvista_python_client/config.py +71 -0
  14. easyvista_python_client/context.py +116 -0
  15. easyvista_python_client/directory.py +53 -0
  16. easyvista_python_client/exceptions.py +53 -0
  17. easyvista_python_client/field_model.py +74 -0
  18. easyvista_python_client/filters.py +82 -0
  19. easyvista_python_client/models/__init__.py +1 -0
  20. easyvista_python_client/models/action.py +65 -0
  21. easyvista_python_client/models/asset.py +36 -0
  22. easyvista_python_client/models/common.py +78 -0
  23. easyvista_python_client/models/department.py +68 -0
  24. easyvista_python_client/models/document.py +32 -0
  25. easyvista_python_client/models/employee.py +67 -0
  26. easyvista_python_client/models/request.py +172 -0
  27. easyvista_python_client/pagination.py +84 -0
  28. easyvista_python_client/py.typed +0 -0
  29. easyvista_python_client/references.py +146 -0
  30. easyvista_python_client/reporting.py +143 -0
  31. easyvista_python_client/resources/__init__.py +1 -0
  32. easyvista_python_client/resources/actions.py +72 -0
  33. easyvista_python_client/resources/assets.py +47 -0
  34. easyvista_python_client/resources/departments.py +57 -0
  35. easyvista_python_client/resources/descriptor.py +99 -0
  36. easyvista_python_client/resources/documents.py +78 -0
  37. easyvista_python_client/resources/employees.py +57 -0
  38. easyvista_python_client/resources/requests.py +91 -0
  39. easyvista_python_client-0.1.0.dist-info/METADATA +178 -0
  40. easyvista_python_client-0.1.0.dist-info/RECORD +42 -0
  41. easyvista_python_client-0.1.0.dist-info/WHEEL +4 -0
  42. 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