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
targetprocess/_nested.py
ADDED
|
@@ -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)
|