ccflow-http 0.2.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ccflow-http
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: ccflow models for HTTP
5
5
  Project-URL: Repository, https://github.com/1kbgz/ccflow-http
6
6
  Project-URL: Homepage, https://github.com/1kbgz/ccflow-http
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Programming Language :: Python :: Implementation :: CPython
18
18
  Classifier: Programming Language :: Python :: Implementation :: PyPy
19
19
  Requires-Python: >=3.11
20
- Requires-Dist: ccflow-etl>=0.1.0
20
+ Requires-Dist: ccflow-etl>=0.4.0
21
21
  Requires-Dist: ccflow>=0.8.5
22
22
  Requires-Dist: httpx2
23
23
  Requires-Dist: jinja2
@@ -28,6 +28,7 @@ Requires-Dist: check-dist; extra == 'develop'
28
28
  Requires-Dist: codespell; extra == 'develop'
29
29
  Requires-Dist: hatchling; extra == 'develop'
30
30
  Requires-Dist: mdformat; extra == 'develop'
31
+ Requires-Dist: mdformat-frontmatter; extra == 'develop'
31
32
  Requires-Dist: mdformat-tables>=1; extra == 'develop'
32
33
  Requires-Dist: pytest; extra == 'develop'
33
34
  Requires-Dist: pytest-cov; extra == 'develop'
@@ -55,7 +56,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
55
56
 
56
57
  ## Current Status
57
58
 
58
- - Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, accepted error statuses returned as results instead of exceptions, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, redaction of secret query values from `httpx2` request logs, result URLs, and status errors, and mocked `httpx2` transport tests.
59
+ - Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, accepted error statuses returned as results instead of exceptions, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, a configurable `RedactionPolicy` (`HTTPModel.redaction`: exact names, substrings, and patterns, with `auth` header and query names added automatically) that masks secret header and query values in `httpx2` request logs, request dumps, result URLs, retry events, and errors, `HTTPDownloadModel` for streaming a response to a local file with size and SHA-256, `HTTPArtifactCaptureModel` for storing a dated download plus a manifest through a `ccflow-etl` file writer, and mocked `httpx2` transport tests.
59
60
  - Partial: the shared execution policy is currently consumed inside `HTTPModel` for sequential request spacing; broader evaluator-level concurrency coordination still belongs in evaluator integrations.
60
61
  - Missing: broader integration examples and provider-specific subclasses/configs in downstream packages.
61
62
 
@@ -15,7 +15,7 @@ It should not contain provider-specific endpoint catalogs. Domain packages can c
15
15
 
16
16
  ## Current Status
17
17
 
18
- - Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, accepted error statuses returned as results instead of exceptions, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, redaction of secret query values from `httpx2` request logs, result URLs, and status errors, and mocked `httpx2` transport tests.
18
+ - Implemented: `HTTPConfig`, `HTTPAuth`, `HTTPContext`, `HTTPRequestContext`, `HTTPRequest`, `HTTPRetryPolicy`, `HTTPResponseResult`, compatibility `HTTPResult`, `HTTPModel`, templated path/query/header rendering, request explanation through `build_request`, no-auth/bearer/API-key/basic auth helpers, JSON/text/bytes/CSV/gzip response parsing, HTTP status retry classification over `ccflow` retry semantics, accepted error statuses returned as results instead of exceptions, retry event summaries on HTTP results, `ccflow-etl` `ExecutionPolicy` request spacing, `next_url`/cursor/page/offset pagination, rate-limit header capture, a configurable `RedactionPolicy` (`HTTPModel.redaction`: exact names, substrings, and patterns, with `auth` header and query names added automatically) that masks secret header and query values in `httpx2` request logs, request dumps, result URLs, retry events, and errors, `HTTPDownloadModel` for streaming a response to a local file with size and SHA-256, `HTTPArtifactCaptureModel` for storing a dated download plus a manifest through a `ccflow-etl` file writer, and mocked `httpx2` transport tests.
19
19
  - Partial: the shared execution policy is currently consumed inside `HTTPModel` for sequential request spacing; broader evaluator-level concurrency coordination still belongs in evaluator integrations.
20
20
  - Missing: broader integration examples and provider-specific subclasses/configs in downstream packages.
21
21
 
@@ -0,0 +1,4 @@
1
+ from .base import *
2
+ from .download import *
3
+
4
+ __version__ = "0.4.0"
@@ -1,4 +1,7 @@
1
+ import re
1
2
  from base64 import b64encode
3
+ from collections.abc import Collection
4
+ from contextvars import ContextVar
2
5
  from csv import DictReader
3
6
  from datetime import UTC, datetime
4
7
  from email.utils import parsedate_to_datetime
@@ -14,7 +17,7 @@ from ccflow import BaseModel, CallableModel, ContextBase, Flow, GenericResult, P
14
17
  from ccflow.utils.retry import RetryPolicy
15
18
  from ccflow_etl import ExecutionPolicy
16
19
  from jinja2 import Environment
17
- from pydantic import Field
20
+ from pydantic import Field, PrivateAttr, field_validator
18
21
 
19
22
  __all__ = (
20
23
  "HTTPAuth",
@@ -26,6 +29,7 @@ __all__ = (
26
29
  "HTTPResponseResult",
27
30
  "HTTPResult",
28
31
  "HTTPRetryPolicy",
32
+ "RedactionPolicy",
29
33
  "redact_mapping",
30
34
  "redact_url",
31
35
  "safe_request_dump",
@@ -75,26 +79,79 @@ class HTTPRequest(BaseModel):
75
79
  headers: dict[str, str] = Field(default_factory=dict)
76
80
  json_data: Any | None = None
77
81
  content: bytes | str | None = None
82
+ # Redaction for this request: the model's policy plus any names its auth places credentials under.
83
+ _redaction: "RedactionPolicy" = PrivateAttr(default_factory=lambda: _DEFAULT_REDACTION)
78
84
 
79
85
 
80
- def _is_sensitive_key(key: str) -> bool:
81
- normalized_key = key.lower().replace("_", "").replace("-", "")
82
- return normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key
86
+ _QUERY_PAIR = re.compile(r"([?&])([^=&\s'\"]+)=([^&\s'\"]*)")
83
87
 
84
88
 
85
- def redact_mapping(values: dict[str, Any]) -> dict[str, Any]:
86
- return {key: "***" if _is_sensitive_key(key) else value for key, value in values.items()}
89
+ def _normalize_name(name: str) -> str:
90
+ return name.lower().replace("_", "").replace("-", "")
87
91
 
88
92
 
89
- def redact_url(url: str) -> str:
90
- parts = urlsplit(url)
91
- if not parts.query:
92
- return url
93
- pairs = []
94
- for pair in parts.query.split("&"):
95
- key = pair.split("=", 1)[0]
96
- pairs.append(f"{key}=***" if _is_sensitive_key(unquote_plus(key)) else pair)
97
- return urlunsplit(parts._replace(query="&".join(pairs)))
93
+ class RedactionPolicy(BaseModel):
94
+ """Which header and query parameter names carry secrets, and how their values are masked.
95
+
96
+ Names are compared after lowercasing and removing hyphens and underscores, so ``X-Api-Key`` and ``x_api_key``
97
+ are the same name. A name is sensitive when it is one of ``names``, contains one of ``substrings``, or matches one
98
+ of ``patterns`` (regular expressions searched in the normalized name). Set it on ``HTTPModel.redaction`` to cover
99
+ a provider's own header or parameter names; names used by ``auth`` are added automatically.
100
+ """
101
+
102
+ names: set[str] = Field(default_factory=lambda: {"authorization", "proxyauthorization", "cookie", "setcookie", "password"})
103
+ substrings: set[str] = Field(default_factory=lambda: {"apikey", "token", "secret", "password"})
104
+ patterns: list[str] = Field(default_factory=list)
105
+ mask: str = "***"
106
+
107
+ @field_validator("names", "substrings", mode="after")
108
+ @classmethod
109
+ def _normalize(cls, values: set[str]) -> set[str]:
110
+ return {_normalize_name(value) for value in values}
111
+
112
+ def with_names(self, *names: str) -> "RedactionPolicy":
113
+ """Return a copy that also treats ``names`` as sensitive."""
114
+ if not names:
115
+ return self
116
+ return self.model_copy(update={"names": self.names | {_normalize_name(name) for name in names}})
117
+
118
+ def is_sensitive(self, name: str) -> bool:
119
+ normalized = _normalize_name(name)
120
+ return (
121
+ normalized in self.names
122
+ or any(substring in normalized for substring in self.substrings)
123
+ or any(re.search(pattern, normalized) for pattern in self.patterns)
124
+ )
125
+
126
+ def redact_mapping(self, values: dict[str, Any]) -> dict[str, Any]:
127
+ return {key: self.mask if self.is_sensitive(key) else value for key, value in values.items()}
128
+
129
+ def redact_url(self, url: str) -> str:
130
+ parts = urlsplit(url)
131
+ if not parts.query:
132
+ return url
133
+ pairs = []
134
+ for pair in parts.query.split("&"):
135
+ key = pair.split("=", 1)[0]
136
+ pairs.append(f"{key}={self.mask}" if self.is_sensitive(unquote_plus(key)) else pair)
137
+ return urlunsplit(parts._replace(query="&".join(pairs)))
138
+
139
+ def redact_text(self, text: str) -> str:
140
+ """Redact secret query values that appear anywhere in free text, such as an exception message quoting a URL."""
141
+ return _QUERY_PAIR.sub(lambda match: f"{match[1]}{match[2]}={self.mask}" if self.is_sensitive(unquote_plus(match[2])) else match[0], text)
142
+
143
+
144
+ _DEFAULT_REDACTION = RedactionPolicy()
145
+ # Redaction policy of the request in flight, so the httpx2 log filter can apply it.
146
+ _REQUEST_REDACTION: ContextVar[RedactionPolicy | None] = ContextVar("ccflow_http_request_redaction", default=None)
147
+
148
+
149
+ def redact_mapping(values: dict[str, Any], extra: Collection[str] = (), policy: RedactionPolicy | None = None) -> dict[str, Any]:
150
+ return (policy or _DEFAULT_REDACTION).with_names(*extra).redact_mapping(values)
151
+
152
+
153
+ def redact_url(url: str, extra: Collection[str] = (), policy: RedactionPolicy | None = None) -> str:
154
+ return (policy or _DEFAULT_REDACTION).with_names(*extra).redact_url(url)
98
155
 
99
156
 
100
157
  class _RedactURLFilter(Filter):
@@ -102,7 +159,8 @@ class _RedactURLFilter(Filter):
102
159
 
103
160
  def filter(self, record: LogRecord) -> bool:
104
161
  if isinstance(record.args, tuple):
105
- record.args = tuple(redact_url(str(arg)) if isinstance(arg, httpx2.URL) else arg for arg in record.args)
162
+ policy = _REQUEST_REDACTION.get() or _DEFAULT_REDACTION
163
+ record.args = tuple(policy.redact_url(str(arg)) if isinstance(arg, httpx2.URL) else arg for arg in record.args)
106
164
  return True
107
165
 
108
166
 
@@ -111,8 +169,8 @@ getLogger("httpx2").addFilter(_RedactURLFilter())
111
169
 
112
170
  def safe_request_dump(request: HTTPRequest) -> dict[str, Any]:
113
171
  request_data = request.model_dump(exclude={"type_"})
114
- request_data["params"] = redact_mapping(request_data.get("params", {}))
115
- request_data["headers"] = redact_mapping(request_data.get("headers", {}))
172
+ request_data["params"] = request._redaction.redact_mapping(request_data.get("params", {}))
173
+ request_data["headers"] = request._redaction.redact_mapping(request_data.get("headers", {}))
116
174
  return request_data
117
175
 
118
176
 
@@ -225,6 +283,9 @@ class HTTPModel(CallableModel):
225
283
  description="Error status codes returned as results with value None instead of raising, such as 404 for lookups that may miss.",
226
284
  )
227
285
  execution_policy: ExecutionPolicy | None = None
286
+ redaction: RedactionPolicy = Field(
287
+ default_factory=RedactionPolicy, description="Header and query parameter names whose values are masked in logs, results, and errors."
288
+ )
228
289
  paginate: bool = False
229
290
  max_pages: int = 100
230
291
  pagination_mode: HTTPPaginationMode = "next_url"
@@ -325,7 +386,7 @@ class HTTPModel(CallableModel):
325
386
  rendered_headers = self._render_mapping(headers, data)
326
387
  self._apply_auth(rendered_headers, rendered_query, data)
327
388
 
328
- return HTTPRequest(
389
+ request = HTTPRequest(
329
390
  method=self.method,
330
391
  url=self._render(path, data),
331
392
  params=rendered_query,
@@ -333,6 +394,11 @@ class HTTPModel(CallableModel):
333
394
  json_data=context.json_body if context.json_body is not None else self.json_body,
334
395
  content=context.content if context.content is not None else self.content,
335
396
  )
397
+ auth_names = []
398
+ if self.auth.strategy in ("api_key_query", "api_key_header"):
399
+ auth_names.append(self._render_required_auth_value(self.auth.name, "name", data))
400
+ request._redaction = self.redaction.with_names(*auth_names)
401
+ return request
336
402
 
337
403
  def _response_value(self, response: httpx2.Response) -> Any:
338
404
  match self.response_format:
@@ -398,6 +464,16 @@ class HTTPModel(CallableModel):
398
464
  current = current.get(part)
399
465
  return current
400
466
 
467
+ def _send(self, client: httpx2.Client, request: HTTPRequest) -> httpx2.Response:
468
+ return client.request(
469
+ method=request.method,
470
+ url=request.url,
471
+ params=request.params,
472
+ headers=request.headers,
473
+ json=request.json_data,
474
+ content=request.content,
475
+ )
476
+
401
477
  def _request_once(
402
478
  self, client: httpx2.Client, request: HTTPRequest, previous_started_at: float | None
403
479
  ) -> tuple[httpx2.Response, int, list[dict[str, Any]], float]:
@@ -409,19 +485,14 @@ class HTTPModel(CallableModel):
409
485
  attempts += 1
410
486
  previous_started_at = self._throttle(previous_started_at)
411
487
  try:
412
- response = client.request(
413
- method=request.method,
414
- url=request.url,
415
- params=request.params,
416
- headers=request.headers,
417
- json=request.json_data,
418
- content=request.content,
419
- )
488
+ response = self._send(client, request)
420
489
  if response.status_code in self.accepted_status_codes:
421
490
  return response, attempts, events, previous_started_at
422
491
  response.raise_for_status()
423
492
  return response, attempts, events, previous_started_at
424
493
  except httpx2.HTTPStatusError as exc:
494
+ if exc.response is not None:
495
+ exc.response.close()
425
496
  status_code = exc.response.status_code if exc.response is not None else None
426
497
  if retry_policy.should_retry_status(status_code, attempts):
427
498
  delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
@@ -465,7 +536,7 @@ class HTTPModel(CallableModel):
465
536
  delay_seconds=delay_seconds,
466
537
  exception_type=type(exc).__name__,
467
538
  category=retry_policy.exception_category(exc),
468
- message=str(exc),
539
+ message=request._redaction.redact_text(str(exc)),
469
540
  )
470
541
  )
471
542
  self._sleep(delay_seconds)
@@ -477,12 +548,13 @@ class HTTPModel(CallableModel):
477
548
  outcome="failed",
478
549
  exception_type=type(exc).__name__,
479
550
  category=retry_policy.exception_category(exc),
480
- message=str(exc),
551
+ message=request._redaction.redact_text(str(exc)),
481
552
  )
482
553
  )
483
- raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
554
+ # httpx2 exceptions keep the full request, including secret query values, so do not chain them.
555
+ raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from None
484
556
  except httpx2.HTTPError as exc:
485
- raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
557
+ raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from None
486
558
 
487
559
  def _merge_page_values(self, values: list[Any]) -> Any:
488
560
  if not values:
@@ -506,7 +578,7 @@ class HTTPModel(CallableModel):
506
578
  next_url_parts = urlsplit(next_url)
507
579
  next_url_params = dict(parse_qsl(next_url_parts.query, keep_blank_values=True))
508
580
  for key, value in request.params.items():
509
- if key not in next_url_params and _is_sensitive_key(key):
581
+ if key not in next_url_params and request._redaction.is_sensitive(key):
510
582
  next_url_params[key] = value
511
583
  next_url_without_query = urlunsplit((next_url_parts.scheme, next_url_parts.netloc, next_url_parts.path, "", next_url_parts.fragment))
512
584
  return request.model_copy(update={"url": next_url_without_query, "params": next_url_params})
@@ -557,39 +629,49 @@ class HTTPModel(CallableModel):
557
629
  @Flow.call
558
630
  def __call__(self, context: HTTPRequestContext) -> HTTPResult:
559
631
  request = self._initial_paginated_request(self.build_request(context))
632
+ redaction = request._redaction
560
633
 
561
- with httpx2.Client(**self._client_kwargs()) as client:
562
- values = []
563
- retry_events = []
564
- total_attempts = 0
565
- pages = 0
566
- previous_started_at = None
567
- while True:
568
- response, attempts, events, previous_started_at = self._request_once(client, request, previous_started_at)
569
- total_attempts += attempts
570
- retry_events.extend(events)
571
- pages += 1
572
- accepted_error = response.status_code in self.accepted_status_codes
573
- if accepted_error:
574
- break
575
- value = self._response_value(response)
576
- values.append(value)
577
-
578
- if not self.paginate or pages >= self.max_pages:
579
- break
580
- next_request = self._next_paginated_request(request, value)
581
- if next_request is None:
582
- break
583
- request = next_request
584
-
585
- return HTTPResult(
586
- value=None if accepted_error else (self._merge_page_values(values) if self.paginate else values[-1]),
587
- status_code=response.status_code,
588
- headers=dict(response.headers or {}),
589
- url=redact_url(str(response.url)),
590
- attempts=total_attempts,
591
- pages=pages,
592
- rate_limit=self._rate_limit_headers(dict(response.headers or {})),
593
- retry_events=retry_events,
594
- retry_summary=self._retry_summary(retry_events, attempts=total_attempts, succeeded=True),
595
- )
634
+ token = _REQUEST_REDACTION.set(redaction)
635
+ try:
636
+ with httpx2.Client(**self._client_kwargs()) as client:
637
+ values = []
638
+ retry_events = []
639
+ total_attempts = 0
640
+ pages = 0
641
+ previous_started_at = None
642
+ while True:
643
+ response, attempts, events, previous_started_at = self._request_once(client, request, previous_started_at)
644
+ total_attempts += attempts
645
+ retry_events.extend(events)
646
+ pages += 1
647
+ accepted_error = response.status_code in self.accepted_status_codes
648
+ if accepted_error:
649
+ if pages > 1:
650
+ raise RuntimeError(
651
+ f"HTTP {request.method} {self._safe_url(request)} returned status {response.status_code} on page {pages}; "
652
+ "accepted statuses are only returned for the first page"
653
+ )
654
+ break
655
+ value = self._response_value(response)
656
+ values.append(value)
657
+
658
+ if not self.paginate or pages >= self.max_pages:
659
+ break
660
+ next_request = self._next_paginated_request(request, value)
661
+ if next_request is None:
662
+ break
663
+ request = next_request
664
+ finally:
665
+ _REQUEST_REDACTION.reset(token)
666
+
667
+ return HTTPResult(
668
+ value=None if accepted_error else (self._merge_page_values(values) if self.paginate else values[-1]),
669
+ status_code=response.status_code,
670
+ headers=dict(response.headers or {}),
671
+ url=redaction.redact_url(str(response.url)),
672
+ attempts=total_attempts,
673
+ pages=pages,
674
+ rate_limit=self._rate_limit_headers(dict(response.headers or {})),
675
+ retry_events=retry_events,
676
+ retry_summary=self._retry_summary(retry_events, attempts=total_attempts, succeeded=True),
677
+ )
@@ -0,0 +1,205 @@
1
+ import json
2
+ from datetime import UTC, date, datetime
3
+ from hashlib import sha256
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+ import httpx2
9
+ from ccflow import CallableModel, DateContext, Flow, ResultBase
10
+ from ccflow_etl import ArtifactWriteFileContext, ArtifactWriteFileModel, ArtifactWriteFileResult
11
+ from pydantic import Field
12
+
13
+ from .base import _REQUEST_REDACTION, HTTPModel, HTTPRequest, HTTPRequestContext
14
+
15
+ __all__ = (
16
+ "HTTPArtifactCaptureModel",
17
+ "HTTPArtifactCaptureResult",
18
+ "HTTPDownloadContext",
19
+ "HTTPDownloadModel",
20
+ "HTTPDownloadResult",
21
+ )
22
+
23
+
24
+ class HTTPDownloadContext(HTTPRequestContext):
25
+ output_path: Path
26
+
27
+
28
+ class HTTPDownloadResult(ResultBase):
29
+ path: str
30
+ size: int
31
+ sha256: str
32
+ status_code: int
33
+ url: str
34
+ etag: str | None = None
35
+ last_modified: str | None = None
36
+ content_type: str | None = None
37
+ attempts: int = 1
38
+ retry_events: list[dict[str, Any]] = Field(default_factory=list)
39
+
40
+
41
+ class HTTPDownloadModel(HTTPModel):
42
+ """Stream a response body to a local file without holding it in memory.
43
+
44
+ The file is written to a temporary path beside the target and renamed into place only after the full body
45
+ has been received, so a failed download never leaves a partial file at the target path.
46
+ """
47
+
48
+ chunk_size: int = Field(default=1 << 20, ge=1)
49
+
50
+ @property
51
+ def context_type(self) -> type[HTTPDownloadContext]:
52
+ return HTTPDownloadContext
53
+
54
+ @property
55
+ def result_type(self) -> type[HTTPDownloadResult]:
56
+ return HTTPDownloadResult
57
+
58
+ def _send(self, client: httpx2.Client, request: HTTPRequest) -> httpx2.Response:
59
+ built = client.build_request(
60
+ method=request.method,
61
+ url=request.url,
62
+ params=request.params,
63
+ headers=request.headers,
64
+ json=request.json_data,
65
+ content=request.content,
66
+ )
67
+ return client.send(built, stream=True)
68
+
69
+ @Flow.call
70
+ def __call__(self, context: HTTPDownloadContext) -> HTTPDownloadResult:
71
+ request = self.build_request(context)
72
+ redaction = request._redaction
73
+ path = context.output_path
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ temp_path = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
76
+ digest = sha256()
77
+ size = 0
78
+ token = _REQUEST_REDACTION.set(redaction)
79
+ try:
80
+ with httpx2.Client(**self._client_kwargs()) as client:
81
+ response, attempts, events, _ = self._request_once(client, request, None)
82
+ try:
83
+ with temp_path.open("wb") as file:
84
+ for chunk in response.iter_bytes(self.chunk_size):
85
+ file.write(chunk)
86
+ digest.update(chunk)
87
+ size += len(chunk)
88
+ finally:
89
+ response.close()
90
+ temp_path.replace(path)
91
+ except BaseException:
92
+ temp_path.unlink(missing_ok=True)
93
+ raise
94
+ finally:
95
+ _REQUEST_REDACTION.reset(token)
96
+ return HTTPDownloadResult(
97
+ path=str(path),
98
+ size=size,
99
+ sha256=digest.hexdigest(),
100
+ status_code=response.status_code,
101
+ url=redaction.redact_url(str(response.url)),
102
+ etag=response.headers.get("etag"),
103
+ last_modified=response.headers.get("last-modified"),
104
+ content_type=response.headers.get("content-type"),
105
+ attempts=attempts,
106
+ retry_events=events,
107
+ )
108
+
109
+
110
+ class HTTPArtifactCaptureResult(ResultBase):
111
+ date: date
112
+ key: str
113
+ manifest_key: str
114
+ status: str
115
+ download: HTTPDownloadResult | None = None
116
+ write: ArtifactWriteFileResult | None = None
117
+ manifest_write: ArtifactWriteFileResult | None = None
118
+
119
+
120
+ class HTTPArtifactCaptureModel(CallableModel):
121
+ """Capture one HTTP resource for a date: download it, store it under a dated key, and store a manifest beside it.
122
+
123
+ The manifest records the redacted URL, capture time, status, response headers, size, and SHA-256 of the stored
124
+ payload. An existing payload is skipped before downloading unless ``overwrite`` is set.
125
+ """
126
+
127
+ download: HTTPDownloadModel
128
+ writer: ArtifactWriteFileModel
129
+ key_template: str = Field(description="Payload key with {date}, {year}, {month}, and {day} placeholders.")
130
+ workspace: Path = Path("cc-etl-workspace")
131
+ dataset: str | None = None
132
+ media_type: str | None = None
133
+ overwrite: bool = False
134
+ keep_local: bool = Field(default=False, description="Keep the downloaded file in the workspace after it is stored.")
135
+ explain: bool = False
136
+
137
+ @property
138
+ def context_type(self) -> type[DateContext]:
139
+ return DateContext
140
+
141
+ @property
142
+ def result_type(self) -> type[HTTPArtifactCaptureResult]:
143
+ return HTTPArtifactCaptureResult
144
+
145
+ def key(self, context: DateContext) -> str:
146
+ value = context.date
147
+ return self.key_template.format(date=value.isoformat(), year=f"{value.year:04d}", month=f"{value.month:02d}", day=f"{value.day:02d}")
148
+
149
+ def _write(self, key: str, path: Path, media_type: str | None, dry_run: bool, metadata: dict[str, Any]) -> ArtifactWriteFileResult:
150
+ return self.writer(
151
+ ArtifactWriteFileContext(
152
+ key=key,
153
+ path=path,
154
+ media_type=media_type,
155
+ dataset=self.dataset,
156
+ stage="extract",
157
+ overwrite=self.overwrite,
158
+ dry_run=dry_run,
159
+ metadata=metadata,
160
+ )
161
+ )
162
+
163
+ def _exists(self, key: str) -> bool:
164
+ exists = getattr(self.writer.store, "exists", None)
165
+ return bool(exists and exists(key))
166
+
167
+ @Flow.call
168
+ def __call__(self, context: DateContext) -> HTTPArtifactCaptureResult:
169
+ key = self.key(context)
170
+ manifest_key = f"{key}.manifest.json"
171
+ local_path = self.workspace / key
172
+ manifest_path = self.workspace / manifest_key
173
+ metadata = {"capture_date": context.date.isoformat()}
174
+ if self.explain:
175
+ return HTTPArtifactCaptureResult(
176
+ date=context.date,
177
+ key=key,
178
+ manifest_key=manifest_key,
179
+ status="planned",
180
+ write=self._write(key, local_path, self.media_type, True, metadata),
181
+ manifest_write=self._write(manifest_key, manifest_path, "application/json", True, metadata),
182
+ )
183
+ if not self.overwrite and self._exists(key):
184
+ return HTTPArtifactCaptureResult(date=context.date, key=key, manifest_key=manifest_key, status="exists")
185
+
186
+ download = self.download(HTTPDownloadContext(output_path=local_path))
187
+ manifest = {
188
+ "key": key,
189
+ "capture_date": context.date.isoformat(),
190
+ "captured_at": datetime.now(UTC).isoformat(),
191
+ **download.model_dump(mode="json", exclude={"type_", "path", "retry_events"}),
192
+ }
193
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
194
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
195
+ try:
196
+ write = self._write(key, local_path, self.media_type or download.content_type, False, {**metadata, "sha256": download.sha256})
197
+ manifest_write = self._write(manifest_key, manifest_path, "application/json", False, metadata)
198
+ finally:
199
+ if not self.keep_local:
200
+ local_path.unlink(missing_ok=True)
201
+ manifest_path.unlink(missing_ok=True)
202
+ status = next((value for value in (write.status, manifest_write.status) if value != "exists"), "exists")
203
+ return HTTPArtifactCaptureResult(
204
+ date=context.date, key=key, manifest_key=manifest_key, status=status, download=download, write=write, manifest_write=manifest_write
205
+ )
@@ -0,0 +1,110 @@
1
+ import json
2
+ from datetime import date
3
+ from hashlib import sha256
4
+
5
+ import httpx2
6
+ import pytest
7
+ from ccflow_etl import ArtifactWriteFileModel, LocalFileOutput
8
+
9
+ from ccflow_http import HTTPArtifactCaptureModel, HTTPAuth, HTTPConfig, HTTPDownloadContext, HTTPDownloadModel, HTTPRetryPolicy
10
+
11
+ PAYLOAD = b"x" * 5000
12
+
13
+
14
+ def _download_model(handler, **kwargs):
15
+ return HTTPDownloadModel(
16
+ config=HTTPConfig(base_url="https://files.example.test", transport=httpx2.MockTransport(handler)),
17
+ path="/bulk/data.zip",
18
+ chunk_size=1024,
19
+ **kwargs,
20
+ )
21
+
22
+
23
+ def test_http_download_streams_body_to_file_with_checksum_and_headers(tmp_path):
24
+ def handler(request):
25
+ return httpx2.Response(
26
+ 200, content=PAYLOAD, headers={"ETag": '"abc"', "Last-Modified": "Fri, 09 Oct 2026 04:35:00 GMT", "Content-Type": "application/zip"}
27
+ )
28
+
29
+ target = tmp_path / "out" / "data.zip"
30
+ result = _download_model(handler, auth=HTTPAuth(strategy="api_key_query", name="key", value="secret"))(HTTPDownloadContext(output_path=target))
31
+
32
+ assert target.read_bytes() == PAYLOAD
33
+ assert result.size == len(PAYLOAD)
34
+ assert result.sha256 == sha256(PAYLOAD).hexdigest()
35
+ assert (result.etag, result.last_modified, result.content_type) == ('"abc"', "Fri, 09 Oct 2026 04:35:00 GMT", "application/zip")
36
+ assert result.url == "https://files.example.test/bulk/data.zip?key=***"
37
+ assert not list(target.parent.glob(".*.tmp"))
38
+
39
+
40
+ def test_http_download_retries_status_and_leaves_no_partial_file_on_failure(tmp_path, monkeypatch):
41
+ monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
42
+ calls = []
43
+
44
+ def flaky(request):
45
+ calls.append(request)
46
+ return httpx2.Response(503) if len(calls) == 1 else httpx2.Response(200, content=PAYLOAD)
47
+
48
+ policy = HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0)
49
+ result = _download_model(flaky, retry_policy=policy)(HTTPDownloadContext(output_path=tmp_path / "data.zip"))
50
+ assert result.attempts == 2
51
+
52
+ target = tmp_path / "failed.zip"
53
+ with pytest.raises(RuntimeError, match="failed with status 404"):
54
+ _download_model(lambda request: httpx2.Response(404))(HTTPDownloadContext(output_path=target))
55
+ assert not target.exists()
56
+ assert not list(tmp_path.glob(".failed.zip.*.tmp"))
57
+
58
+
59
+ def _capture(tmp_path, handler, **kwargs):
60
+ store = LocalFileOutput(path=tmp_path / "store")
61
+ model = HTTPArtifactCaptureModel(
62
+ download=_download_model(handler),
63
+ writer=ArtifactWriteFileModel(store=store),
64
+ key_template="bulk/data/{year}/{month}/{date}.zip",
65
+ workspace=tmp_path / "workspace",
66
+ dataset="bulk-data",
67
+ **kwargs,
68
+ )
69
+ return model, store
70
+
71
+
72
+ def test_http_capture_stores_payload_and_manifest_and_cleans_workspace(tmp_path):
73
+ model, store = _capture(tmp_path, lambda request: httpx2.Response(200, content=PAYLOAD, headers={"ETag": '"abc"'}))
74
+
75
+ result = model(date(2026, 10, 11))
76
+ manifest = json.loads(store.file_path("bulk/data/2026/10/2026-10-11.zip.manifest.json").read_text())
77
+
78
+ assert result.status == "written"
79
+ assert store.file_path("bulk/data/2026/10/2026-10-11.zip").read_bytes() == PAYLOAD
80
+ assert manifest["sha256"] == sha256(PAYLOAD).hexdigest()
81
+ assert manifest["size"] == len(PAYLOAD)
82
+ assert manifest["etag"] == '"abc"'
83
+ assert manifest["capture_date"] == "2026-10-11"
84
+ assert manifest["url"] == "https://files.example.test/bulk/data.zip"
85
+ assert not [path for path in (tmp_path / "workspace").rglob("*") if path.is_file()]
86
+
87
+
88
+ def test_http_capture_skips_existing_payload_without_downloading(tmp_path):
89
+ calls = []
90
+ model, _ = _capture(tmp_path, lambda request: calls.append(request) or httpx2.Response(200, content=PAYLOAD))
91
+ model(date(2026, 10, 11))
92
+
93
+ result = model(date(2026, 10, 11))
94
+
95
+ assert result.status == "exists"
96
+ assert len(calls) == 1
97
+
98
+
99
+ def test_http_capture_explain_does_no_network_or_storage_io(tmp_path):
100
+ def handler(request):
101
+ raise AssertionError("explain made a request")
102
+
103
+ model, _ = _capture(tmp_path, handler, explain=True)
104
+
105
+ result = model(date(2026, 10, 11))
106
+
107
+ assert result.status == "planned"
108
+ assert result.write.status == "planned"
109
+ assert not (tmp_path / "store").exists()
110
+ assert not (tmp_path / "workspace").exists()
@@ -18,6 +18,7 @@ from ccflow_http import (
18
18
  HTTPRequestContext,
19
19
  HTTPResponseResult,
20
20
  HTTPRetryPolicy,
21
+ RedactionPolicy,
21
22
  redact_url,
22
23
  safe_request_dump,
23
24
  )
@@ -118,14 +119,14 @@ def test_safe_request_dump_redacts_secret_params_and_headers():
118
119
  method="GET",
119
120
  url="/v1/tickers/AAA",
120
121
  params={"date": "2024-01-03", "apiKey": "query-secret", "page_token": "cursor-secret"},
121
- headers={"Authorization": "Bearer header-secret", "X-Request-ID": "abc"},
122
+ headers={"Authorization": "Bearer header-secret", "X-OPENFIGI-APIKEY": "vendor-secret", "X-Request-ID": "abc"},
122
123
  )
123
124
 
124
125
  assert safe_request_dump(request) == {
125
126
  "method": "GET",
126
127
  "url": "/v1/tickers/AAA",
127
128
  "params": {"date": "2024-01-03", "apiKey": "***", "page_token": "***"},
128
- "headers": {"Authorization": "***", "X-Request-ID": "abc"},
129
+ "headers": {"Authorization": "***", "X-OPENFIGI-APIKEY": "***", "X-Request-ID": "abc"},
129
130
  "json_data": None,
130
131
  "content": None,
131
132
  }
@@ -490,7 +491,7 @@ def test_http_model_returns_accepted_error_status_without_raising():
490
491
  HTTPModel(config=config, path="/v1/tickers/ZZZZ")(HTTPRequestContext())
491
492
 
492
493
 
493
- def test_http_model_stops_pagination_on_accepted_error_status():
494
+ def test_http_model_raises_on_accepted_error_status_after_first_page():
494
495
  def handler(request: httpx2.Request) -> httpx2.Response:
495
496
  if "cursor" in request.url.params:
496
497
  return httpx2.Response(404)
@@ -503,11 +504,80 @@ def test_http_model_stops_pagination_on_accepted_error_status():
503
504
  accepted_status_codes=[404],
504
505
  )
505
506
 
507
+ with pytest.raises(RuntimeError, match="status 404 on page 2"):
508
+ model(HTTPRequestContext())
509
+
510
+
511
+ def test_http_model_redacts_custom_query_auth_name_everywhere(caplog):
512
+ seen_urls = []
513
+
514
+ def handler(request: httpx2.Request) -> httpx2.Response:
515
+ seen_urls.append(str(request.url))
516
+ if len(seen_urls) == 1:
517
+ return httpx2.Response(200, json={"results": [{"ticker": "AAA"}], "next_url": "/v1/tickers?cursor=2"})
518
+ return httpx2.Response(200, json={"results": [{"ticker": "BBB"}]})
519
+
520
+ model = HTTPModel(
521
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
522
+ path="/v1/tickers",
523
+ paginate=True,
524
+ auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
525
+ )
526
+
527
+ with caplog.at_level("INFO", logger="httpx2"):
528
+ result = model(HTTPRequestContext())
529
+
530
+ assert seen_urls == ["https://api.example.test/v1/tickers?key=custom-secret", "https://api.example.test/v1/tickers?cursor=2&key=custom-secret"]
531
+ assert "custom-secret" not in caplog.text
532
+ assert caplog.text.count("key=***") == 2
533
+ assert result.url == "https://api.example.test/v1/tickers?cursor=2&key=***"
534
+ assert safe_request_dump(model.build_request(HTTPRequestContext()))["params"] == {"key": "***"}
535
+
536
+
537
+ def test_http_model_does_not_leak_query_secrets_through_transport_errors(monkeypatch):
538
+ monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
539
+ calls = []
540
+
541
+ def handler(request: httpx2.Request) -> httpx2.Response:
542
+ calls.append(request)
543
+ if len(calls) == 1:
544
+ raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
545
+ raise httpx2.ReadTimeout(f"read timed out for {request.url}", request=request)
546
+
547
+ model = HTTPModel(
548
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
549
+ path="/v1/tickers",
550
+ auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
551
+ retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
552
+ )
553
+
554
+ with pytest.raises(RuntimeError, match="failed with ReadTimeout") as error:
555
+ model(HTTPRequestContext())
556
+
557
+ assert error.value.__cause__ is None
558
+ assert error.value.__suppress_context__
559
+
560
+
561
+ def test_http_model_redacts_query_secrets_in_retry_event_messages(monkeypatch):
562
+ monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
563
+ calls = []
564
+
565
+ def handler(request: httpx2.Request) -> httpx2.Response:
566
+ calls.append(request)
567
+ if len(calls) == 1:
568
+ raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
569
+ return httpx2.Response(200, json={"status": "OK"})
570
+
571
+ model = HTTPModel(
572
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
573
+ path="/v1/tickers",
574
+ auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
575
+ retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
576
+ )
577
+
506
578
  result = model(HTTPRequestContext())
507
579
 
508
- assert result.status_code == 404
509
- assert result.value is None
510
- assert result.pages == 2
580
+ assert result.retry_events[0]["message"] == "connection reset for https://api.example.test/v1/tickers?key=***"
511
581
 
512
582
 
513
583
  def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
@@ -728,3 +798,67 @@ def test_http_model_ignores_invalid_retry_after(monkeypatch):
728
798
  model(HTTPRequestContext())
729
799
 
730
800
  assert sleeps == [1.0]
801
+
802
+
803
+ def test_redaction_policy_matches_normalized_names_substrings_and_patterns():
804
+ policy = RedactionPolicy(names={"X-Session"}, substrings={"Signature"}, patterns=[r"^sig\d+$"], mask="<redacted>")
805
+
806
+ assert policy.is_sensitive("x_session") and policy.is_sensitive("X-SESSION")
807
+ assert policy.is_sensitive("X-Amz-Signature")
808
+ assert policy.is_sensitive("sig2") and not policy.is_sensitive("signal")
809
+ assert not policy.is_sensitive("apiKey")
810
+ assert policy.with_names("apiKey").is_sensitive("API_KEY")
811
+ assert policy.redact_mapping({"X-Session": "s", "date": "d"}) == {"X-Session": "<redacted>", "date": "d"}
812
+ assert policy.redact_url("https://h/p?sig2=a&date=d") == "https://h/p?sig2=<redacted>&date=d"
813
+ assert policy.redact_text("failed for https://h/p?x-session=a&date=d") == "failed for https://h/p?x-session=<redacted>&date=d"
814
+
815
+
816
+ def test_default_redaction_policy_covers_common_credential_names():
817
+ policy = RedactionPolicy()
818
+
819
+ for name in [
820
+ "Authorization",
821
+ "Proxy-Authorization",
822
+ "Cookie",
823
+ "Set-Cookie",
824
+ "password",
825
+ "apiKey",
826
+ "X-OPENFIGI-APIKEY",
827
+ "page_token",
828
+ "client_secret",
829
+ ]:
830
+ assert policy.is_sensitive(name), name
831
+ for name in ["date", "ticker", "X-Request-ID", "limit"]:
832
+ assert not policy.is_sensitive(name), name
833
+
834
+
835
+ def test_http_model_applies_configured_and_auth_redaction_names_everywhere(caplog, monkeypatch):
836
+ monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
837
+ seen = []
838
+
839
+ def handler(request: httpx2.Request) -> httpx2.Response:
840
+ seen.append(request)
841
+ if len(seen) == 1:
842
+ raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
843
+ return httpx2.Response(200, json={"ok": True})
844
+
845
+ model = HTTPModel(
846
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
847
+ path="/v1/items",
848
+ query={"sig": "query-secret", "date": "2025-01-02"},
849
+ headers={"X-Vendor-Session": "header-secret"},
850
+ auth=HTTPAuth(strategy="api_key_header", name="X-Partner", value="auth-secret"),
851
+ redaction=RedactionPolicy(names={"X-Vendor-Session"}, patterns=[r"^sig$"]),
852
+ retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
853
+ )
854
+
855
+ with caplog.at_level("INFO", logger="httpx2"):
856
+ result = model(HTTPRequestContext())
857
+ dump = safe_request_dump(model.build_request(HTTPRequestContext()))
858
+
859
+ assert seen[-1].headers["X-Partner"] == "auth-secret" and seen[-1].headers["X-Vendor-Session"] == "header-secret"
860
+ assert dump["headers"]["X-Partner"] == "***" and dump["headers"]["X-Vendor-Session"] == "***"
861
+ assert dump["params"] == {"sig": "***", "date": "2025-01-02"}
862
+ assert result.url == "https://api.example.test/v1/items?sig=***&date=2025-01-02"
863
+ assert "query-secret" not in caplog.text
864
+ assert "query-secret" not in result.retry_events[0]["message"]
@@ -12,7 +12,7 @@ authors = [
12
12
  description = "ccflow models for HTTP"
13
13
  readme = "README.md"
14
14
  license = { text = "Apache-2.0" }
15
- version = "0.2.0"
15
+ version = "0.4.0"
16
16
  requires-python = ">=3.11"
17
17
  keywords = []
18
18
 
@@ -30,7 +30,7 @@ classifiers = [
30
30
 
31
31
  dependencies = [
32
32
  "ccflow>=0.8.5",
33
- "ccflow-etl>=0.1.0",
33
+ "ccflow-etl>=0.4.0",
34
34
  "httpx2",
35
35
  "jinja2",
36
36
  ]
@@ -43,6 +43,7 @@ develop = [
43
43
  "codespell",
44
44
  "hatchling",
45
45
  "mdformat",
46
+ "mdformat-frontmatter",
46
47
  "mdformat-tables>=1",
47
48
  "pytest",
48
49
  "pytest-cov",
@@ -60,7 +61,7 @@ Repository = "https://github.com/1kbgz/ccflow-http"
60
61
  Homepage = "https://github.com/1kbgz/ccflow-http"
61
62
 
62
63
  [tool.bumpversion]
63
- current_version = "0.2.0"
64
+ current_version = "0.4.0"
64
65
  commit = true
65
66
  tag = true
66
67
  commit_args = "-s"
@@ -78,7 +79,7 @@ replace = 'version = "{new_version}"'
78
79
  [tool.coverage.run]
79
80
  branch = true
80
81
  omit = [
81
- "ccflow_http/tests/integration/",
82
+ "ccflow_http/tests/integration/*",
82
83
  ]
83
84
 
84
85
  [tool.coverage.report]
@@ -1,3 +0,0 @@
1
- from .base import *
2
-
3
- __version__ = "0.2.0"
File without changes
File without changes