ccflow-http 0.2.0__tar.gz → 0.3.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.3.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, redaction of secret query values (including the configured `api_key_query` parameter) from `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, redaction of secret query values (including the configured `api_key_query` parameter) from `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.3.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
18
21
 
19
22
  __all__ = (
20
23
  "HTTPAuth",
@@ -75,34 +78,47 @@ class HTTPRequest(BaseModel):
75
78
  headers: dict[str, str] = Field(default_factory=dict)
76
79
  json_data: Any | None = None
77
80
  content: bytes | str | None = None
81
+ # Query parameter names that carry credentials under names the key heuristic may not recognize, such as api_key_query auth.
82
+ _sensitive_params: frozenset[str] = PrivateAttr(default_factory=frozenset)
78
83
 
79
84
 
80
- def _is_sensitive_key(key: str) -> bool:
85
+ # Extra sensitive query names for the request in flight, so the httpx2 log filter can redact them.
86
+ _REQUEST_SENSITIVE_PARAMS: ContextVar[frozenset[str]] = ContextVar("ccflow_http_request_sensitive_params", default=frozenset())
87
+ _QUERY_PAIR = re.compile(r"([?&])([^=&\s'\"]+)=([^&\s'\"]*)")
88
+
89
+
90
+ def _is_sensitive_key(key: str, extra: Collection[str] = ()) -> bool:
81
91
  normalized_key = key.lower().replace("_", "").replace("-", "")
82
- return normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key
92
+ return key in extra or normalized_key in {"apikey", "authorization", "password"} or "token" in normalized_key or "secret" in normalized_key
83
93
 
84
94
 
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()}
95
+ def redact_mapping(values: dict[str, Any], extra: Collection[str] = ()) -> dict[str, Any]:
96
+ return {key: "***" if _is_sensitive_key(key, extra) else value for key, value in values.items()}
87
97
 
88
98
 
89
- def redact_url(url: str) -> str:
99
+ def redact_url(url: str, extra: Collection[str] = ()) -> str:
90
100
  parts = urlsplit(url)
91
101
  if not parts.query:
92
102
  return url
93
103
  pairs = []
94
104
  for pair in parts.query.split("&"):
95
105
  key = pair.split("=", 1)[0]
96
- pairs.append(f"{key}=***" if _is_sensitive_key(unquote_plus(key)) else pair)
106
+ pairs.append(f"{key}=***" if _is_sensitive_key(unquote_plus(key), extra) else pair)
97
107
  return urlunsplit(parts._replace(query="&".join(pairs)))
98
108
 
99
109
 
110
+ def _redact_query_text(text: str, extra: Collection[str] = ()) -> str:
111
+ """Redact secret query values that appear anywhere in free text, such as an exception message quoting a URL."""
112
+ return _QUERY_PAIR.sub(lambda match: f"{match[1]}{match[2]}=***" if _is_sensitive_key(unquote_plus(match[2]), extra) else match[0], text)
113
+
114
+
100
115
  class _RedactURLFilter(Filter):
101
116
  """Redact secret query values from URLs that httpx2 logs for each request."""
102
117
 
103
118
  def filter(self, record: LogRecord) -> bool:
104
119
  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)
120
+ extra = _REQUEST_SENSITIVE_PARAMS.get()
121
+ record.args = tuple(redact_url(str(arg), extra) if isinstance(arg, httpx2.URL) else arg for arg in record.args)
106
122
  return True
107
123
 
108
124
 
@@ -111,7 +127,7 @@ getLogger("httpx2").addFilter(_RedactURLFilter())
111
127
 
112
128
  def safe_request_dump(request: HTTPRequest) -> dict[str, Any]:
113
129
  request_data = request.model_dump(exclude={"type_"})
114
- request_data["params"] = redact_mapping(request_data.get("params", {}))
130
+ request_data["params"] = redact_mapping(request_data.get("params", {}), request._sensitive_params)
115
131
  request_data["headers"] = redact_mapping(request_data.get("headers", {}))
116
132
  return request_data
117
133
 
@@ -325,7 +341,7 @@ class HTTPModel(CallableModel):
325
341
  rendered_headers = self._render_mapping(headers, data)
326
342
  self._apply_auth(rendered_headers, rendered_query, data)
327
343
 
328
- return HTTPRequest(
344
+ request = HTTPRequest(
329
345
  method=self.method,
330
346
  url=self._render(path, data),
331
347
  params=rendered_query,
@@ -333,6 +349,9 @@ class HTTPModel(CallableModel):
333
349
  json_data=context.json_body if context.json_body is not None else self.json_body,
334
350
  content=context.content if context.content is not None else self.content,
335
351
  )
352
+ if self.auth.strategy == "api_key_query":
353
+ request._sensitive_params = frozenset({self._render_required_auth_value(self.auth.name, "name", data)})
354
+ return request
336
355
 
337
356
  def _response_value(self, response: httpx2.Response) -> Any:
338
357
  match self.response_format:
@@ -398,6 +417,16 @@ class HTTPModel(CallableModel):
398
417
  current = current.get(part)
399
418
  return current
400
419
 
420
+ def _send(self, client: httpx2.Client, request: HTTPRequest) -> httpx2.Response:
421
+ return client.request(
422
+ method=request.method,
423
+ url=request.url,
424
+ params=request.params,
425
+ headers=request.headers,
426
+ json=request.json_data,
427
+ content=request.content,
428
+ )
429
+
401
430
  def _request_once(
402
431
  self, client: httpx2.Client, request: HTTPRequest, previous_started_at: float | None
403
432
  ) -> tuple[httpx2.Response, int, list[dict[str, Any]], float]:
@@ -409,19 +438,14 @@ class HTTPModel(CallableModel):
409
438
  attempts += 1
410
439
  previous_started_at = self._throttle(previous_started_at)
411
440
  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
- )
441
+ response = self._send(client, request)
420
442
  if response.status_code in self.accepted_status_codes:
421
443
  return response, attempts, events, previous_started_at
422
444
  response.raise_for_status()
423
445
  return response, attempts, events, previous_started_at
424
446
  except httpx2.HTTPStatusError as exc:
447
+ if exc.response is not None:
448
+ exc.response.close()
425
449
  status_code = exc.response.status_code if exc.response is not None else None
426
450
  if retry_policy.should_retry_status(status_code, attempts):
427
451
  delay_seconds = retry_policy.retry_delay_seconds(attempts, total_wait_seconds=total_retry_wait_seconds)
@@ -465,7 +489,7 @@ class HTTPModel(CallableModel):
465
489
  delay_seconds=delay_seconds,
466
490
  exception_type=type(exc).__name__,
467
491
  category=retry_policy.exception_category(exc),
468
- message=str(exc),
492
+ message=_redact_query_text(str(exc), request._sensitive_params),
469
493
  )
470
494
  )
471
495
  self._sleep(delay_seconds)
@@ -477,12 +501,13 @@ class HTTPModel(CallableModel):
477
501
  outcome="failed",
478
502
  exception_type=type(exc).__name__,
479
503
  category=retry_policy.exception_category(exc),
480
- message=str(exc),
504
+ message=_redact_query_text(str(exc), request._sensitive_params),
481
505
  )
482
506
  )
483
- raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
507
+ # httpx2 exceptions keep the full request, including secret query values, so do not chain them.
508
+ raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from None
484
509
  except httpx2.HTTPError as exc:
485
- raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from exc
510
+ raise RuntimeError(f"HTTP {request.method} {self._safe_url(request)} failed with {type(exc).__name__}") from None
486
511
 
487
512
  def _merge_page_values(self, values: list[Any]) -> Any:
488
513
  if not values:
@@ -506,7 +531,7 @@ class HTTPModel(CallableModel):
506
531
  next_url_parts = urlsplit(next_url)
507
532
  next_url_params = dict(parse_qsl(next_url_parts.query, keep_blank_values=True))
508
533
  for key, value in request.params.items():
509
- if key not in next_url_params and _is_sensitive_key(key):
534
+ if key not in next_url_params and _is_sensitive_key(key, request._sensitive_params):
510
535
  next_url_params[key] = value
511
536
  next_url_without_query = urlunsplit((next_url_parts.scheme, next_url_parts.netloc, next_url_parts.path, "", next_url_parts.fragment))
512
537
  return request.model_copy(update={"url": next_url_without_query, "params": next_url_params})
@@ -557,39 +582,49 @@ class HTTPModel(CallableModel):
557
582
  @Flow.call
558
583
  def __call__(self, context: HTTPRequestContext) -> HTTPResult:
559
584
  request = self._initial_paginated_request(self.build_request(context))
585
+ sensitive_params = request._sensitive_params
560
586
 
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
- )
587
+ token = _REQUEST_SENSITIVE_PARAMS.set(sensitive_params)
588
+ try:
589
+ with httpx2.Client(**self._client_kwargs()) as client:
590
+ values = []
591
+ retry_events = []
592
+ total_attempts = 0
593
+ pages = 0
594
+ previous_started_at = None
595
+ while True:
596
+ response, attempts, events, previous_started_at = self._request_once(client, request, previous_started_at)
597
+ total_attempts += attempts
598
+ retry_events.extend(events)
599
+ pages += 1
600
+ accepted_error = response.status_code in self.accepted_status_codes
601
+ if accepted_error:
602
+ if pages > 1:
603
+ raise RuntimeError(
604
+ f"HTTP {request.method} {self._safe_url(request)} returned status {response.status_code} on page {pages}; "
605
+ "accepted statuses are only returned for the first page"
606
+ )
607
+ break
608
+ value = self._response_value(response)
609
+ values.append(value)
610
+
611
+ if not self.paginate or pages >= self.max_pages:
612
+ break
613
+ next_request = self._next_paginated_request(request, value)
614
+ if next_request is None:
615
+ break
616
+ request = next_request
617
+ finally:
618
+ _REQUEST_SENSITIVE_PARAMS.reset(token)
619
+
620
+ return HTTPResult(
621
+ value=None if accepted_error else (self._merge_page_values(values) if self.paginate else values[-1]),
622
+ status_code=response.status_code,
623
+ headers=dict(response.headers or {}),
624
+ url=redact_url(str(response.url), sensitive_params),
625
+ attempts=total_attempts,
626
+ pages=pages,
627
+ rate_limit=self._rate_limit_headers(dict(response.headers or {})),
628
+ retry_events=retry_events,
629
+ retry_summary=self._retry_summary(retry_events, attempts=total_attempts, succeeded=True),
630
+ )
@@ -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_SENSITIVE_PARAMS, HTTPModel, HTTPRequest, HTTPRequestContext, redact_url
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
+ sensitive_params = request._sensitive_params
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_SENSITIVE_PARAMS.set(sensitive_params)
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_SENSITIVE_PARAMS.reset(token)
96
+ return HTTPDownloadResult(
97
+ path=str(path),
98
+ size=size,
99
+ sha256=digest.hexdigest(),
100
+ status_code=response.status_code,
101
+ url=redact_url(str(response.url), sensitive_params),
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()
@@ -490,7 +490,7 @@ def test_http_model_returns_accepted_error_status_without_raising():
490
490
  HTTPModel(config=config, path="/v1/tickers/ZZZZ")(HTTPRequestContext())
491
491
 
492
492
 
493
- def test_http_model_stops_pagination_on_accepted_error_status():
493
+ def test_http_model_raises_on_accepted_error_status_after_first_page():
494
494
  def handler(request: httpx2.Request) -> httpx2.Response:
495
495
  if "cursor" in request.url.params:
496
496
  return httpx2.Response(404)
@@ -503,11 +503,80 @@ def test_http_model_stops_pagination_on_accepted_error_status():
503
503
  accepted_status_codes=[404],
504
504
  )
505
505
 
506
+ with pytest.raises(RuntimeError, match="status 404 on page 2"):
507
+ model(HTTPRequestContext())
508
+
509
+
510
+ def test_http_model_redacts_custom_query_auth_name_everywhere(caplog):
511
+ seen_urls = []
512
+
513
+ def handler(request: httpx2.Request) -> httpx2.Response:
514
+ seen_urls.append(str(request.url))
515
+ if len(seen_urls) == 1:
516
+ return httpx2.Response(200, json={"results": [{"ticker": "AAA"}], "next_url": "/v1/tickers?cursor=2"})
517
+ return httpx2.Response(200, json={"results": [{"ticker": "BBB"}]})
518
+
519
+ model = HTTPModel(
520
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
521
+ path="/v1/tickers",
522
+ paginate=True,
523
+ auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
524
+ )
525
+
526
+ with caplog.at_level("INFO", logger="httpx2"):
527
+ result = model(HTTPRequestContext())
528
+
529
+ assert seen_urls == ["https://api.example.test/v1/tickers?key=custom-secret", "https://api.example.test/v1/tickers?cursor=2&key=custom-secret"]
530
+ assert "custom-secret" not in caplog.text
531
+ assert caplog.text.count("key=***") == 2
532
+ assert result.url == "https://api.example.test/v1/tickers?cursor=2&key=***"
533
+ assert safe_request_dump(model.build_request(HTTPRequestContext()))["params"] == {"key": "***"}
534
+
535
+
536
+ def test_http_model_does_not_leak_query_secrets_through_transport_errors(monkeypatch):
537
+ monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
538
+ calls = []
539
+
540
+ def handler(request: httpx2.Request) -> httpx2.Response:
541
+ calls.append(request)
542
+ if len(calls) == 1:
543
+ raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
544
+ raise httpx2.ReadTimeout(f"read timed out for {request.url}", request=request)
545
+
546
+ model = HTTPModel(
547
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
548
+ path="/v1/tickers",
549
+ auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
550
+ retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
551
+ )
552
+
553
+ with pytest.raises(RuntimeError, match="failed with ReadTimeout") as error:
554
+ model(HTTPRequestContext())
555
+
556
+ assert error.value.__cause__ is None
557
+ assert error.value.__suppress_context__
558
+
559
+
560
+ def test_http_model_redacts_query_secrets_in_retry_event_messages(monkeypatch):
561
+ monkeypatch.setattr("ccflow_http.base.sleep", lambda seconds: None)
562
+ calls = []
563
+
564
+ def handler(request: httpx2.Request) -> httpx2.Response:
565
+ calls.append(request)
566
+ if len(calls) == 1:
567
+ raise httpx2.ConnectError(f"connection reset for {request.url}", request=request)
568
+ return httpx2.Response(200, json={"status": "OK"})
569
+
570
+ model = HTTPModel(
571
+ config=HTTPConfig(base_url="https://api.example.test", transport=httpx2.MockTransport(handler)),
572
+ path="/v1/tickers",
573
+ auth=HTTPAuth(strategy="api_key_query", name="key", value="custom-secret"),
574
+ retry_policy=HTTPRetryPolicy(max_attempts=2, wait_initial=0.0, wait_max=0.0, wait_jitter=0.0),
575
+ )
576
+
506
577
  result = model(HTTPRequestContext())
507
578
 
508
- assert result.status_code == 404
509
- assert result.value is None
510
- assert result.pages == 2
579
+ assert result.retry_events[0]["message"] == "connection reset for https://api.example.test/v1/tickers?key=***"
511
580
 
512
581
 
513
582
  def test_http_model_parses_csv_and_gzip_responses_with_mock_transport():
@@ -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.3.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.3.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