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,223 @@
1
+ """Lightweight nested models embedded in entity payloads.
2
+
3
+ The shapes TP nests inside entity payloads rather than serving as entities in
4
+ their own right: the reference projections (:class:`EntityRef`,
5
+ :class:`UserRef`, :class:`RefWithImportance`), the embedded
6
+ :class:`CustomFieldValue` and :class:`CustomFieldConfig`, and the
7
+ ``Items``-wrapped assignment collections. The entity models themselves live in
8
+ the sibling private modules and are re-exported, along with everything here, by
9
+ :mod:`targetprocess.models`.
10
+ """
11
+
12
+ from typing import Annotated
13
+
14
+ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
15
+
16
+
17
+ def _unwrap_items(value: object) -> object:
18
+ """Unwrap TP's ``{"Items": [...]}`` collection shape.
19
+
20
+ Assignment-style collections (e.g. ``AssignedUser`` fetched via
21
+ ``include=``) arrive wrapped in an ``Items`` envelope rather than as a
22
+ bare list. Non-matching values (already a list, None) pass through for
23
+ pydantic's own list handling.
24
+ """
25
+ if isinstance(value, dict) and "Items" in value:
26
+ return value["Items"]
27
+ return value
28
+
29
+
30
+ class CustomFieldValue(BaseModel):
31
+ """A single custom-field value embedded in an entity's ``CustomFields``.
32
+
33
+ TP has no queryable ``/CustomFieldValues`` collection; custom-field values
34
+ arrive as ``{Name, Type, Value}`` elements inside each entity payload's
35
+ ``CustomFields`` array. ``Value`` is polymorphic - a string, number,
36
+ boolean, or null depending on the field's ``Type`` - so it is typed
37
+ ``object`` to carry the wire value faithfully without lossy coercion.
38
+ Date-typed values therefore stay as their raw ``/Date(ms±HHMM)/`` wire
39
+ string; pass one through :func:`targetprocess.models.parse_tp_date` for a
40
+ ``datetime``.
41
+
42
+ Attributes:
43
+ name: Custom-field name
44
+ type: Custom-field type (e.g. "DropDown", "Number", "CheckBox")
45
+ value: The field value (polymorphic; may be None)
46
+ """
47
+
48
+ model_config = ConfigDict(
49
+ populate_by_name=True,
50
+ str_strip_whitespace=True,
51
+ validate_assignment=True,
52
+ )
53
+
54
+ name: str | None = Field(default=None, alias="Name", description="Custom-field name")
55
+ type: str | None = Field(default=None, alias="Type", description="Custom-field type")
56
+ value: object = Field(default=None, alias="Value", description="Field value (polymorphic)")
57
+
58
+
59
+ class CustomFieldConfig(BaseModel):
60
+ """Type-specific configuration attached to a custom-field definition.
61
+
62
+ ``CustomField.Config`` is a reference in TP's ``/meta`` but not an entity
63
+ reference: it arrives as a bare settings object carrying no ``Id``, so
64
+ :class:`EntityRef` cannot model it. Which keys are meaningful depends on
65
+ the field's ``FieldType`` - a calculated field populates
66
+ ``CalculationModel``, a numeric one ``Units`` and ``FormatSpecifier``.
67
+
68
+ Attributes:
69
+ default_value: Value TP applies when the field is left unset
70
+ calculation_model: Expression driving a calculated field
71
+ calculation_model_contains_collections: Whether that expression spans
72
+ collections (TP evaluates those differently)
73
+ units: Unit label rendered beside a numeric value
74
+ format_specifier: Numeric/date format string
75
+ format_info: Locale/format metadata accompanying the specifier
76
+ editor_type: Editor TP renders for the field
77
+ """
78
+
79
+ model_config = ConfigDict(
80
+ populate_by_name=True,
81
+ str_strip_whitespace=True,
82
+ validate_assignment=True,
83
+ )
84
+
85
+ default_value: str | None = Field(
86
+ default=None, alias="DefaultValue", description="Default value"
87
+ )
88
+ calculation_model: str | None = Field(
89
+ default=None, alias="CalculationModel", description="Calculation expression"
90
+ )
91
+ calculation_model_contains_collections: bool | None = Field(
92
+ default=None,
93
+ alias="CalculationModelContainsCollections",
94
+ description="Calculation spans collections",
95
+ )
96
+ units: str | None = Field(default=None, alias="Units", description="Unit label")
97
+ format_specifier: str | None = Field(
98
+ default=None, alias="FormatSpecifier", description="Format string"
99
+ )
100
+ format_info: str | None = Field(default=None, alias="FormatInfo", description="Format metadata")
101
+ editor_type: str | None = Field(default=None, alias="EditorType", description="Editor type")
102
+
103
+
104
+ class EntityRef(BaseModel):
105
+ """Reference to another TargetProcess entity.
106
+
107
+ Lightweight model for nested id+name-shaped entity references (Project,
108
+ Team, EntityState, etc.). Pydantic aliases handle the API PascalCase
109
+ format automatically. Extra keys the referenced entity may carry (e.g.
110
+ Team's ``EmojiIcon``, EntityState's ``NumericPriority``, Project's
111
+ nested ``Process``) are tolerated and ignored.
112
+
113
+ Attributes:
114
+ id: Unique identifier of the referenced entity
115
+ name: Display name of the referenced entity
116
+ """
117
+
118
+ model_config = ConfigDict(
119
+ populate_by_name=True,
120
+ str_strip_whitespace=True,
121
+ validate_assignment=True,
122
+ )
123
+
124
+ id: int = Field(alias="Id", description="Unique identifier")
125
+ name: str | None = Field(default=None, alias="Name", description="Display name")
126
+
127
+
128
+ class EntityTypeRef(BaseModel):
129
+ """Reference to a TP ``EntityType``, which can arrive without an ``Id``.
130
+
131
+ Unlike every other reference, TP does not reliably identify this one. A
132
+ ``UserStory`` carries the full ``{ResourceType, Id, Name,
133
+ IsUnitInHourOnly}``, while a ``Bug`` or ``Task`` carries only
134
+ ``{ResourceType, IsUnitInHourOnly}`` - no ``Id``, no ``Name``. So
135
+ :class:`EntityRef`, whose ``Id`` is required, cannot model it: a required
136
+ ``Id`` would fail the whole entity on a nested field TP chose not to
137
+ populate.
138
+
139
+ Both identifying fields are therefore optional, and a caller must treat
140
+ either as possibly ``None``. The entity's own ``resource_type`` names the
141
+ same type and is always present, so it is the reliable source for "what
142
+ kind of thing is this".
143
+
144
+ Attributes:
145
+ id: Unique identifier of the entity type (absent on some types)
146
+ name: Display name of the entity type (absent on some types)
147
+ is_unit_in_hour_only: Whether the type's effort is hours-only
148
+ """
149
+
150
+ model_config = ConfigDict(
151
+ populate_by_name=True,
152
+ str_strip_whitespace=True,
153
+ validate_assignment=True,
154
+ )
155
+
156
+ id: int | None = Field(default=None, alias="Id", description="Unique identifier")
157
+ name: str | None = Field(default=None, alias="Name", description="Display name")
158
+ is_unit_in_hour_only: bool | None = Field(
159
+ default=None, alias="IsUnitInHourOnly", description="Effort is hours-only"
160
+ )
161
+
162
+
163
+ class UserRef(BaseModel):
164
+ """Reference to a User-shaped entity.
165
+
166
+ Covers Owner/Creator/LastEditor/LastCommentedUser/AssignedUser-style
167
+ fields, which TP shapes as ``{ResourceType, Id, FirstName, LastName,
168
+ Login, FullName}`` - notably with no ``Name`` key.
169
+
170
+ Attributes:
171
+ id: Unique identifier of the referenced user
172
+ resource_type: Type of the reference (typically "User")
173
+ first_name: Referenced user's first name
174
+ last_name: Referenced user's last name
175
+ login: Referenced user's login
176
+ full_name: Referenced user's full name
177
+ """
178
+
179
+ model_config = ConfigDict(
180
+ populate_by_name=True,
181
+ str_strip_whitespace=True,
182
+ validate_assignment=True,
183
+ )
184
+
185
+ id: int = Field(alias="Id", description="Unique identifier")
186
+ resource_type: str | None = Field(default=None, alias="ResourceType", description="Entity type")
187
+ first_name: str | None = Field(default=None, alias="FirstName", description="First name")
188
+ last_name: str | None = Field(default=None, alias="LastName", description="Last name")
189
+ login: str | None = Field(default=None, alias="Login", description="Login")
190
+ full_name: str | None = Field(default=None, alias="FullName", description="Full name")
191
+
192
+
193
+ class RefWithImportance(BaseModel):
194
+ """Reference object carrying an Importance ranking.
195
+
196
+ Shape used by ``Bug.severity`` / ``Bug.priority`` (and equivalent
197
+ ``priority`` fields on other assignables): ``{ResourceType, Id, Name,
198
+ Importance}`` rather than a bare scalar.
199
+
200
+ Attributes:
201
+ id: Unique identifier of the referenced entity
202
+ resource_type: Type of the reference (e.g. "Severity", "Priority")
203
+ name: Display name (e.g. "Blocking", "Urgent")
204
+ importance: Importance ranking
205
+ """
206
+
207
+ model_config = ConfigDict(
208
+ populate_by_name=True,
209
+ str_strip_whitespace=True,
210
+ validate_assignment=True,
211
+ )
212
+
213
+ id: int = Field(alias="Id", description="Unique identifier")
214
+ resource_type: str | None = Field(default=None, alias="ResourceType", description="Entity type")
215
+ name: str | None = Field(default=None, alias="Name", description="Display name")
216
+ importance: int | None = Field(
217
+ default=None, alias="Importance", description="Importance ranking"
218
+ )
219
+
220
+
221
+ # Assignment collections (e.g. AssignedUser fetched via include=) arrive as
222
+ # {"Items": [...]} rather than a bare list or a single ref.
223
+ AssignedUsers = Annotated[list[UserRef], BeforeValidator(_unwrap_items)]
@@ -0,0 +1,368 @@
1
+ """Runtime observability: structured logging, log scrubbing, and request-ID propagation.
2
+
3
+ This module is the single place where the library's runtime observability
4
+ hooks live. Three concerns, each scoped to what a client library should
5
+ own (applications own metrics, alerting, error tracking, and deployment
6
+ observability - see SPEC.md "Observability"):
7
+
8
+ * **Structured logging** - a dedicated ``targetprocess`` logger, emitted as
9
+ JSON when a handler is attached, silent (``NullHandler``) by default so
10
+ the library never writes to stderr unless the application opts in.
11
+ * **Log scrubbing** - a :class:`ScrubbingFilter` that redacts the
12
+ ``access_token`` query param and ``Authorization`` headers from records
13
+ emitted through this library's own loggers (the ``targetprocess`` logger
14
+ and every child :func:`get_logger` returns), so the token-leak surface
15
+ documented in the README cannot recur through them. Records from other
16
+ libraries - notably ``httpx`` / ``httpcore`` - are outside its reach; see
17
+ SPEC.md "Observability" for what an application should do about those.
18
+ * **Correlation-ID propagation** - a ``ContextVar`` request ID, stamped on
19
+ every outgoing request as ``X-Request-ID`` (via the transport's request
20
+ hook) and attachable to every log record, so a caller's request context
21
+ flows end-to-end.
22
+ """
23
+
24
+ import json
25
+ import logging
26
+ import re
27
+ import uuid
28
+ from collections.abc import Iterator, Mapping
29
+ from contextlib import contextmanager
30
+ from contextvars import ContextVar, Token
31
+ from typing import Any
32
+
33
+ # ContextVar holding the correlation ID bound to the current async context.
34
+ _REQUEST_ID: ContextVar[str | None] = ContextVar("targetprocess_request_id", default=None)
35
+
36
+ # Header name used to propagate the correlation ID on every outgoing request.
37
+ REQUEST_ID_HEADER = "X-Request-ID"
38
+
39
+ _REDACTED = "[REDACTED]"
40
+
41
+ # access_token=... in a URL query string (TP's token scheme - the token
42
+ # travels as a query param, the leak surface the README warns about).
43
+ _ACCESS_TOKEN_RE = re.compile(r"(access_token=)[^&\s]+", re.IGNORECASE)
44
+ # "Authorization: Basic <tok>" / "Authorization: Bearer <tok>" header values
45
+ # that may appear in logged free text (Basic auth is the header alternative).
46
+ _AUTH_HEADER_RE = re.compile(
47
+ r"(authorization:\s*)(basic|bearer)\s+[^\s]+",
48
+ re.IGNORECASE,
49
+ )
50
+ # Header names whose values are credentials and must never be logged verbatim.
51
+ _SECRET_HEADERS = frozenset({"authorization", "proxy-authorization", "cookie", "set-cookie"})
52
+
53
+ # Attributes the stdlib sets on every LogRecord. Anything outside this set is
54
+ # a caller-supplied ``extra`` and is scrubbed before it can reach a handler.
55
+ _STD_RECORD_ATTRS = frozenset(
56
+ {
57
+ "args",
58
+ "asctime",
59
+ "created",
60
+ "exc_info",
61
+ "exc_text",
62
+ "filename",
63
+ "funcName",
64
+ "levelname",
65
+ "levelno",
66
+ "lineno",
67
+ "message",
68
+ "module",
69
+ "msecs",
70
+ "msg",
71
+ "name",
72
+ "pathname",
73
+ "process",
74
+ "processName",
75
+ "relativeCreated",
76
+ "stack_info",
77
+ "taskName",
78
+ "thread",
79
+ "threadName",
80
+ }
81
+ )
82
+
83
+ # Characters valid in an HTTP header value (RFC 9110 field-value: visible
84
+ # ASCII plus space/HTAB). A correlation ID sourced from an inbound request
85
+ # is untrusted input, so anything outside this range is stripped before the
86
+ # ID is bound - CR/LF in particular would either be rejected by the HTTP
87
+ # stack mid-request or, worse, split the header.
88
+ _UNSAFE_REQUEST_ID_RE = re.compile(r"[^\x20-\x7e]")
89
+ # Upper bound on a bound correlation ID, so an oversized inbound value
90
+ # cannot push the request over a server's header-size limit.
91
+ _MAX_REQUEST_ID_LEN = 200
92
+
93
+
94
+ def scrub_url(url: str) -> str:
95
+ """Redact the ``access_token`` query parameter from a URL string.
96
+
97
+ The TargetProcess token is carried as ``access_token=`` in the request
98
+ URL (TP has no header-borne token scheme), so any URL that reaches a
99
+ log must have that value replaced. Other query params are preserved so
100
+ the URL remains useful for debugging.
101
+
102
+ Args:
103
+ url: A URL string that may carry an ``access_token`` query param.
104
+
105
+ Returns:
106
+ The URL with any ``access_token`` value replaced by ``[REDACTED]``.
107
+ """
108
+ if not url:
109
+ return url
110
+ return _ACCESS_TOKEN_RE.sub(lambda m: m.group(1) + _REDACTED, url)
111
+
112
+
113
+ def scrub_message(text: str) -> str:
114
+ """Redact known secret surfaces from a free-text log message.
115
+
116
+ Redacts ``access_token=`` query params and ``Authorization: Basic|Bearer
117
+ <token>`` header values. The scrub is deliberately narrow to the two
118
+ real secret surfaces this library produces; it does not run a generic
119
+ token-shaped regex, which would risk redacting legitimate entity IDs,
120
+ hashes, or long identifiers that appear in normal log content.
121
+
122
+ Args:
123
+ text: The message string to scrub.
124
+
125
+ Returns:
126
+ The text with secret values replaced by ``[REDACTED]``.
127
+ """
128
+ if not text:
129
+ return text
130
+ text = _ACCESS_TOKEN_RE.sub(lambda m: m.group(1) + _REDACTED, text)
131
+ text = _AUTH_HEADER_RE.sub(lambda m: f"{m.group(1)}{m.group(2)} {_REDACTED}", text)
132
+ return text
133
+
134
+
135
+ def scrub_headers(headers: Mapping[str, str]) -> dict[str, str]:
136
+ """Return a copy of ``headers`` with credential-bearing values redacted.
137
+
138
+ Header names are matched case-insensitively against the known
139
+ credential headers (``Authorization``, ``Proxy-Authorization``,
140
+ ``Cookie``, ``Set-Cookie``). Non-credential headers are preserved so
141
+ the result stays useful for debugging.
142
+
143
+ Args:
144
+ headers: A mapping of header names to values.
145
+
146
+ Returns:
147
+ A new dict with credential header values replaced by ``[REDACTED]``.
148
+ """
149
+ return {
150
+ name: (_REDACTED if name.lower() in _SECRET_HEADERS else value)
151
+ for name, value in headers.items()
152
+ }
153
+
154
+
155
+ class ScrubbingFilter(logging.Filter):
156
+ """Redact secrets from a log record before it reaches a handler.
157
+
158
+ Pre-formats the record's message and scrubs it, then clears ``args`` so
159
+ a downstream formatter re-renders the already-scrubbed string. Also
160
+ scrubs the record's ``extra`` fields - ``url`` via :func:`scrub_url`,
161
+ ``headers`` via :func:`scrub_headers`, and every other string-valued
162
+ extra via :func:`scrub_message` - so a record logged as
163
+ ``logger.info("request", extra={"url": url})`` cannot leak the
164
+ ``access_token`` even when the message format does not include the URL
165
+ directly. Scrubbing every string extra matters because an extra such as
166
+ ``{"error": repr(exc)}`` can carry a request URL the library never
167
+ formatted into the message itself. An extra named for a credential
168
+ header (``{"Authorization": "Bearer ..."}``) is redacted whole: the
169
+ value alone carries no field name for the free-text patterns to match,
170
+ so the key is the only signal that it is a secret.
171
+
172
+ A filter attached to a *logger* only sees records logged through that
173
+ logger, not records propagated up from its children, so this filter is
174
+ attached to each logger :func:`get_logger` hands out as well as to the
175
+ ``targetprocess`` parent. An application that wants the same redaction
176
+ applied to records from other libraries (``httpx``, ``httpcore``)
177
+ should attach an instance to its own handler, where every propagated
178
+ record passes through.
179
+ """
180
+
181
+ def filter(self, record: logging.LogRecord) -> bool: # noqa: A003 - logging API name
182
+ """Scrub the record's message and its extras in place."""
183
+ record.msg = scrub_message(record.getMessage())
184
+ record.args = ()
185
+ for key, value in vars(record).items():
186
+ if key in _STD_RECORD_ATTRS or key.startswith("_"):
187
+ continue
188
+ if key == "url" and isinstance(value, str):
189
+ setattr(record, key, scrub_url(value))
190
+ elif isinstance(value, Mapping) and key == "headers":
191
+ setattr(record, key, scrub_headers(value))
192
+ elif key.lower() in _SECRET_HEADERS:
193
+ # A credential logged as its own extra - extra={"Authorization":
194
+ # "Bearer ..."} - carries the value without the field name, so
195
+ # the free-text patterns cannot recognise it. The key is the
196
+ # only signal there is, so redact wholesale.
197
+ setattr(record, key, _REDACTED)
198
+ elif isinstance(value, str):
199
+ setattr(record, key, scrub_message(value))
200
+ return True
201
+
202
+
203
+ def _attach_scrubbing(target: logging.Logger) -> logging.Logger:
204
+ """Attach a :class:`ScrubbingFilter` to ``target`` unless it has one.
205
+
206
+ Idempotent, so repeated :func:`get_logger` calls for the same name do
207
+ not stack duplicate filters onto the logger.
208
+ """
209
+ if not any(isinstance(f, ScrubbingFilter) for f in target.filters):
210
+ target.addFilter(ScrubbingFilter())
211
+ return target
212
+
213
+
214
+ # The library logger. Applications opt in by attaching a handler to this
215
+ # logger (or a child via :func:`get_logger`); the NullHandler keeps the
216
+ # library silent by default, per the stdlib logging best practice for
217
+ # libraries.
218
+ logger = logging.getLogger("targetprocess")
219
+ if not logger.handlers:
220
+ logger.addHandler(logging.NullHandler())
221
+ # Scrub records logged through this logger. Children get their own filter
222
+ # in get_logger(), because logger-level filters do not run for records that
223
+ # merely propagate up from a child.
224
+ _attach_scrubbing(logger)
225
+
226
+
227
+ class StructuredJsonFormatter(logging.Formatter):
228
+ """Emit each log record as a single JSON line with stable keys.
229
+
230
+ Keys: ``ts``, ``level``, ``logger``, ``msg``, and ``request_id`` when
231
+ one is bound to the record, plus any non-reserved ``extra`` fields the
232
+ caller attached. An attached exception (``logger.exception``) is
233
+ serialised under ``exc``.
234
+
235
+ Attach this formatter to a handler on the ``targetprocess`` logger to
236
+ get structured output; pair it with :class:`ScrubbingFilter` (already
237
+ attached to the library logger) so secrets are redacted before
238
+ serialisation.
239
+ """
240
+
241
+ _RESERVED = frozenset(
242
+ {
243
+ "name",
244
+ "msg",
245
+ "args",
246
+ "levelname",
247
+ "levelno",
248
+ "pathname",
249
+ "filename",
250
+ "module",
251
+ "exc_info",
252
+ "exc_text",
253
+ "stack_info",
254
+ "lineno",
255
+ "funcName",
256
+ "created",
257
+ "msecs",
258
+ "relativeCreated",
259
+ "thread",
260
+ "threadName",
261
+ "processName",
262
+ "process",
263
+ "taskName",
264
+ "message",
265
+ "asctime",
266
+ "request_id",
267
+ }
268
+ )
269
+
270
+ def format(self, record: logging.LogRecord) -> str:
271
+ """Return the record serialised as a JSON object on a single line."""
272
+ payload: dict[str, Any] = {
273
+ "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"),
274
+ "level": record.levelname,
275
+ "logger": record.name,
276
+ "msg": record.getMessage(),
277
+ }
278
+ request_id = getattr(record, "request_id", None)
279
+ if request_id is not None:
280
+ payload["request_id"] = request_id
281
+ for key, value in record.__dict__.items():
282
+ if key not in self._RESERVED and not key.startswith("_"):
283
+ payload[key] = value
284
+ if record.exc_info:
285
+ # ScrubbingFilter never sees the formatted traceback - it is
286
+ # rendered here, from exc_info - and an httpx status error
287
+ # carries the full request URL (access_token and all) in its
288
+ # message, so scrub it at the point of serialisation.
289
+ payload["exc"] = scrub_message(self.formatException(record.exc_info))
290
+ return json.dumps(payload, default=str)
291
+
292
+
293
+ def get_logger(name: str | None = None) -> logging.Logger:
294
+ """Return a logger under the ``targetprocess`` namespace.
295
+
296
+ Attach a handler to ``logging.getLogger("targetprocess")`` or the
297
+ returned child to enable output; the library is silent by default.
298
+
299
+ The returned logger carries its own :class:`ScrubbingFilter`. A child
300
+ obtained by calling ``logging.getLogger("targetprocess.x")`` directly
301
+ does not, because a filter on the parent logger is not applied to
302
+ records propagated from a child - so use this function rather than the
303
+ stdlib call to keep the redaction guarantee.
304
+
305
+ Args:
306
+ name: Optional child name (e.g. ``"transport"``); ``None`` returns
307
+ the ``targetprocess`` logger itself.
308
+
309
+ Returns:
310
+ The requested ``logging.Logger``, with scrubbing attached.
311
+ """
312
+ return _attach_scrubbing(
313
+ logging.getLogger("targetprocess" if name is None else f"targetprocess.{name}")
314
+ )
315
+
316
+
317
+ def new_request_id() -> str:
318
+ """Generate a new request ID (UUID4 hex, 32 chars, no dashes)."""
319
+ return uuid.uuid4().hex
320
+
321
+
322
+ def _sanitise_request_id(request_id: str) -> str:
323
+ """Return ``request_id`` reduced to characters safe in a header value.
324
+
325
+ The documented use for :func:`request_id_context` is propagating a
326
+ correlation ID that arrived on an inbound request, which makes it
327
+ untrusted input. Left as-is, a CR/LF would either be rejected by the
328
+ HTTP stack - turning every request in that context into a transport
329
+ error - or split the ``X-Request-ID`` header. Characters outside the
330
+ printable-ASCII field-value range are dropped, the result is trimmed
331
+ and length-capped, and an ID with nothing usable left falls back to a
332
+ generated one so the request stays traceable.
333
+ """
334
+ cleaned = _UNSAFE_REQUEST_ID_RE.sub("", request_id).strip()[:_MAX_REQUEST_ID_LEN]
335
+ return cleaned or new_request_id()
336
+
337
+
338
+ def current_request_id() -> str | None:
339
+ """Return the correlation ID bound to the current context, if any."""
340
+ return _REQUEST_ID.get()
341
+
342
+
343
+ @contextmanager
344
+ def request_id_context(request_id: str | None) -> Iterator[str | None]:
345
+ """Bind a correlation ID to the current context for the duration of the block.
346
+
347
+ The bound ID is read by the transport's request hook to stamp the
348
+ ``X-Request-ID`` header on outgoing requests, and by callers to attach
349
+ to log records. The previous value is restored on exit, so nested
350
+ scopes can override and then return to the outer ID.
351
+
352
+ A non-``None`` ID is sanitised to characters valid in a header value
353
+ before it is bound, since the ID commonly originates from an inbound
354
+ request. The sanitised value is what gets bound and yielded, so the
355
+ caller sees exactly what will appear on the wire and in the logs.
356
+
357
+ Args:
358
+ request_id: The ID to bind, or ``None`` to clear.
359
+
360
+ Yields:
361
+ The bound ``request_id``, sanitised when not ``None``.
362
+ """
363
+ bound = None if request_id is None else _sanitise_request_id(request_id)
364
+ token: Token[str | None] = _REQUEST_ID.set(bound)
365
+ try:
366
+ yield bound
367
+ finally:
368
+ _REQUEST_ID.reset(token)