etlplus 0.3.17__tar.gz → 0.3.21__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.
- {etlplus-0.3.17/etlplus.egg-info → etlplus-0.3.21}/PKG-INFO +1 -1
- {etlplus-0.3.17 → etlplus-0.3.21/etlplus.egg-info}/PKG-INFO +1 -1
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus.egg-info/SOURCES.txt +5 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/integration/test_i_cli.py +2 -2
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_auth.py +1 -1
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_endpoint_client.py +22 -21
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_rate_limiter.py +1 -1
- etlplus-0.3.21/tests/unit/api/test_u_request_manager.py +349 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_transport.py +48 -1
- etlplus-0.3.21/tests/unit/api/test_u_types.py +135 -0
- etlplus-0.3.21/tests/unit/config/test_u_config_utils.py +129 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/config/test_u_connector.py +1 -1
- etlplus-0.3.21/tests/unit/config/test_u_jobs.py +131 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/config/test_u_pipeline.py +1 -1
- etlplus-0.3.21/tests/unit/test_u_cli.py +299 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_enums.py +1 -1
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_extract.py +1 -1
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_file.py +5 -6
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_load.py +4 -4
- etlplus-0.3.21/tests/unit/test_u_main.py +58 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_mixins.py +1 -1
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_run.py +33 -18
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_run_helpers.py +1 -1
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_transform.py +167 -40
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_utils.py +3 -3
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/test_u_validate.py +4 -3
- etlplus-0.3.21/tests/unit/test_u_version.py +53 -0
- etlplus-0.3.17/tests/unit/api/test_u_request_manager.py +0 -135
- etlplus-0.3.17/tests/unit/test_u_cli.py +0 -185
- {etlplus-0.3.17 → etlplus-0.3.21}/.coveragerc +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/.editorconfig +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/.gitattributes +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/.github/actions/python-bootstrap/action.yml +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/.github/workflows/ci.yml +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/.gitignore +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/.pre-commit-config.yaml +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/.ruff.toml +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/CODE_OF_CONDUCT.md +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/CONTRIBUTING.md +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/DEMO.md +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/LICENSE +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/Makefile +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/README.md +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/REFERENCES.md +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/docs/pipeline-guide.md +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/docs/snippets/installation_version.md +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/__main__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/__version__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/README.md +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/auth.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/endpoint_client.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/errors.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/pagination/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/pagination/client.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/pagination/config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/pagination/paginator.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/rate_limiting/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/rate_limiting/config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/request_manager.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/retry_manager.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/transport.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/api/types.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/cli.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/config/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/config/connector.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/config/jobs.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/config/pipeline.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/config/profile.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/config/types.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/config/utils.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/enums.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/extract.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/file.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/load.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/mixins.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/py.typed +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/run.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/run_helpers.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/transform.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/types.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/utils.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/validate.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/validation/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus/validation/utils.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus.egg-info/dependency_links.txt +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus.egg-info/entry_points.txt +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus.egg-info/requires.txt +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/etlplus.egg-info/top_level.txt +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/examples/README.md +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/examples/configs/pipeline.yml +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/examples/data/sample.csv +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/examples/data/sample.json +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/examples/data/sample.xml +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/examples/data/sample.xsd +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/examples/data/sample.yaml +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/examples/quickstart_python.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/pyproject.toml +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/pytest.ini +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/setup.cfg +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/setup.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/__init__.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/conftest.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/integration/conftest.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/integration/test_i_examples_data_parity.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/integration/test_i_pagination_strategy.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/integration/test_i_pipeline_smoke.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/integration/test_i_run.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/conftest.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_mocks.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_pagination_client.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_pagination_config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_paginator.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_rate_limit_config.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/api/test_u_retry_manager.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/conftest.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tests/unit/validation/test_u_validation_utils.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tools/run_pipeline.py +0 -0
- {etlplus-0.3.17 → etlplus-0.3.21}/tools/update_demo_snippets.py +0 -0
|
@@ -91,12 +91,14 @@ tests/unit/test_u_enums.py
|
|
|
91
91
|
tests/unit/test_u_extract.py
|
|
92
92
|
tests/unit/test_u_file.py
|
|
93
93
|
tests/unit/test_u_load.py
|
|
94
|
+
tests/unit/test_u_main.py
|
|
94
95
|
tests/unit/test_u_mixins.py
|
|
95
96
|
tests/unit/test_u_run.py
|
|
96
97
|
tests/unit/test_u_run_helpers.py
|
|
97
98
|
tests/unit/test_u_transform.py
|
|
98
99
|
tests/unit/test_u_utils.py
|
|
99
100
|
tests/unit/test_u_validate.py
|
|
101
|
+
tests/unit/test_u_version.py
|
|
100
102
|
tests/unit/api/conftest.py
|
|
101
103
|
tests/unit/api/test_u_auth.py
|
|
102
104
|
tests/unit/api/test_u_config.py
|
|
@@ -110,7 +112,10 @@ tests/unit/api/test_u_rate_limiter.py
|
|
|
110
112
|
tests/unit/api/test_u_request_manager.py
|
|
111
113
|
tests/unit/api/test_u_retry_manager.py
|
|
112
114
|
tests/unit/api/test_u_transport.py
|
|
115
|
+
tests/unit/api/test_u_types.py
|
|
116
|
+
tests/unit/config/test_u_config_utils.py
|
|
113
117
|
tests/unit/config/test_u_connector.py
|
|
118
|
+
tests/unit/config/test_u_jobs.py
|
|
114
119
|
tests/unit/config/test_u_pipeline.py
|
|
115
120
|
tests/unit/validation/test_u_validation_utils.py
|
|
116
121
|
tools/run_pipeline.py
|
|
@@ -21,8 +21,8 @@ from typing import TYPE_CHECKING
|
|
|
21
21
|
import pytest
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING: # pragma: no cover - typing helpers only
|
|
24
|
-
from tests.
|
|
25
|
-
from tests.
|
|
24
|
+
from tests.conftest import CliInvoke
|
|
25
|
+
from tests.conftest import JsonFactory
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
# SECTION: HELPERS ========================================================== #
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
:mod:`tests.unit.api.test_u_endpoint_client` module.
|
|
3
3
|
|
|
4
|
-
Unit tests for
|
|
4
|
+
Unit tests for :mod:`etlplus.api.endpoint_client`.
|
|
5
5
|
|
|
6
6
|
Notes
|
|
7
7
|
-----
|
|
@@ -41,6 +41,12 @@ pytestmark = pytest.mark.unit
|
|
|
41
41
|
|
|
42
42
|
EXAMPLE_BASE_URL = 'https://example.test'
|
|
43
43
|
|
|
44
|
+
type CursorConfigFactory = Callable[..., CursorPaginationConfigMap]
|
|
45
|
+
type StubRequestManager = Callable[
|
|
46
|
+
[Sequence[dict[str, Any]]],
|
|
47
|
+
list[dict[str, Any]],
|
|
48
|
+
]
|
|
49
|
+
|
|
44
50
|
# Optional Hypothesis import with safe stubs when missing.
|
|
45
51
|
try: # pragma: no try
|
|
46
52
|
from hypothesis import given # type: ignore[import-not-found]
|
|
@@ -110,6 +116,7 @@ def _page_responder(
|
|
|
110
116
|
Callable[..., list[dict[str, Any]]]
|
|
111
117
|
Handler compatible with ``patch_request_once``.
|
|
112
118
|
"""
|
|
119
|
+
# pylint: disable=unused-argument
|
|
113
120
|
|
|
114
121
|
def _handler(
|
|
115
122
|
self: EndpointClient,
|
|
@@ -155,6 +162,7 @@ def _stub_request_manager(
|
|
|
155
162
|
ValueError
|
|
156
163
|
If ``responses`` is empty.
|
|
157
164
|
"""
|
|
165
|
+
# pylint: disable=unused-argument
|
|
158
166
|
|
|
159
167
|
if not responses:
|
|
160
168
|
msg = 'responses must contain at least one payload'
|
|
@@ -375,12 +383,9 @@ class TestCursorPagination:
|
|
|
375
383
|
)
|
|
376
384
|
def test_page_size_normalizes(
|
|
377
385
|
self,
|
|
378
|
-
cursor_cfg:
|
|
386
|
+
cursor_cfg: CursorConfigFactory,
|
|
379
387
|
client_factory: Callable[..., EndpointClient],
|
|
380
|
-
stub_request_manager:
|
|
381
|
-
[Sequence[dict[str, Any]]],
|
|
382
|
-
list[dict[str, Any]],
|
|
383
|
-
],
|
|
388
|
+
stub_request_manager: StubRequestManager,
|
|
384
389
|
raw_page_size: Any,
|
|
385
390
|
expected_limit: int,
|
|
386
391
|
) -> None:
|
|
@@ -389,12 +394,11 @@ class TestCursorPagination:
|
|
|
389
394
|
|
|
390
395
|
Parameters
|
|
391
396
|
----------
|
|
392
|
-
cursor_cfg :
|
|
397
|
+
cursor_cfg : CursorConfigFactory
|
|
393
398
|
Factory for cursor pagination config.
|
|
394
399
|
client_factory : Callable[..., EndpointClient]
|
|
395
400
|
Factory fixture used to construct :class:`EndpointClient`.
|
|
396
|
-
stub_request_manager :
|
|
397
|
-
list[dict[str, Any]]]
|
|
401
|
+
stub_request_manager : StubRequestManager
|
|
398
402
|
Fixture that patches the underlying :class:`RequestManager`.
|
|
399
403
|
raw_page_size : Any
|
|
400
404
|
Raw page size input.
|
|
@@ -473,6 +477,7 @@ class TestRequestOptionIntegration:
|
|
|
473
477
|
client_factory: Callable[..., EndpointClient],
|
|
474
478
|
) -> None:
|
|
475
479
|
"""Paginated iterations override RequestOptions params per call."""
|
|
480
|
+
# pylint: disable=unused-argument
|
|
476
481
|
|
|
477
482
|
client = client_factory(base_url=EXAMPLE_BASE_URL, endpoints={})
|
|
478
483
|
observed: list[RequestOptions] = []
|
|
@@ -512,24 +517,20 @@ class TestRequestOptionIntegration:
|
|
|
512
517
|
|
|
513
518
|
def test_adds_limit_and_advances_cursor(
|
|
514
519
|
self,
|
|
515
|
-
cursor_cfg:
|
|
520
|
+
cursor_cfg: CursorConfigFactory,
|
|
516
521
|
client_factory: Callable[..., EndpointClient],
|
|
517
|
-
stub_request_manager:
|
|
518
|
-
[Sequence[dict[str, Any]]],
|
|
519
|
-
list[dict[str, Any]],
|
|
520
|
-
],
|
|
522
|
+
stub_request_manager: StubRequestManager,
|
|
521
523
|
) -> None:
|
|
522
524
|
"""
|
|
523
525
|
Test that limit is added and cursor advances correctly.
|
|
524
526
|
|
|
525
527
|
Parameters
|
|
526
528
|
----------
|
|
527
|
-
cursor_cfg :
|
|
529
|
+
cursor_cfg : CursorConfigFactory
|
|
528
530
|
Factory for cursor pagination config.
|
|
529
531
|
client_factory : Callable[..., EndpointClient]
|
|
530
532
|
Factory fixture used to construct :class:`EndpointClient`.
|
|
531
|
-
stub_request_manager :
|
|
532
|
-
list[dict[str, Any]]]
|
|
533
|
+
stub_request_manager : StubRequestManager
|
|
533
534
|
Fixture that patches :class:`RequestManager` responses.
|
|
534
535
|
"""
|
|
535
536
|
calls = stub_request_manager(
|
|
@@ -561,7 +562,7 @@ class TestRequestOptionIntegration:
|
|
|
561
562
|
self,
|
|
562
563
|
base_url: str,
|
|
563
564
|
patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
|
|
564
|
-
cursor_cfg:
|
|
565
|
+
cursor_cfg: CursorConfigFactory,
|
|
565
566
|
client_factory: Callable[..., EndpointClient],
|
|
566
567
|
) -> None:
|
|
567
568
|
"""
|
|
@@ -577,7 +578,7 @@ class TestRequestOptionIntegration:
|
|
|
577
578
|
Common base URL used across tests.
|
|
578
579
|
patch_request_once : Callable[[Callable[..., Any]], Callable[..., Any]]
|
|
579
580
|
Helper that patches the request helper for deterministic failures.
|
|
580
|
-
cursor_cfg :
|
|
581
|
+
cursor_cfg : CursorConfigFactory
|
|
581
582
|
Factory for cursor pagination config.
|
|
582
583
|
client_factory : Callable[..., EndpointClient]
|
|
583
584
|
Factory fixture used to construct :class:`EndpointClient`.
|
|
@@ -700,7 +701,7 @@ class TestRequestOptionIntegration:
|
|
|
700
701
|
|
|
701
702
|
def test_retry_backoff_sleeps(
|
|
702
703
|
self,
|
|
703
|
-
cursor_cfg:
|
|
704
|
+
cursor_cfg: CursorConfigFactory,
|
|
704
705
|
capture_sleeps: list[float],
|
|
705
706
|
jitter: Callable[[list[float]], list[float]],
|
|
706
707
|
patch_request_once: Callable[[Callable[..., Any]], Callable[..., Any]],
|
|
@@ -713,7 +714,7 @@ class TestRequestOptionIntegration:
|
|
|
713
714
|
|
|
714
715
|
Parameters
|
|
715
716
|
----------
|
|
716
|
-
cursor_cfg :
|
|
717
|
+
cursor_cfg : CursorConfigFactory
|
|
717
718
|
Factory for cursor pagination config.
|
|
718
719
|
capture_sleeps : list[float]
|
|
719
720
|
List to capture sleep durations.
|
|
@@ -48,7 +48,7 @@ def fixed_limiter_fixture() -> RateLimiter:
|
|
|
48
48
|
return RateLimiter.fixed(0.25)
|
|
49
49
|
|
|
50
50
|
|
|
51
|
-
# SECTION: TESTS
|
|
51
|
+
# SECTION: TESTS ============================================================ #
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
@pytest.mark.unit
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`tests.unit.api.test_u_request_manager` module.
|
|
3
|
+
|
|
4
|
+
Unit tests for :class:`etlplus.api.request_manager.RequestManager`.
|
|
5
|
+
|
|
6
|
+
These tests focus on:
|
|
7
|
+
|
|
8
|
+
- Session-adapter plumbing (building and closing sessions).
|
|
9
|
+
- Context-manager semantics (reuse + cleanup).
|
|
10
|
+
- Delegation to request callables.
|
|
11
|
+
|
|
12
|
+
The suite is intentionally lightweight and uses small doubles/mocks rather than
|
|
13
|
+
real network sessions.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any
|
|
21
|
+
from typing import cast
|
|
22
|
+
from unittest.mock import Mock
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
from etlplus.api.request_manager import RequestManager
|
|
27
|
+
|
|
28
|
+
# SECTION: HELPERS ========================================================== #
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
pytestmark = pytest.mark.unit
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _make_request_callable(
|
|
35
|
+
probe: RequestProbe,
|
|
36
|
+
) -> Callable[..., dict[str, Any]]:
|
|
37
|
+
"""Create a request callable that records inputs into ``probe``."""
|
|
38
|
+
|
|
39
|
+
def _request(
|
|
40
|
+
_method: str,
|
|
41
|
+
url: str,
|
|
42
|
+
*,
|
|
43
|
+
session: Any,
|
|
44
|
+
timeout: Any,
|
|
45
|
+
**kwargs: Any,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
probe.sessions_used.append(session)
|
|
48
|
+
probe.timeouts.append(timeout)
|
|
49
|
+
probe.urls.append(url)
|
|
50
|
+
probe.extra_kwargs.append(kwargs)
|
|
51
|
+
return {'url': url}
|
|
52
|
+
|
|
53
|
+
return _request
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class DummySession:
|
|
58
|
+
"""Lightweight session double-tracking ``close`` calls."""
|
|
59
|
+
|
|
60
|
+
closed: bool = False
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
"""Close the session."""
|
|
64
|
+
self.closed = True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(slots=True)
|
|
68
|
+
class SessionBuilderProbe:
|
|
69
|
+
"""Callable probe for session-builder usage."""
|
|
70
|
+
|
|
71
|
+
session: DummySession
|
|
72
|
+
calls: list[Any]
|
|
73
|
+
|
|
74
|
+
def __call__(self, cfg: Any) -> DummySession:
|
|
75
|
+
self.calls.append(cfg)
|
|
76
|
+
return self.session
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True)
|
|
80
|
+
class RequestProbe:
|
|
81
|
+
"""Callable probe for capturing arguments passed to a request callable."""
|
|
82
|
+
|
|
83
|
+
sessions_used: list[Any]
|
|
84
|
+
timeouts: list[Any]
|
|
85
|
+
urls: list[str]
|
|
86
|
+
extra_kwargs: list[dict[str, Any]]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# SECTION: FIXTURES ========================================================= #
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.fixture(name='dummy_session')
|
|
93
|
+
def dummy_session_fixture() -> DummySession:
|
|
94
|
+
"""Return a fresh dummy session for each test."""
|
|
95
|
+
|
|
96
|
+
return DummySession()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.fixture(name='session_builder')
|
|
100
|
+
def session_builder_fixture(
|
|
101
|
+
dummy_session: DummySession,
|
|
102
|
+
) -> SessionBuilderProbe:
|
|
103
|
+
"""Provide a probe callable for adapter-session creation."""
|
|
104
|
+
|
|
105
|
+
return SessionBuilderProbe(session=dummy_session, calls=[])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# SECTION: TESTS ============================================================ #
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TestRequestManager:
|
|
112
|
+
"""Unit tests for :class:`etlplus.api.request_manager.RequestManager`."""
|
|
113
|
+
|
|
114
|
+
def test_adapter_session_built_and_closed(
|
|
115
|
+
self,
|
|
116
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
117
|
+
session_builder: SessionBuilderProbe,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Test that adapter configs yield a managed session that gets closed.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
monkeypatch.setattr(
|
|
124
|
+
'etlplus.api.request_manager.build_session_with_adapters',
|
|
125
|
+
session_builder,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
manager = RequestManager(
|
|
129
|
+
session_adapters=[{'prefix': 'https://', 'pool_connections': 2}],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
probe = RequestProbe([], [], [], [])
|
|
133
|
+
request_callable = _make_request_callable(probe)
|
|
134
|
+
|
|
135
|
+
result = manager.request(
|
|
136
|
+
'GET',
|
|
137
|
+
'https://example.com/resource',
|
|
138
|
+
request_callable=request_callable,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
assert result == {'url': 'https://example.com/resource'}
|
|
142
|
+
assert probe.sessions_used == [session_builder.session]
|
|
143
|
+
assert isinstance(session_builder.calls[0], tuple)
|
|
144
|
+
assert session_builder.session.closed is True
|
|
145
|
+
|
|
146
|
+
def test_context_manager_reuses_adapter_session(
|
|
147
|
+
self,
|
|
148
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
149
|
+
session_builder: SessionBuilderProbe,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Test that context manager reuses one adapter-backed session."""
|
|
152
|
+
|
|
153
|
+
monkeypatch.setattr(
|
|
154
|
+
'etlplus.api.request_manager.build_session_with_adapters',
|
|
155
|
+
session_builder,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
manager = RequestManager(
|
|
159
|
+
session_adapters=[{'prefix': 'https://', 'pool_connections': 1}],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
probe = RequestProbe([], [], [], [])
|
|
163
|
+
request_callable = _make_request_callable(probe)
|
|
164
|
+
|
|
165
|
+
with manager:
|
|
166
|
+
manager.request(
|
|
167
|
+
'GET',
|
|
168
|
+
'https://example.com/a',
|
|
169
|
+
request_callable=request_callable,
|
|
170
|
+
)
|
|
171
|
+
manager.request(
|
|
172
|
+
'GET',
|
|
173
|
+
'https://example.com/b',
|
|
174
|
+
request_callable=request_callable,
|
|
175
|
+
)
|
|
176
|
+
assert session_builder.session.closed is False
|
|
177
|
+
|
|
178
|
+
assert session_builder.session.closed is True
|
|
179
|
+
assert len(session_builder.calls) == 1
|
|
180
|
+
assert probe.sessions_used == [session_builder.session] * 2
|
|
181
|
+
assert probe.urls == ['https://example.com/a', 'https://example.com/b']
|
|
182
|
+
assert probe.timeouts == [
|
|
183
|
+
manager.default_timeout,
|
|
184
|
+
manager.default_timeout,
|
|
185
|
+
]
|
|
186
|
+
assert probe.extra_kwargs == [{}, {}]
|
|
187
|
+
|
|
188
|
+
def test_invalid_session_adapters(
|
|
189
|
+
self,
|
|
190
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Test that bad adapter config does not raise during context enter/exit.
|
|
194
|
+
"""
|
|
195
|
+
bad_adapters = cast(
|
|
196
|
+
Any,
|
|
197
|
+
[{'prefix': 'https://', 'pool_connections': 'bad'}],
|
|
198
|
+
)
|
|
199
|
+
manager = RequestManager(session_adapters=bad_adapters)
|
|
200
|
+
|
|
201
|
+
def _bad_builder(cfg: Any) -> None: # pragma: no cover
|
|
202
|
+
raise ValueError('bad config')
|
|
203
|
+
|
|
204
|
+
monkeypatch.setattr(
|
|
205
|
+
'etlplus.api.request_manager.build_session_with_adapters',
|
|
206
|
+
_bad_builder,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# If this raises, pytest will fail the test automatically.
|
|
210
|
+
with manager:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
def test_request_delegates_to_request_once(
|
|
214
|
+
self,
|
|
215
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Test that ``request`` passes through the ``request_callable`` to
|
|
219
|
+
:meth:`request_once`.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
manager = RequestManager()
|
|
223
|
+
request_once = Mock(return_value={'ok': True})
|
|
224
|
+
cb = Mock(return_value={'ok': 'cb'})
|
|
225
|
+
|
|
226
|
+
monkeypatch.setattr(
|
|
227
|
+
type(manager),
|
|
228
|
+
'request_once',
|
|
229
|
+
staticmethod(request_once),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
result = manager.request('POST', 'http://test', request_callable=cb)
|
|
233
|
+
|
|
234
|
+
assert result == {'ok': True}
|
|
235
|
+
assert request_once.call_count == 1
|
|
236
|
+
args = request_once.call_args.args
|
|
237
|
+
kwargs = request_once.call_args.kwargs
|
|
238
|
+
assert args[:2] == ('POST', 'http://test')
|
|
239
|
+
assert kwargs['session'] is None
|
|
240
|
+
assert kwargs['timeout'] == manager.default_timeout
|
|
241
|
+
assert kwargs['request_callable'] is cb
|
|
242
|
+
|
|
243
|
+
def test_default_init_values(self) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Test that :class:`RequestManager` default initialization values are
|
|
246
|
+
stable and explicit.
|
|
247
|
+
"""
|
|
248
|
+
manager = RequestManager()
|
|
249
|
+
assert manager.retry is None
|
|
250
|
+
assert manager.retry_network_errors is False
|
|
251
|
+
assert manager.default_timeout == 10.0
|
|
252
|
+
assert manager.session is None
|
|
253
|
+
assert manager.session_factory is None
|
|
254
|
+
assert manager.retry_cap == 30.0
|
|
255
|
+
assert manager.session_adapters is None
|
|
256
|
+
|
|
257
|
+
def test_context_manager_handles_exceptions(self) -> None:
|
|
258
|
+
"""
|
|
259
|
+
Test that :meth:`__exit__` cleans up even when the managed block
|
|
260
|
+
raises an exception.
|
|
261
|
+
"""
|
|
262
|
+
# pylint: disable=protected-access
|
|
263
|
+
|
|
264
|
+
manager = RequestManager()
|
|
265
|
+
|
|
266
|
+
class DummyExc(Exception):
|
|
267
|
+
"""Dummy exception for context-manager testing."""
|
|
268
|
+
|
|
269
|
+
manager._ctx_session = DummySession()
|
|
270
|
+
manager._ctx_owns_session = True
|
|
271
|
+
|
|
272
|
+
with pytest.raises(DummyExc):
|
|
273
|
+
with manager:
|
|
274
|
+
raise DummyExc()
|
|
275
|
+
|
|
276
|
+
assert manager._ctx_session is None
|
|
277
|
+
assert manager._ctx_owns_session is False
|
|
278
|
+
|
|
279
|
+
def test_request_once_returns_callable(self) -> None:
|
|
280
|
+
"""
|
|
281
|
+
Test that :meth:`request_once` returns the underlying callable's
|
|
282
|
+
result.
|
|
283
|
+
"""
|
|
284
|
+
# pylint: disable=unused-argument
|
|
285
|
+
|
|
286
|
+
manager = RequestManager()
|
|
287
|
+
|
|
288
|
+
def _callable(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
289
|
+
return {'ok': True}
|
|
290
|
+
|
|
291
|
+
result = manager.request_once(
|
|
292
|
+
'GET',
|
|
293
|
+
'http://x',
|
|
294
|
+
session=None,
|
|
295
|
+
timeout=1,
|
|
296
|
+
request_callable=_callable,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
assert result == {'ok': True}
|
|
300
|
+
|
|
301
|
+
@pytest.mark.parametrize(
|
|
302
|
+
('api_method', 'expected_method'),
|
|
303
|
+
[('get', 'GET'), ('post', 'POST')],
|
|
304
|
+
)
|
|
305
|
+
def test_http_shortcuts_delegate_to_request_once(
|
|
306
|
+
self,
|
|
307
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
308
|
+
api_method: str,
|
|
309
|
+
expected_method: str,
|
|
310
|
+
) -> None:
|
|
311
|
+
"""
|
|
312
|
+
Test that ``GET``/``POST`` call into :meth:`request_once` with the
|
|
313
|
+
right method.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
manager = RequestManager()
|
|
317
|
+
request_once = Mock(return_value={'ok': True})
|
|
318
|
+
|
|
319
|
+
monkeypatch.setattr(
|
|
320
|
+
type(manager),
|
|
321
|
+
'request_once',
|
|
322
|
+
staticmethod(request_once),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
func = getattr(manager, api_method)
|
|
326
|
+
assert callable(func)
|
|
327
|
+
|
|
328
|
+
assert func('http://x') == {'ok': True}
|
|
329
|
+
assert request_once.call_args.args[:2] == (expected_method, 'http://x')
|
|
330
|
+
|
|
331
|
+
def test_request_accepts_unknown_methods(
|
|
332
|
+
self,
|
|
333
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""
|
|
336
|
+
Test that unknown HTTP method strings are passed through unchanged.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
manager = RequestManager()
|
|
340
|
+
request_once = Mock(return_value={'ok': True})
|
|
341
|
+
|
|
342
|
+
monkeypatch.setattr(
|
|
343
|
+
type(manager),
|
|
344
|
+
'request_once',
|
|
345
|
+
staticmethod(request_once),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
assert manager.request('FOO', 'http://x') == {'ok': True}
|
|
349
|
+
assert request_once.call_args.args[:2] == ('FOO', 'http://x')
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
:mod:`tests.unit.api.test_u_transport` module.
|
|
3
3
|
|
|
4
|
-
Unit tests for
|
|
4
|
+
Unit tests for :mod:`etlplus.api.transport`.
|
|
5
5
|
|
|
6
6
|
Notes
|
|
7
7
|
-----
|
|
@@ -20,6 +20,7 @@ import pytest
|
|
|
20
20
|
import requests # type: ignore[import]
|
|
21
21
|
|
|
22
22
|
from etlplus.api.transport import build_http_adapter
|
|
23
|
+
from etlplus.api.transport import build_session_with_adapters
|
|
23
24
|
|
|
24
25
|
# SECTION: HELPERS ========================================================== #
|
|
25
26
|
|
|
@@ -67,6 +68,52 @@ class TestBuildHttpAdapter:
|
|
|
67
68
|
total = getattr(mr, 'total', None)
|
|
68
69
|
assert total in (0, 3)
|
|
69
70
|
|
|
71
|
+
def test_build_http_adapter_invalid_config(self):
|
|
72
|
+
"""
|
|
73
|
+
Test that invalid config does not raise and returns a usable adapter.
|
|
74
|
+
"""
|
|
75
|
+
cfg = {
|
|
76
|
+
'pool_connections': 'not-an-int',
|
|
77
|
+
'max_retries': {'total': 'bad'},
|
|
78
|
+
}
|
|
79
|
+
adapter = build_http_adapter(cfg)
|
|
80
|
+
assert isinstance(adapter, requests.adapters.HTTPAdapter)
|
|
81
|
+
|
|
82
|
+
def test_build_http_adapter_missing_keys(self):
|
|
83
|
+
"""Test that missing keys are handled gracefully."""
|
|
84
|
+
cfg = {}
|
|
85
|
+
adapter = build_http_adapter(cfg)
|
|
86
|
+
assert isinstance(adapter, requests.adapters.HTTPAdapter)
|
|
87
|
+
|
|
88
|
+
def test_build_http_adapter_retry_dict_edge(self):
|
|
89
|
+
"""Test retry dict with unknown keys is ignored."""
|
|
90
|
+
cfg = {
|
|
91
|
+
'max_retries': {'total': 2, 'unknown_key': 123},
|
|
92
|
+
}
|
|
93
|
+
adapter = build_http_adapter(cfg)
|
|
94
|
+
mr = adapter.max_retries
|
|
95
|
+
if isinstance(mr, int):
|
|
96
|
+
assert mr in (0, 2)
|
|
97
|
+
else:
|
|
98
|
+
assert getattr(mr, 'total', None) in (0, 2)
|
|
99
|
+
|
|
100
|
+
def test_build_session_with_adapters_invalid(self):
|
|
101
|
+
"""
|
|
102
|
+
Test that invalid adapter configs are skipped but session is usable.
|
|
103
|
+
"""
|
|
104
|
+
# pylint: disable=broad-exception-caught
|
|
105
|
+
adapters_cfg = [
|
|
106
|
+
{'prefix': 'https://', 'pool_connections': 'bad'},
|
|
107
|
+
{'prefix': 'http://', 'max_retries': {'total': 'bad'}},
|
|
108
|
+
]
|
|
109
|
+
session = requests.Session()
|
|
110
|
+
# Should not raise
|
|
111
|
+
try:
|
|
112
|
+
session = build_session_with_adapters(adapters_cfg)
|
|
113
|
+
except Exception:
|
|
114
|
+
pytest.fail('build_session_with_adapters should not raise')
|
|
115
|
+
assert isinstance(session, requests.Session)
|
|
116
|
+
|
|
70
117
|
def test_integer_retries_fallback(self) -> None:
|
|
71
118
|
"""Test handling integer max_retries fallback."""
|
|
72
119
|
cfg = {
|