targetprocess-py 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 (58) hide show
  1. targetprocess/__init__.py +231 -0
  2. targetprocess/_assignables.py +162 -0
  3. targetprocess/_base.py +318 -0
  4. targetprocess/_content.py +287 -0
  5. targetprocess/_dates.py +70 -0
  6. targetprocess/_entity_types.py +33 -0
  7. targetprocess/_generals.py +324 -0
  8. targetprocess/_joins.py +217 -0
  9. targetprocess/_lookups.py +477 -0
  10. targetprocess/_nested.py +223 -0
  11. targetprocess/_observability.py +368 -0
  12. targetprocess/client.py +430 -0
  13. targetprocess/exceptions.py +147 -0
  14. targetprocess/models.py +121 -0
  15. targetprocess/py.typed +1 -0
  16. targetprocess/request_handler.py +724 -0
  17. targetprocess/resources/__init__.py +76 -0
  18. targetprocess/resources/assignments.py +29 -0
  19. targetprocess/resources/attachments.py +164 -0
  20. targetprocess/resources/base.py +563 -0
  21. targetprocess/resources/bugs.py +20 -0
  22. targetprocess/resources/comments.py +23 -0
  23. targetprocess/resources/custom_activities.py +56 -0
  24. targetprocess/resources/custom_fields.py +27 -0
  25. targetprocess/resources/custom_rules.py +28 -0
  26. targetprocess/resources/entities.py +433 -0
  27. targetprocess/resources/entity_states.py +20 -0
  28. targetprocess/resources/entity_types.py +61 -0
  29. targetprocess/resources/epics.py +20 -0
  30. targetprocess/resources/features.py +20 -0
  31. targetprocess/resources/iterations.py +20 -0
  32. targetprocess/resources/priorities.py +85 -0
  33. targetprocess/resources/processes.py +65 -0
  34. targetprocess/resources/projects.py +20 -0
  35. targetprocess/resources/relation_types.py +59 -0
  36. targetprocess/resources/relations.py +34 -0
  37. targetprocess/resources/releases.py +20 -0
  38. targetprocess/resources/requests.py +20 -0
  39. targetprocess/resources/role_efforts.py +21 -0
  40. targetprocess/resources/roles.py +52 -0
  41. targetprocess/resources/severities.py +53 -0
  42. targetprocess/resources/tasks.py +20 -0
  43. targetprocess/resources/team_assignments.py +21 -0
  44. targetprocess/resources/team_iterations.py +24 -0
  45. targetprocess/resources/teams.py +20 -0
  46. targetprocess/resources/terms.py +28 -0
  47. targetprocess/resources/test_cases.py +20 -0
  48. targetprocess/resources/times.py +392 -0
  49. targetprocess/resources/user_stories.py +20 -0
  50. targetprocess/resources/users.py +20 -0
  51. targetprocess/resources/workflows.py +27 -0
  52. targetprocess/response_parser.py +113 -0
  53. targetprocess/transport.py +116 -0
  54. targetprocess/types.py +56 -0
  55. targetprocess_py-0.1.0.dist-info/METADATA +476 -0
  56. targetprocess_py-0.1.0.dist-info/RECORD +58 -0
  57. targetprocess_py-0.1.0.dist-info/WHEEL +4 -0
  58. targetprocess_py-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,724 @@
1
+ """Request handler for TargetProcess API operations."""
2
+
3
+ import asyncio
4
+ import builtins
5
+ from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
6
+ from datetime import UTC, datetime, timedelta
7
+ from email.utils import parsedate_to_datetime
8
+ from typing import Any, cast
9
+ from urllib.parse import urlencode
10
+
11
+ import httpx
12
+ from easylimit import RateLimiter
13
+
14
+ from targetprocess._entity_types import require_entity_type
15
+ from targetprocess._observability import (
16
+ current_request_id,
17
+ logger,
18
+ new_request_id,
19
+ request_id_context,
20
+ scrub_url,
21
+ )
22
+ from targetprocess.exceptions import APIError, NetworkError, ParseError
23
+ from targetprocess.response_parser import ResponseParser
24
+ from targetprocess.transport import HTTPTransport
25
+
26
+ # Retry on 429/5xx: initial attempt + this many retries = 4 requests max.
27
+ _MAX_ATTEMPTS = 4
28
+ _BACKOFF_BASE_SECONDS = 0.5
29
+ _BACKOFF_FACTOR = 2
30
+ _MAX_DELAY_SECONDS = 30.0
31
+
32
+
33
+ class RequestHandler:
34
+ """Handle API requests with rate limiting, retry/backoff, and response parsing.
35
+
36
+ Orchestrates:
37
+ - URL construction with query parameters
38
+ - Write-permission enforcement (defense in depth alongside the
39
+ resource layer)
40
+ - Rate limiting (100 requests/minute), applied once per attempt
41
+ - Retry with exponential backoff on 429/5xx, safe methods only
42
+ (GET/HEAD/OPTIONS) - mutating methods are never auto-retried
43
+ - HTTP calls via HTTPTransport
44
+ - Error checking and mapping
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ transport: HTTPTransport,
50
+ *,
51
+ check_write: Callable[[], None] | None = None,
52
+ sleep: Callable[[float], Awaitable[None]] | None = None,
53
+ ) -> None:
54
+ """Initialize request handler.
55
+
56
+ Args:
57
+ transport: HTTPTransport instance for making HTTP calls
58
+ check_write: Optional callback invoked before every write
59
+ (create/update/delete), raising if writes aren't permitted.
60
+ This is the client's readonly-mode check, injected so writes
61
+ are refused here too - not only at the resource layer above.
62
+ Defaults to no check, since RequestHandler is also
63
+ constructed directly in tests without a client.
64
+ sleep: Optional callable used to wait between retries. Defaults
65
+ to ``asyncio.sleep``; injected so retry-backoff tests never
66
+ really sleep.
67
+ """
68
+ self._transport = transport
69
+ self._check_write = check_write
70
+ self._sleep = sleep or asyncio.sleep
71
+ # TargetProcess API limit: ~100 requests per minute
72
+ self._rate_limiter = RateLimiter(limit=100, period=timedelta(minutes=1))
73
+
74
+ async def _request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
75
+ """Make rate-limited HTTP request, retrying 429/5xx on safe methods only.
76
+
77
+ Retries up to 3 times (4 attempts total), with exponential backoff
78
+ (0.5s base, factor 2) capped at 30s per delay. A numeric
79
+ ``Retry-After`` header, when present, is honoured in place of the
80
+ computed delay; an HTTP-date value is also honoured (converted to a
81
+ delay relative to now); a header that is neither falls back to the
82
+ exponential schedule. The rate limiter is (re-)acquired once per
83
+ attempt.
84
+
85
+ Retry eligibility is method-aware:
86
+
87
+ - GET/HEAD/OPTIONS retry on 429 or 5xx - these methods have no side
88
+ effects, so re-sending is always safe.
89
+ - POST/PUT/DELETE never retry, on 429 or 5xx alike. TargetProcess
90
+ documents no guarantee that a 429 response means a mutation was
91
+ not processed, and offers no idempotency keys - so retrying a
92
+ mutating request risks duplicating it. Both status classes
93
+ surface immediately instead; retrying a mutation, if a caller
94
+ knows it to be safe, is the caller's own responsibility.
95
+
96
+ A transport-level failure (no response received) is never retried -
97
+ it always raises NetworkError immediately. Once retries are
98
+ exhausted (or a non-retryable status arrives), the last response's
99
+ status is mapped via the normal error handling below, so a
100
+ persistent 429/5xx surfaces as RateLimitError/APIError rather than a
101
+ bespoke retry exception.
102
+
103
+ Args:
104
+ method: HTTP method (GET, POST, PUT, DELETE)
105
+ url: Full URL to request
106
+ **kwargs: Additional arguments for httpx request
107
+
108
+ Returns:
109
+ HTTP response
110
+
111
+ Raises:
112
+ NetworkError: The request could not be sent (connection error,
113
+ timeout, DNS failure, ...) - no response was received.
114
+ Various exceptions via ResponseParser.parse_error for error status codes
115
+ """
116
+ request_id = current_request_id() or new_request_id()
117
+ response: httpx.Response
118
+ with request_id_context(request_id):
119
+ logger.debug(
120
+ "request.start",
121
+ extra={"method": method, "url": scrub_url(url), "request_id": request_id},
122
+ )
123
+ for attempt in range(_MAX_ATTEMPTS):
124
+ async with self._rate_limiter:
125
+ try:
126
+ response = await self._transport._client.request(method, url, **kwargs)
127
+ except httpx.HTTPError as exc:
128
+ logger.error(
129
+ "request.transport_error",
130
+ extra={"method": method, "error": repr(exc), "request_id": request_id},
131
+ )
132
+ raise NetworkError(f"transport failure: {exc!r}") from exc
133
+
134
+ retryable = method.upper() in {"GET", "HEAD", "OPTIONS"} and (
135
+ response.status_code == 429 or response.status_code >= 500
136
+ )
137
+ if not retryable or attempt == _MAX_ATTEMPTS - 1:
138
+ break
139
+ delay = self._retry_delay(response, attempt)
140
+ logger.warning(
141
+ "request.retry",
142
+ extra={
143
+ "method": method,
144
+ "status": response.status_code,
145
+ "attempt": attempt + 1,
146
+ "delay": delay,
147
+ "request_id": request_id,
148
+ },
149
+ )
150
+ await self._sleep(delay)
151
+
152
+ # Check for errors and raise appropriate exceptions
153
+ if response.status_code >= 400:
154
+ logger.warning(
155
+ "request.error",
156
+ extra={
157
+ "method": method,
158
+ "status": response.status_code,
159
+ "request_id": request_id,
160
+ },
161
+ )
162
+ ResponseParser.parse_error(response)
163
+
164
+ logger.debug(
165
+ "request.complete",
166
+ extra={"method": method, "status": response.status_code, "request_id": request_id},
167
+ )
168
+ return response
169
+
170
+ @staticmethod
171
+ def _retry_delay(response: httpx.Response, attempt: int) -> float:
172
+ """Compute the backoff delay before the next retry attempt.
173
+
174
+ Honours a ``Retry-After`` header when present, tried in order:
175
+ numeric delta-seconds first, then an HTTP-date (parsed via
176
+ ``email.utils.parsedate_to_datetime`` and converted to a delay
177
+ relative to now); a header that matches neither format falls back to
178
+ the exponential schedule, as does a missing header. A negative or
179
+ past-due delay is floored at 0s. Every delay is capped at 30s.
180
+ """
181
+ retry_after = response.headers.get("Retry-After")
182
+ delay: float | None = None
183
+ if retry_after is not None:
184
+ try:
185
+ delay = float(retry_after)
186
+ except ValueError:
187
+ try:
188
+ retry_date = parsedate_to_datetime(retry_after)
189
+ if retry_date.tzinfo is None:
190
+ retry_date = retry_date.replace(tzinfo=UTC)
191
+ delay = (retry_date - datetime.now(UTC)).total_seconds()
192
+ except (ValueError, TypeError):
193
+ delay = None
194
+ if delay is None:
195
+ delay = _BACKOFF_BASE_SECONDS * _BACKOFF_FACTOR**attempt
196
+ return min(max(delay, 0.0), _MAX_DELAY_SECONDS)
197
+
198
+ @staticmethod
199
+ def _shaping_params(
200
+ *,
201
+ include: list[str] | None,
202
+ exclude: list[str] | None,
203
+ result_include: list[str] | None,
204
+ append: list[str] | None,
205
+ innertake: int | None,
206
+ ) -> dict[str, str]:
207
+ """Build the response-shaping query params shared by ``get`` and ``list``.
208
+
209
+ Field lists render in TP's bracketed form (``include=[Field1,Field2]``);
210
+ ``innertake`` renders as a plain integer.
211
+
212
+ Raises:
213
+ ValueError: ``innertake`` is negative.
214
+ """
215
+ params: dict[str, str] = {}
216
+ for name, fields in (
217
+ ("include", include),
218
+ ("exclude", exclude),
219
+ ("resultInclude", result_include),
220
+ ("append", append),
221
+ ):
222
+ if fields:
223
+ params[name] = f"[{','.join(fields)}]"
224
+ if innertake is not None:
225
+ if innertake < 0:
226
+ raise ValueError(f"innertake must be >= 0 (got {innertake})")
227
+ params["innertake"] = str(innertake)
228
+ return params
229
+
230
+ async def get(
231
+ self,
232
+ entity_type: str,
233
+ id: int,
234
+ include: list[str] | None = None,
235
+ *,
236
+ exclude: list[str] | None = None,
237
+ result_include: list[str] | None = None,
238
+ append: list[str] | None = None,
239
+ innertake: int | None = None,
240
+ ) -> dict[str, Any]:
241
+ """Get single entity by ID.
242
+
243
+ Args:
244
+ entity_type: TP entity type (e.g., "UserStories", "Bugs")
245
+ id: Entity ID
246
+ include: Optional list of fields to include
247
+ exclude: Optional list of fields to exclude from the response
248
+ (server-side; the complement of ``include``)
249
+ result_include: Optional list of fields to restrict the response
250
+ to - server-side payload narrowing (``resultInclude=``)
251
+ append: Optional list of calculated fields to append
252
+ (e.g. ``["Tasks-Count"]``)
253
+ innertake: Optional bound on the size of nested collections
254
+ hydrated via ``include``
255
+
256
+ Returns:
257
+ Raw entity data dict
258
+
259
+ Raises:
260
+ ValueError: ``innertake`` is negative.
261
+
262
+ Example:
263
+ >>> data = await handler.get("UserStories", 123, include=["Name", "EntityState"])
264
+ """
265
+ require_entity_type(entity_type)
266
+ # Build URL
267
+ url = f"{self._transport.base_url}/{entity_type}/{id}"
268
+
269
+ # Build query parameters
270
+ params = {"format": "json"}
271
+ params.update(
272
+ self._shaping_params(
273
+ include=include,
274
+ exclude=exclude,
275
+ result_include=result_include,
276
+ append=append,
277
+ innertake=innertake,
278
+ )
279
+ )
280
+
281
+ # Add query string to URL
282
+ url = f"{url}?{urlencode(params)}"
283
+
284
+ # Make request
285
+ response = await self._request("GET", url)
286
+ return cast(dict[str, Any], response.json())
287
+
288
+ @staticmethod
289
+ def _list_params(
290
+ *,
291
+ where: str | None,
292
+ include: list[str] | None,
293
+ exclude: list[str] | None,
294
+ result_include: list[str] | None,
295
+ append: list[str] | None,
296
+ innertake: int | None,
297
+ order_by: str | None,
298
+ order_by_desc: str | None,
299
+ skip: int | None,
300
+ limit: int | None,
301
+ page_size: int,
302
+ ) -> dict[str, str]:
303
+ """Validate and build the first-request query params for ``list``.
304
+
305
+ Raises:
306
+ ValueError: Both ``order_by`` and ``order_by_desc`` were passed
307
+ (each direction was verified individually against the live
308
+ API; their combination is undefined, so it is refused rather
309
+ than sent), ``skip`` is negative, or ``innertake`` is
310
+ negative.
311
+ """
312
+ if order_by is not None and order_by_desc is not None:
313
+ raise ValueError("pass at most one of order_by / order_by_desc, not both")
314
+ if skip is not None and skip < 0:
315
+ raise ValueError(f"skip must be >= 0 (got {skip})")
316
+ params: dict[str, str] = {
317
+ "format": "json",
318
+ "take": str(min(page_size, limit) if limit else page_size),
319
+ }
320
+ if where:
321
+ params["where"] = where
322
+ if order_by:
323
+ params["orderBy"] = order_by
324
+ if order_by_desc:
325
+ params["orderByDesc"] = order_by_desc
326
+ if skip is not None:
327
+ params["skip"] = str(skip)
328
+ params.update(
329
+ RequestHandler._shaping_params(
330
+ include=include,
331
+ exclude=exclude,
332
+ result_include=result_include,
333
+ append=append,
334
+ innertake=innertake,
335
+ )
336
+ )
337
+ return params
338
+
339
+ async def list(
340
+ self,
341
+ entity_type: str,
342
+ *,
343
+ where: str | None = None,
344
+ include: list[str] | None = None,
345
+ exclude: list[str] | None = None,
346
+ result_include: list[str] | None = None,
347
+ append: list[str] | None = None,
348
+ innertake: int | None = None,
349
+ order_by: str | None = None,
350
+ order_by_desc: str | None = None,
351
+ skip: int | None = None,
352
+ limit: int | None = None,
353
+ page_size: int = 25,
354
+ ) -> AsyncIterator[dict[str, Any]]:
355
+ """List entities with lazy pagination.
356
+
357
+ Yields entities one at a time, fetching pages as needed. Pagination
358
+ follows the server-provided ``Next`` URL verbatim - never recomputing
359
+ ``skip`` locally - so short pages never silently drop items. Before
360
+ it is requested, each ``Next`` URL is checked against the
361
+ transport's base URL (see ``_check_next_url_host``) so a malicious
362
+ or misconfigured continuation URL can never redirect the request
363
+ (and its ``access_token``) to an unexpected host.
364
+
365
+ ``skip`` is a deliberate opt-in: it offsets where iteration *starts*
366
+ (sent on the first request only), after which pagination still
367
+ follows ``Next`` verbatim. The default remains the forward-only
368
+ ``Next`` walk from offset 0 - offsets are never recomputed locally.
369
+
370
+ Args:
371
+ entity_type: TP entity type (e.g., "UserStories", "Bugs")
372
+ where: TP `where=` filter expression, passed through verbatim
373
+ include: Optional list of fields to include
374
+ exclude: Optional list of fields to exclude from the response
375
+ (server-side; the complement of ``include``)
376
+ result_include: Optional list of fields to restrict each item
377
+ to - server-side payload narrowing (``resultInclude=``)
378
+ append: Optional list of calculated fields to append
379
+ (e.g. ``["Tasks-Count"]``)
380
+ innertake: Optional bound on the size of nested collections
381
+ hydrated via ``include``
382
+ order_by: Optional field to sort by, ascending, server-side
383
+ (``orderBy=``); mutually exclusive with ``order_by_desc``
384
+ order_by_desc: Optional field to sort by, descending, server-side
385
+ (``orderByDesc=``); mutually exclusive with ``order_by``
386
+ skip: Optional server-side offset before the first yielded item
387
+ (opt-in - see above)
388
+ limit: Maximum TOTAL items to yield across all pages (None = unbounded)
389
+ page_size: Page size for each underlying request (`take=`)
390
+
391
+ Yields:
392
+ Raw entity data dicts
393
+
394
+ Raises:
395
+ ValueError: Both ``order_by`` and ``order_by_desc`` were passed,
396
+ ``skip`` is negative, or ``innertake`` is negative. Raised
397
+ when iteration begins (this is an async generator), not at
398
+ the call itself.
399
+
400
+ Example:
401
+ >>> async for item in handler.list(
402
+ ... "UserStories", where="(EntityState.IsFinal eq 'false')"
403
+ ... ):
404
+ ... process(item)
405
+ """
406
+ require_entity_type(entity_type)
407
+ # Validate-and-build before the limit short-circuit, so invalid
408
+ # arguments raise rather than vanishing behind an empty iteration.
409
+ params = self._list_params(
410
+ where=where,
411
+ include=include,
412
+ exclude=exclude,
413
+ result_include=result_include,
414
+ append=append,
415
+ innertake=innertake,
416
+ order_by=order_by,
417
+ order_by_desc=order_by_desc,
418
+ skip=skip,
419
+ limit=limit,
420
+ page_size=page_size,
421
+ )
422
+ if limit is not None and limit <= 0:
423
+ return
424
+
425
+ yielded = 0
426
+ url: str | None = f"/{entity_type}"
427
+ first = True
428
+ while url is not None:
429
+ response = await self._request("GET", url, params=params if first else None)
430
+ first = False
431
+ data = response.json()
432
+ items = data.get("Items") or []
433
+ for item in items:
434
+ yield item
435
+ yielded += 1
436
+ if limit is not None and yielded >= limit:
437
+ return
438
+ next_url = data.get("Next")
439
+ if next_url is not None:
440
+ self._check_next_url_host(next_url)
441
+ url = next_url
442
+
443
+ def _check_next_url_host(self, next_url: str) -> None:
444
+ """Guard against a server-provided ``Next`` URL redirecting off-host.
445
+
446
+ ``Next`` is server-controlled, and every request sent through this
447
+ handler's transport carries the ``access_token`` (merged in at send
448
+ time - see ``HTTPTransport``/``_QueryTokenAuth`` - regardless of
449
+ destination host). Blindly following an absolute ``Next`` to an
450
+ unexpected host would leak the token there (SSRF), so the resolved
451
+ scheme/host/port must match the transport's base URL exactly. A
452
+ relative ``Next`` resolves against the base URL and so matches - and
453
+ passes - by construction.
454
+
455
+ Raises:
456
+ NetworkError: The resolved URL's scheme, host, or port differs
457
+ from the transport's base URL.
458
+ """
459
+ base = self._transport._client.base_url
460
+ resolved = base.join(next_url)
461
+ if (resolved.scheme, resolved.host, resolved.port) != (
462
+ base.scheme,
463
+ base.host,
464
+ base.port,
465
+ ):
466
+ raise NetworkError(
467
+ f"refusing to follow Next URL to unexpected host {resolved.host!r} "
468
+ f"(expected {base.host!r})"
469
+ )
470
+
471
+ async def create(self, entity_type: str, fields: dict[str, Any]) -> dict[str, Any]:
472
+ """Create new entity.
473
+
474
+ Args:
475
+ entity_type: TP entity type (e.g., "UserStories", "Bugs")
476
+ fields: Entity field values
477
+
478
+ Returns:
479
+ Created entity data dict
480
+
481
+ Example:
482
+ >>> data = await handler.create("UserStories", {
483
+ ... "Name": "New Story",
484
+ ... "Project": {"Id": 42}
485
+ ... })
486
+ """
487
+ if self._check_write is not None:
488
+ self._check_write()
489
+ require_entity_type(entity_type)
490
+ url = f"{self._transport.base_url}/{entity_type}?format=json"
491
+ response = await self._request("POST", url, json=fields)
492
+ return cast(dict[str, Any], response.json())
493
+
494
+ async def update(self, entity_type: str, id: int, fields: dict[str, Any]) -> dict[str, Any]:
495
+ """Update existing entity.
496
+
497
+ Args:
498
+ entity_type: TP entity type (e.g., "UserStories", "Bugs")
499
+ id: Entity ID
500
+ fields: Entity field values to update
501
+
502
+ Returns:
503
+ Updated entity data dict
504
+
505
+ Example:
506
+ >>> data = await handler.update("UserStories", 123, {
507
+ ... "Name": "Updated Name"
508
+ ... })
509
+ """
510
+ if self._check_write is not None:
511
+ self._check_write()
512
+ require_entity_type(entity_type)
513
+ url = f"{self._transport.base_url}/{entity_type}/{id}?format=json"
514
+ response = await self._request("POST", url, json=fields)
515
+ return cast(dict[str, Any], response.json())
516
+
517
+ # Return annotations on methods defined after ``list`` above must spell
518
+ # ``builtins.list``: in class scope the method name shadows the builtin.
519
+ async def bulk(
520
+ self, entity_type: str, items: Sequence[dict[str, Any]]
521
+ ) -> builtins.list[dict[str, Any]]:
522
+ """Create and/or update several entities in one request.
523
+
524
+ TargetProcess exposes a per-collection bulk endpoint -
525
+ ``POST /api/v1/{collection}/bulk`` taking a JSON array of entity
526
+ objects. An object carrying an ``Id`` updates that entity; an object
527
+ without one creates a new entity; one request may mix both (vendor
528
+ API reference - the UserStories, Projects, Features, Users and
529
+ Requesters pages all document the endpoint this way).
530
+
531
+ Like every mutating request, a bulk request is write-gated and never
532
+ auto-retried (see ``_request``): a 429 or 5xx surfaces immediately.
533
+ The vendor documents neither the response body's shape nor whether a
534
+ failed bulk request is atomic, so on an error a caller must not
535
+ assume all-or-nothing behaviour - some of the array may have been
536
+ applied.
537
+
538
+ An empty ``items`` returns ``[]`` without making a network request;
539
+ the write gate still runs first, so a readonly client is refused
540
+ even for an empty batch.
541
+
542
+ Args:
543
+ entity_type: TP entity type (e.g., "UserStories", "Bugs")
544
+ items: Entity field dicts, one per entity to create or update
545
+
546
+ Returns:
547
+ The response's entity data dicts. The response body shape is
548
+ not vendor-documented, so both plausible envelopes are
549
+ accepted: a bare JSON array, or an object wrapping the array
550
+ as ``Items``.
551
+
552
+ Raises:
553
+ ReadOnlyViolation: The client is in readonly mode.
554
+ ParseError: The response body was neither a JSON array nor an
555
+ ``Items``-wrapped one, or any element of it was not an
556
+ object.
557
+ Other exceptions as for any POST (see ``_request``).
558
+
559
+ Example:
560
+ >>> created = await handler.bulk(
561
+ ... "UserStories",
562
+ ... [
563
+ ... {"Name": "First", "Project": {"Id": 2}},
564
+ ... {"Name": "Second", "Project": {"Id": 2}},
565
+ ... ],
566
+ ... )
567
+ """
568
+ if self._check_write is not None:
569
+ self._check_write()
570
+ require_entity_type(entity_type)
571
+ if not items:
572
+ return []
573
+ url = f"{self._transport.base_url}/{entity_type}/bulk?format=json"
574
+ response = await self._request("POST", url, json=list(items))
575
+ data: Any = response.json()
576
+ items_out: Any = data.get("Items") if isinstance(data, dict) else data
577
+ if isinstance(items_out, list) and all(isinstance(item, dict) for item in items_out):
578
+ return cast(list[dict[str, Any]], items_out)
579
+ raise ParseError(
580
+ "bulk response was neither a JSON array of objects nor an "
581
+ f"'Items'-wrapped array of objects (got {type(data).__name__})"
582
+ )
583
+
584
+ async def download_file(self, path: str) -> bytes:
585
+ """GET an instance-root path and return the raw response body.
586
+
587
+ Attachment bytes are served from the application root (e.g.
588
+ ``/Attachment.aspx?AttachmentID=1234`` - the ``Uri`` an Attachment
589
+ record carries), not from under ``/api/v1``, so the URL is built from
590
+ the transport's domain rather than its API base URL. The transport's
591
+ auth applies as on any other request; note TargetProcess documents
592
+ file downloads against Basic auth ("You can't use REST API Token to
593
+ download attached file so far" - vendor guide), so a token-auth
594
+ client may be refused or redirected here.
595
+
596
+ Args:
597
+ path: Instance-root path - must start with exactly one ``/``
598
+ (e.g. ``/Attachment.aspx?AttachmentID=1234``); anything else
599
+ raises ``ValueError`` (see ``_require_instance_path``)
600
+
601
+ Returns:
602
+ Raw response body bytes
603
+
604
+ Raises:
605
+ ValueError: ``path`` is not an absolute instance-root path.
606
+ APIError: The server answered with a redirect (3xx). Redirects
607
+ are never followed - the auth credential must not travel to
608
+ a host this handler did not choose - and an attachment
609
+ download that redirects (typically to a login page, for an
610
+ auth scheme the endpoint does not accept) has not returned
611
+ the file, so returning its empty body would silently hand
612
+ back nothing.
613
+ Other exceptions as for any GET (see ``_request``).
614
+ """
615
+ self._require_instance_path(path)
616
+ url = f"https://{self._transport.domain}{path}"
617
+ response = await self._request("GET", url)
618
+ self._reject_redirect(response, "download")
619
+ return response.content
620
+
621
+ async def upload_file(
622
+ self,
623
+ path: str,
624
+ *,
625
+ data: dict[str, Any],
626
+ files: dict[str, Any],
627
+ ) -> str:
628
+ """POST multipart/form-data to an instance-root path.
629
+
630
+ TargetProcess uploads attachments outside the JSON entity API:
631
+ ``POST /UploadFile.ashx`` with ``Content-Type: multipart/form-data``,
632
+ a ``generalid`` form field naming the target entity and the file
633
+ content as a ``file`` part (vendor-documented wire shape). The
634
+ response body is returned verbatim - TargetProcess does not document
635
+ its shape, so this layer does not interpret it.
636
+
637
+ Like every mutating request, the upload is write-gated and never
638
+ auto-retried.
639
+
640
+ Args:
641
+ path: Instance-root path - must start with exactly one ``/``
642
+ (e.g. ``/UploadFile.ashx``); anything else raises
643
+ ``ValueError`` (see ``_require_instance_path``)
644
+ data: Plain form fields (e.g. ``{"generalid": "42"}``)
645
+ files: File parts, in httpx's ``files=`` shape
646
+ (e.g. ``{"file": (filename, content, mime_type)}``)
647
+
648
+ Returns:
649
+ The response body text, verbatim
650
+
651
+ Raises:
652
+ ValueError: ``path`` is not an absolute instance-root path.
653
+ ReadOnlyViolation: The client is in readonly mode.
654
+ APIError: The server answered with a redirect (3xx) - see
655
+ ``download_file`` for why redirects are rejected.
656
+ Other exceptions as for any POST (see ``_request``).
657
+ """
658
+ if self._check_write is not None:
659
+ self._check_write()
660
+ self._require_instance_path(path)
661
+ url = f"https://{self._transport.domain}{path}"
662
+ response = await self._request("POST", url, data=data, files=files)
663
+ self._reject_redirect(response, "upload")
664
+ return response.text
665
+
666
+ @staticmethod
667
+ def _require_instance_path(path: str) -> None:
668
+ """Refuse a path that could re-anchor the request's host.
669
+
670
+ Instance-root paths are interpolated directly after the domain in
671
+ ``https://<domain><path>``, so anything but a single leading slash
672
+ can reinterpret the URL's authority - ``@attacker.example/f`` turns
673
+ the real domain into userinfo and sends the request (credential
674
+ included) to ``attacker.example``, any other slash-less prefix
675
+ extends the hostname, and a ``//`` prefix is the protocol-relative
676
+ shape of the same trick. ``download_file`` can be fed a
677
+ server-supplied value (an Attachment record's ``Uri``), so this is
678
+ enforced, not just documented: the path must be absolute from the
679
+ instance root, meaning exactly one leading slash.
680
+
681
+ Raises:
682
+ ValueError: ``path`` does not start with exactly one ``/``.
683
+ """
684
+ if not path.startswith("/") or path.startswith("//"):
685
+ raise ValueError(
686
+ "instance-root path must start with exactly one leading '/' "
687
+ f"(got {path!r}) - anything else would re-anchor the request "
688
+ "host and leak the credential to it"
689
+ )
690
+
691
+ @staticmethod
692
+ def _reject_redirect(response: httpx.Response, operation: str) -> None:
693
+ """Refuse a 3xx response on a file operation.
694
+
695
+ Redirects are not followed anywhere in this handler (following one
696
+ would carry the auth credential to a URL the server chose), so on a
697
+ file operation a 3xx means the file was not transferred - surface
698
+ that rather than letting an empty body read as success.
699
+ """
700
+ if 300 <= response.status_code < 400:
701
+ raise APIError(
702
+ f"file {operation} answered with a redirect "
703
+ f"(HTTP {response.status_code}); redirects are not followed. "
704
+ "TargetProcess serves file endpoints against Basic auth - a "
705
+ "token-authenticated client is typically redirected to the "
706
+ "login page here.",
707
+ status_code=response.status_code,
708
+ )
709
+
710
+ async def delete(self, entity_type: str, id: int) -> None:
711
+ """Delete entity.
712
+
713
+ Args:
714
+ entity_type: TP entity type (e.g., "UserStories", "Bugs")
715
+ id: Entity ID
716
+
717
+ Example:
718
+ >>> await handler.delete("UserStories", 123)
719
+ """
720
+ if self._check_write is not None:
721
+ self._check_write()
722
+ require_entity_type(entity_type)
723
+ url = f"{self._transport.base_url}/{entity_type}/{id}?format=json"
724
+ await self._request("DELETE", url)