etlplus 0.3.19__tar.gz → 0.3.22__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.19/etlplus.egg-info → etlplus-0.3.22}/PKG-INFO +1 -1
- {etlplus-0.3.19 → etlplus-0.3.22/etlplus.egg-info}/PKG-INFO +1 -1
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus.egg-info/SOURCES.txt +4 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_auth.py +1 -1
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_endpoint_client.py +1 -1
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_rate_limiter.py +1 -1
- etlplus-0.3.22/tests/unit/api/test_u_request_manager.py +349 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_transport.py +2 -1
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_types.py +1 -2
- etlplus-0.3.22/tests/unit/config/test_u_config_utils.py +129 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/config/test_u_connector.py +1 -1
- etlplus-0.3.22/tests/unit/config/test_u_jobs.py +131 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/config/test_u_pipeline.py +1 -1
- etlplus-0.3.22/tests/unit/test_u_cli.py +598 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_enums.py +1 -1
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_extract.py +1 -1
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_file.py +4 -4
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_load.py +4 -4
- etlplus-0.3.22/tests/unit/test_u_main.py +58 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_mixins.py +1 -1
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_run.py +32 -18
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_run_helpers.py +1 -1
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_transform.py +167 -40
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_utils.py +3 -3
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/test_u_validate.py +2 -2
- etlplus-0.3.22/tests/unit/test_u_version.py +53 -0
- etlplus-0.3.19/tests/unit/api/test_u_request_manager.py +0 -201
- etlplus-0.3.19/tests/unit/test_u_cli.py +0 -185
- {etlplus-0.3.19 → etlplus-0.3.22}/.coveragerc +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/.editorconfig +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/.gitattributes +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/.github/actions/python-bootstrap/action.yml +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/.github/workflows/ci.yml +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/.gitignore +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/.pre-commit-config.yaml +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/.ruff.toml +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/CODE_OF_CONDUCT.md +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/CONTRIBUTING.md +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/DEMO.md +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/LICENSE +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/Makefile +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/README.md +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/REFERENCES.md +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/docs/pipeline-guide.md +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/docs/snippets/installation_version.md +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/__init__.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/__main__.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/__version__.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/README.md +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/__init__.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/auth.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/config.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/endpoint_client.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/errors.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/pagination/__init__.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/pagination/client.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/pagination/config.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/pagination/paginator.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/rate_limiting/__init__.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/rate_limiting/config.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/request_manager.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/retry_manager.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/transport.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/api/types.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/cli.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/config/__init__.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/config/connector.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/config/jobs.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/config/pipeline.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/config/profile.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/config/types.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/config/utils.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/enums.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/extract.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/file.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/load.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/mixins.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/py.typed +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/run.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/run_helpers.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/transform.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/types.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/utils.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/validate.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/validation/__init__.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus/validation/utils.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus.egg-info/dependency_links.txt +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus.egg-info/entry_points.txt +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus.egg-info/requires.txt +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/etlplus.egg-info/top_level.txt +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/examples/README.md +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/examples/configs/pipeline.yml +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/examples/data/sample.csv +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/examples/data/sample.json +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/examples/data/sample.xml +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/examples/data/sample.xsd +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/examples/data/sample.yaml +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/examples/quickstart_python.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/pyproject.toml +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/pytest.ini +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/setup.cfg +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/setup.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/__init__.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/conftest.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/integration/conftest.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/integration/test_i_cli.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/integration/test_i_examples_data_parity.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/integration/test_i_pagination_strategy.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/integration/test_i_pipeline_smoke.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/integration/test_i_run.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/conftest.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_config.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_mocks.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_pagination_client.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_pagination_config.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_paginator.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_rate_limit_config.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/api/test_u_retry_manager.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/conftest.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tests/unit/validation/test_u_validation_utils.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/tools/run_pipeline.py +0 -0
- {etlplus-0.3.19 → etlplus-0.3.22}/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
|
|
@@ -111,7 +113,9 @@ 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
|
|
113
115
|
tests/unit/api/test_u_types.py
|
|
116
|
+
tests/unit/config/test_u_config_utils.py
|
|
114
117
|
tests/unit/config/test_u_connector.py
|
|
118
|
+
tests/unit/config/test_u_jobs.py
|
|
115
119
|
tests/unit/config/test_u_pipeline.py
|
|
116
120
|
tests/unit/validation/test_u_validation_utils.py
|
|
117
121
|
tools/run_pipeline.py
|
|
@@ -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
|
-----
|
|
@@ -101,6 +101,7 @@ class TestBuildHttpAdapter:
|
|
|
101
101
|
"""
|
|
102
102
|
Test that invalid adapter configs are skipped but session is usable.
|
|
103
103
|
"""
|
|
104
|
+
# pylint: disable=broad-exception-caught
|
|
104
105
|
adapters_cfg = [
|
|
105
106
|
{'prefix': 'https://', 'pool_connections': 'bad'},
|
|
106
107
|
{'prefix': 'http://', 'max_retries': {'total': 'bad'}},
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
:mod:`tests.unit.config.test_u_config_utils` module.
|
|
3
|
+
|
|
4
|
+
Unit tests for :mod:`etlplus.config.utils`.
|
|
5
|
+
|
|
6
|
+
Notes
|
|
7
|
+
-----
|
|
8
|
+
- These tests are intentionally focused on functional behavior (input → output)
|
|
9
|
+
and avoid asserting implementation details.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from etlplus.config import utils as config_utils
|
|
17
|
+
|
|
18
|
+
# SECTION: HELPERS ========================================================== #
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
pytestmark = pytest.mark.unit
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# SECTION: FIXTURES ========================================================= #
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture(name='vars_map_basic')
|
|
28
|
+
def vars_map_basic_fixture() -> dict[str, str]:
|
|
29
|
+
"""Provide a basic variables mapping for token substitution."""
|
|
30
|
+
|
|
31
|
+
return {'FOO': 'foo', 'BAR': 'bar'}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(name='vars_map_nested')
|
|
35
|
+
def vars_map_nested_fixture() -> dict[str, int]:
|
|
36
|
+
"""Provide an integer variables mapping used in nested substitutions."""
|
|
37
|
+
|
|
38
|
+
return {'X': 1, 'Y': 2, 'Z': 3}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# SECTION: TESTS ============================================================ #
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestDeepSubstitute:
|
|
45
|
+
"""Unit test suite for :func:`etlplus.config.utils.deep_substitute`."""
|
|
46
|
+
|
|
47
|
+
def test_basic_substitution(self, vars_map_basic: dict[str, str]) -> None:
|
|
48
|
+
"""Test substituting tokens across nested mappings and sequences."""
|
|
49
|
+
|
|
50
|
+
value = {'a': '${FOO}', 'b': 2, 'c': ['${BAR}', 3]}
|
|
51
|
+
result = config_utils.deep_substitute(value, vars_map_basic, None)
|
|
52
|
+
|
|
53
|
+
assert result == {'a': 'foo', 'b': 2, 'c': ['bar', 3]}
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize(
|
|
56
|
+
'value, expected',
|
|
57
|
+
[
|
|
58
|
+
pytest.param('', '', id='empty-string'),
|
|
59
|
+
pytest.param({}, {}, id='empty-dict'),
|
|
60
|
+
pytest.param([], [], id='empty-list'),
|
|
61
|
+
pytest.param(None, None, id='none'),
|
|
62
|
+
],
|
|
63
|
+
)
|
|
64
|
+
def test_empty_inputs_passthrough(
|
|
65
|
+
self,
|
|
66
|
+
value: object,
|
|
67
|
+
expected: object,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Test that empty inputs are returned unchanged."""
|
|
70
|
+
|
|
71
|
+
result = config_utils.deep_substitute(value, None, None)
|
|
72
|
+
if expected is None:
|
|
73
|
+
assert result is None
|
|
74
|
+
else:
|
|
75
|
+
assert result == expected
|
|
76
|
+
|
|
77
|
+
def test_env_overrides_vars_map(
|
|
78
|
+
self,
|
|
79
|
+
vars_map_basic: dict[str, str],
|
|
80
|
+
) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Test that ``env_map`` values are preferred over ``vars_map`` values.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
value = {'a': '${FOO}', 'b': '${BAR}'}
|
|
86
|
+
env_map = {'FOO': 'envfoo'}
|
|
87
|
+
|
|
88
|
+
result = config_utils.deep_substitute(value, vars_map_basic, env_map)
|
|
89
|
+
|
|
90
|
+
assert result == {'a': 'envfoo', 'b': 'bar'}
|
|
91
|
+
|
|
92
|
+
def test_nested_structures(self, vars_map_nested: dict[str, int]) -> None:
|
|
93
|
+
"""Test substituting tokens in nested structures, including tuples."""
|
|
94
|
+
|
|
95
|
+
value = {'a': ['${X}', {'b': '${Y}'}], 'c': ({'d': '${Z}'},)}
|
|
96
|
+
result = config_utils.deep_substitute(value, vars_map_nested, None)
|
|
97
|
+
|
|
98
|
+
# deep_substitute coerces substituted values to strings.
|
|
99
|
+
assert result == {'a': ['1', {'b': '2'}], 'c': ({'d': '3'},)}
|
|
100
|
+
|
|
101
|
+
def test_no_substitutions_needed(self) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Test returning the original value when no substitutions are required.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
value = {'a': 1, 'b': [2, 3], 'c': {'d': 4}}
|
|
107
|
+
result = config_utils.deep_substitute(value, None, None)
|
|
108
|
+
|
|
109
|
+
assert result == value
|
|
110
|
+
|
|
111
|
+
def test_sets_and_frozensets(self) -> None:
|
|
112
|
+
"""Test substituting tokens within set-like container structures."""
|
|
113
|
+
|
|
114
|
+
value = {'a': {'${FOO}', 'bar'}, 'b': frozenset(['${FOO}', 'baz'])}
|
|
115
|
+
result = config_utils.deep_substitute(value, {'FOO': 'f'}, None)
|
|
116
|
+
|
|
117
|
+
assert result['a'] == {'f', 'bar'}
|
|
118
|
+
assert result['b'] == frozenset({'f', 'baz'})
|
|
119
|
+
|
|
120
|
+
def test_token_not_found_returns_original(self) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Test that unknown tokens are left unchanged when no mapping provides a
|
|
123
|
+
value.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
value = 'Hello ${MISSING}'
|
|
127
|
+
result = config_utils.deep_substitute(value, {'FOO': 'foo'}, None)
|
|
128
|
+
|
|
129
|
+
assert result == 'Hello ${MISSING}'
|