etlplus 0.3.19__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.
Files changed (126) hide show
  1. {etlplus-0.3.19/etlplus.egg-info → etlplus-0.3.21}/PKG-INFO +1 -1
  2. {etlplus-0.3.19 → etlplus-0.3.21/etlplus.egg-info}/PKG-INFO +1 -1
  3. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus.egg-info/SOURCES.txt +4 -0
  4. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_auth.py +1 -1
  5. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_endpoint_client.py +1 -1
  6. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_rate_limiter.py +1 -1
  7. etlplus-0.3.21/tests/unit/api/test_u_request_manager.py +349 -0
  8. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_transport.py +2 -1
  9. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_types.py +1 -2
  10. etlplus-0.3.21/tests/unit/config/test_u_config_utils.py +129 -0
  11. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/config/test_u_connector.py +1 -1
  12. etlplus-0.3.21/tests/unit/config/test_u_jobs.py +131 -0
  13. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/config/test_u_pipeline.py +1 -1
  14. etlplus-0.3.21/tests/unit/test_u_cli.py +299 -0
  15. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_enums.py +1 -1
  16. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_extract.py +1 -1
  17. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_file.py +4 -4
  18. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_load.py +4 -4
  19. etlplus-0.3.21/tests/unit/test_u_main.py +58 -0
  20. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_mixins.py +1 -1
  21. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_run.py +32 -18
  22. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_run_helpers.py +1 -1
  23. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_transform.py +167 -40
  24. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_utils.py +3 -3
  25. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/test_u_validate.py +2 -2
  26. etlplus-0.3.21/tests/unit/test_u_version.py +53 -0
  27. etlplus-0.3.19/tests/unit/api/test_u_request_manager.py +0 -201
  28. etlplus-0.3.19/tests/unit/test_u_cli.py +0 -185
  29. {etlplus-0.3.19 → etlplus-0.3.21}/.coveragerc +0 -0
  30. {etlplus-0.3.19 → etlplus-0.3.21}/.editorconfig +0 -0
  31. {etlplus-0.3.19 → etlplus-0.3.21}/.gitattributes +0 -0
  32. {etlplus-0.3.19 → etlplus-0.3.21}/.github/actions/python-bootstrap/action.yml +0 -0
  33. {etlplus-0.3.19 → etlplus-0.3.21}/.github/workflows/ci.yml +0 -0
  34. {etlplus-0.3.19 → etlplus-0.3.21}/.gitignore +0 -0
  35. {etlplus-0.3.19 → etlplus-0.3.21}/.pre-commit-config.yaml +0 -0
  36. {etlplus-0.3.19 → etlplus-0.3.21}/.ruff.toml +0 -0
  37. {etlplus-0.3.19 → etlplus-0.3.21}/CODE_OF_CONDUCT.md +0 -0
  38. {etlplus-0.3.19 → etlplus-0.3.21}/CONTRIBUTING.md +0 -0
  39. {etlplus-0.3.19 → etlplus-0.3.21}/DEMO.md +0 -0
  40. {etlplus-0.3.19 → etlplus-0.3.21}/LICENSE +0 -0
  41. {etlplus-0.3.19 → etlplus-0.3.21}/Makefile +0 -0
  42. {etlplus-0.3.19 → etlplus-0.3.21}/README.md +0 -0
  43. {etlplus-0.3.19 → etlplus-0.3.21}/REFERENCES.md +0 -0
  44. {etlplus-0.3.19 → etlplus-0.3.21}/docs/pipeline-guide.md +0 -0
  45. {etlplus-0.3.19 → etlplus-0.3.21}/docs/snippets/installation_version.md +0 -0
  46. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/__init__.py +0 -0
  47. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/__main__.py +0 -0
  48. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/__version__.py +0 -0
  49. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/README.md +0 -0
  50. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/__init__.py +0 -0
  51. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/auth.py +0 -0
  52. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/config.py +0 -0
  53. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/endpoint_client.py +0 -0
  54. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/errors.py +0 -0
  55. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/pagination/__init__.py +0 -0
  56. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/pagination/client.py +0 -0
  57. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/pagination/config.py +0 -0
  58. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/pagination/paginator.py +0 -0
  59. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/rate_limiting/__init__.py +0 -0
  60. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/rate_limiting/config.py +0 -0
  61. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  62. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/request_manager.py +0 -0
  63. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/retry_manager.py +0 -0
  64. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/transport.py +0 -0
  65. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/api/types.py +0 -0
  66. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/cli.py +0 -0
  67. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/config/__init__.py +0 -0
  68. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/config/connector.py +0 -0
  69. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/config/jobs.py +0 -0
  70. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/config/pipeline.py +0 -0
  71. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/config/profile.py +0 -0
  72. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/config/types.py +0 -0
  73. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/config/utils.py +0 -0
  74. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/enums.py +0 -0
  75. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/extract.py +0 -0
  76. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/file.py +0 -0
  77. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/load.py +0 -0
  78. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/mixins.py +0 -0
  79. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/py.typed +0 -0
  80. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/run.py +0 -0
  81. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/run_helpers.py +0 -0
  82. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/transform.py +0 -0
  83. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/types.py +0 -0
  84. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/utils.py +0 -0
  85. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/validate.py +0 -0
  86. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/validation/__init__.py +0 -0
  87. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus/validation/utils.py +0 -0
  88. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus.egg-info/dependency_links.txt +0 -0
  89. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus.egg-info/entry_points.txt +0 -0
  90. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus.egg-info/requires.txt +0 -0
  91. {etlplus-0.3.19 → etlplus-0.3.21}/etlplus.egg-info/top_level.txt +0 -0
  92. {etlplus-0.3.19 → etlplus-0.3.21}/examples/README.md +0 -0
  93. {etlplus-0.3.19 → etlplus-0.3.21}/examples/configs/pipeline.yml +0 -0
  94. {etlplus-0.3.19 → etlplus-0.3.21}/examples/data/sample.csv +0 -0
  95. {etlplus-0.3.19 → etlplus-0.3.21}/examples/data/sample.json +0 -0
  96. {etlplus-0.3.19 → etlplus-0.3.21}/examples/data/sample.xml +0 -0
  97. {etlplus-0.3.19 → etlplus-0.3.21}/examples/data/sample.xsd +0 -0
  98. {etlplus-0.3.19 → etlplus-0.3.21}/examples/data/sample.yaml +0 -0
  99. {etlplus-0.3.19 → etlplus-0.3.21}/examples/quickstart_python.py +0 -0
  100. {etlplus-0.3.19 → etlplus-0.3.21}/pyproject.toml +0 -0
  101. {etlplus-0.3.19 → etlplus-0.3.21}/pytest.ini +0 -0
  102. {etlplus-0.3.19 → etlplus-0.3.21}/setup.cfg +0 -0
  103. {etlplus-0.3.19 → etlplus-0.3.21}/setup.py +0 -0
  104. {etlplus-0.3.19 → etlplus-0.3.21}/tests/__init__.py +0 -0
  105. {etlplus-0.3.19 → etlplus-0.3.21}/tests/conftest.py +0 -0
  106. {etlplus-0.3.19 → etlplus-0.3.21}/tests/integration/conftest.py +0 -0
  107. {etlplus-0.3.19 → etlplus-0.3.21}/tests/integration/test_i_cli.py +0 -0
  108. {etlplus-0.3.19 → etlplus-0.3.21}/tests/integration/test_i_examples_data_parity.py +0 -0
  109. {etlplus-0.3.19 → etlplus-0.3.21}/tests/integration/test_i_pagination_strategy.py +0 -0
  110. {etlplus-0.3.19 → etlplus-0.3.21}/tests/integration/test_i_pipeline_smoke.py +0 -0
  111. {etlplus-0.3.19 → etlplus-0.3.21}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
  112. {etlplus-0.3.19 → etlplus-0.3.21}/tests/integration/test_i_run.py +0 -0
  113. {etlplus-0.3.19 → etlplus-0.3.21}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
  114. {etlplus-0.3.19 → etlplus-0.3.21}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
  115. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/conftest.py +0 -0
  116. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_config.py +0 -0
  117. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_mocks.py +0 -0
  118. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_pagination_client.py +0 -0
  119. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_pagination_config.py +0 -0
  120. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_paginator.py +0 -0
  121. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_rate_limit_config.py +0 -0
  122. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/api/test_u_retry_manager.py +0 -0
  123. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/conftest.py +0 -0
  124. {etlplus-0.3.19 → etlplus-0.3.21}/tests/unit/validation/test_u_validation_utils.py +0 -0
  125. {etlplus-0.3.19 → etlplus-0.3.21}/tools/run_pipeline.py +0 -0
  126. {etlplus-0.3.19 → etlplus-0.3.21}/tools/update_demo_snippets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.3.19
3
+ Version: 0.3.21
4
4
  Summary: A Swiss Army knife for simple ETL operations
5
5
  Home-page: https://github.com/Dagitali/ETLPlus
6
6
  Author: ETLPlus Team
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.3.19
3
+ Version: 0.3.21
4
4
  Summary: A Swiss Army knife for simple ETL operations
5
5
  Home-page: https://github.com/Dagitali/ETLPlus
6
6
  Author: ETLPlus Team
@@ -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
@@ -1,7 +1,7 @@
1
1
  """
2
2
  :mod:`tests.unit.api.test_u_auth` module.
3
3
 
4
- Unit tests for ``etlplus.api.auth``.
4
+ Unit tests for :mod:`etlplus.api.auth`.
5
5
 
6
6
  Notes
7
7
  -----
@@ -1,7 +1,7 @@
1
1
  """
2
2
  :mod:`tests.unit.api.test_u_endpoint_client` module.
3
3
 
4
- Unit tests for ``etlplus.api.endpoint_client``.
4
+ Unit tests for :mod:`etlplus.api.endpoint_client`.
5
5
 
6
6
  Notes
7
7
  -----
@@ -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 ``etlplus.api.transport``.
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'}},
@@ -1,8 +1,7 @@
1
1
  """
2
2
  :mod:`tests.unit.api.test_u_types` module.
3
3
 
4
- Unit tests for custom type aliases and data structures in
5
- :mod:`etlplus.api.types`.
4
+ Unit tests for :mod:`etlplus.api.types`.
6
5
  """
7
6
 
8
7
  import pytest
@@ -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}'
@@ -1,7 +1,7 @@
1
1
  """
2
2
  :mod:`tests.unit.config.test_u_connector` module.
3
3
 
4
- Unit tests for ``etlplus.config.connector``.
4
+ Unit tests for :mod:`etlplus.config.connector`.
5
5
 
6
6
  Notes
7
7
  -----