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.
- {ccflow_http-0.2.0 → ccflow_http-0.3.0}/PKG-INFO +4 -3
- {ccflow_http-0.2.0 → ccflow_http-0.3.0}/README.md +1 -1
- ccflow_http-0.3.0/ccflow_http/__init__.py +4 -0
- {ccflow_http-0.2.0 → ccflow_http-0.3.0}/ccflow_http/base.py +93 -58
- ccflow_http-0.3.0/ccflow_http/download.py +205 -0
- ccflow_http-0.3.0/ccflow_http/tests/test_download.py +110 -0
- {ccflow_http-0.2.0 → ccflow_http-0.3.0}/ccflow_http/tests/test_model.py +73 -4
- {ccflow_http-0.2.0 → ccflow_http-0.3.0}/pyproject.toml +5 -4
- ccflow_http-0.2.0/ccflow_http/__init__.py +0 -3
- {ccflow_http-0.2.0 → ccflow_http-0.3.0}/.gitignore +0 -0
- {ccflow_http-0.2.0 → ccflow_http-0.3.0}/LICENSE +0 -0
- {ccflow_http-0.2.0 → ccflow_http-0.3.0}/ccflow_http/tests/test_all.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ccflow-http
|
|
3
|
-
Version: 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.
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|