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.
- targetprocess/__init__.py +231 -0
- targetprocess/_assignables.py +162 -0
- targetprocess/_base.py +318 -0
- targetprocess/_content.py +287 -0
- targetprocess/_dates.py +70 -0
- targetprocess/_entity_types.py +33 -0
- targetprocess/_generals.py +324 -0
- targetprocess/_joins.py +217 -0
- targetprocess/_lookups.py +477 -0
- targetprocess/_nested.py +223 -0
- targetprocess/_observability.py +368 -0
- targetprocess/client.py +430 -0
- targetprocess/exceptions.py +147 -0
- targetprocess/models.py +121 -0
- targetprocess/py.typed +1 -0
- targetprocess/request_handler.py +724 -0
- targetprocess/resources/__init__.py +76 -0
- targetprocess/resources/assignments.py +29 -0
- targetprocess/resources/attachments.py +164 -0
- targetprocess/resources/base.py +563 -0
- targetprocess/resources/bugs.py +20 -0
- targetprocess/resources/comments.py +23 -0
- targetprocess/resources/custom_activities.py +56 -0
- targetprocess/resources/custom_fields.py +27 -0
- targetprocess/resources/custom_rules.py +28 -0
- targetprocess/resources/entities.py +433 -0
- targetprocess/resources/entity_states.py +20 -0
- targetprocess/resources/entity_types.py +61 -0
- targetprocess/resources/epics.py +20 -0
- targetprocess/resources/features.py +20 -0
- targetprocess/resources/iterations.py +20 -0
- targetprocess/resources/priorities.py +85 -0
- targetprocess/resources/processes.py +65 -0
- targetprocess/resources/projects.py +20 -0
- targetprocess/resources/relation_types.py +59 -0
- targetprocess/resources/relations.py +34 -0
- targetprocess/resources/releases.py +20 -0
- targetprocess/resources/requests.py +20 -0
- targetprocess/resources/role_efforts.py +21 -0
- targetprocess/resources/roles.py +52 -0
- targetprocess/resources/severities.py +53 -0
- targetprocess/resources/tasks.py +20 -0
- targetprocess/resources/team_assignments.py +21 -0
- targetprocess/resources/team_iterations.py +24 -0
- targetprocess/resources/teams.py +20 -0
- targetprocess/resources/terms.py +28 -0
- targetprocess/resources/test_cases.py +20 -0
- targetprocess/resources/times.py +392 -0
- targetprocess/resources/user_stories.py +20 -0
- targetprocess/resources/users.py +20 -0
- targetprocess/resources/workflows.py +27 -0
- targetprocess/response_parser.py +113 -0
- targetprocess/transport.py +116 -0
- targetprocess/types.py +56 -0
- targetprocess_py-0.1.0.dist-info/METADATA +476 -0
- targetprocess_py-0.1.0.dist-info/RECORD +58 -0
- targetprocess_py-0.1.0.dist-info/WHEEL +4 -0
- 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)
|