etlplus 0.3.10__tar.gz → 0.3.17__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 (127) hide show
  1. etlplus-0.3.17/.coveragerc +21 -0
  2. {etlplus-0.3.10 → etlplus-0.3.17}/.github/actions/python-bootstrap/action.yml +5 -1
  3. {etlplus-0.3.10 → etlplus-0.3.17}/.github/workflows/ci.yml +56 -31
  4. {etlplus-0.3.10/etlplus.egg-info → etlplus-0.3.17}/PKG-INFO +1 -1
  5. {etlplus-0.3.10 → etlplus-0.3.17/etlplus.egg-info}/PKG-INFO +1 -1
  6. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus.egg-info/SOURCES.txt +5 -0
  7. etlplus-0.3.17/tests/conftest.py +210 -0
  8. {etlplus-0.3.10 → etlplus-0.3.17}/tests/integration/conftest.py +105 -16
  9. etlplus-0.3.17/tests/integration/test_i_cli.py +244 -0
  10. {etlplus-0.3.10 → etlplus-0.3.17}/tests/integration/test_i_examples_data_parity.py +5 -0
  11. etlplus-0.3.17/tests/integration/test_i_pagination_strategy.py +551 -0
  12. {etlplus-0.3.10 → etlplus-0.3.17}/tests/integration/test_i_pipeline_smoke.py +41 -36
  13. {etlplus-0.3.10 → etlplus-0.3.17}/tests/integration/test_i_pipeline_yaml_load.py +6 -0
  14. etlplus-0.3.17/tests/integration/test_i_run.py +69 -0
  15. {etlplus-0.3.10 → etlplus-0.3.17}/tests/integration/test_i_run_profile_pagination_defaults.py +11 -7
  16. {etlplus-0.3.10 → etlplus-0.3.17}/tests/integration/test_i_run_profile_rate_limit_defaults.py +6 -0
  17. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/conftest.py +42 -15
  18. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_auth.py +113 -123
  19. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_config.py +60 -16
  20. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_endpoint_client.py +447 -268
  21. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_pagination_client.py +5 -0
  22. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_pagination_config.py +5 -0
  23. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_paginator.py +5 -0
  24. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_rate_limit_config.py +5 -0
  25. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_rate_limiter.py +5 -0
  26. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_request_manager.py +3 -2
  27. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_retry_manager.py +6 -0
  28. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_transport.py +5 -0
  29. etlplus-0.3.17/tests/unit/config/test_u_connector.py +119 -0
  30. etlplus-0.3.17/tests/unit/config/test_u_pipeline.py +285 -0
  31. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/conftest.py +30 -30
  32. etlplus-0.3.17/tests/unit/test_u_cli.py +185 -0
  33. etlplus-0.3.17/tests/unit/test_u_enums.py +135 -0
  34. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/test_u_extract.py +212 -0
  35. etlplus-0.3.17/tests/unit/test_u_file.py +297 -0
  36. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/test_u_load.py +201 -0
  37. etlplus-0.3.17/tests/unit/test_u_mixins.py +47 -0
  38. etlplus-0.3.17/tests/unit/test_u_run.py +288 -0
  39. etlplus-0.3.17/tests/unit/test_u_run_helpers.py +385 -0
  40. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/test_u_transform.py +80 -0
  41. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/test_u_utils.py +92 -3
  42. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/test_u_validate.py +39 -0
  43. etlplus-0.3.10/tests/conftest.py +0 -11
  44. etlplus-0.3.10/tests/integration/test_i_cli.py +0 -348
  45. etlplus-0.3.10/tests/integration/test_i_pagination_strategy.py +0 -452
  46. etlplus-0.3.10/tests/integration/test_i_run.py +0 -133
  47. etlplus-0.3.10/tests/unit/config/test_u_connector.py +0 -54
  48. etlplus-0.3.10/tests/unit/config/test_u_pipeline.py +0 -194
  49. etlplus-0.3.10/tests/unit/test_u_cli.py +0 -124
  50. etlplus-0.3.10/tests/unit/test_u_file.py +0 -100
  51. {etlplus-0.3.10 → etlplus-0.3.17}/.editorconfig +0 -0
  52. {etlplus-0.3.10 → etlplus-0.3.17}/.gitattributes +0 -0
  53. {etlplus-0.3.10 → etlplus-0.3.17}/.gitignore +0 -0
  54. {etlplus-0.3.10 → etlplus-0.3.17}/.pre-commit-config.yaml +0 -0
  55. {etlplus-0.3.10 → etlplus-0.3.17}/.ruff.toml +0 -0
  56. {etlplus-0.3.10 → etlplus-0.3.17}/CODE_OF_CONDUCT.md +0 -0
  57. {etlplus-0.3.10 → etlplus-0.3.17}/CONTRIBUTING.md +0 -0
  58. {etlplus-0.3.10 → etlplus-0.3.17}/DEMO.md +0 -0
  59. {etlplus-0.3.10 → etlplus-0.3.17}/LICENSE +0 -0
  60. {etlplus-0.3.10 → etlplus-0.3.17}/Makefile +0 -0
  61. {etlplus-0.3.10 → etlplus-0.3.17}/README.md +0 -0
  62. {etlplus-0.3.10 → etlplus-0.3.17}/REFERENCES.md +0 -0
  63. {etlplus-0.3.10 → etlplus-0.3.17}/docs/pipeline-guide.md +0 -0
  64. {etlplus-0.3.10 → etlplus-0.3.17}/docs/snippets/installation_version.md +0 -0
  65. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/__init__.py +0 -0
  66. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/__main__.py +0 -0
  67. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/__version__.py +0 -0
  68. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/README.md +0 -0
  69. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/__init__.py +0 -0
  70. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/auth.py +0 -0
  71. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/config.py +0 -0
  72. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/endpoint_client.py +0 -0
  73. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/errors.py +0 -0
  74. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/pagination/__init__.py +0 -0
  75. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/pagination/client.py +0 -0
  76. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/pagination/config.py +0 -0
  77. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/pagination/paginator.py +0 -0
  78. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/rate_limiting/__init__.py +0 -0
  79. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/rate_limiting/config.py +0 -0
  80. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  81. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/request_manager.py +0 -0
  82. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/retry_manager.py +0 -0
  83. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/transport.py +0 -0
  84. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/api/types.py +0 -0
  85. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/cli.py +0 -0
  86. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/config/__init__.py +0 -0
  87. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/config/connector.py +0 -0
  88. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/config/jobs.py +0 -0
  89. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/config/pipeline.py +0 -0
  90. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/config/profile.py +0 -0
  91. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/config/types.py +0 -0
  92. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/config/utils.py +0 -0
  93. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/enums.py +0 -0
  94. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/extract.py +0 -0
  95. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/file.py +0 -0
  96. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/load.py +0 -0
  97. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/mixins.py +0 -0
  98. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/py.typed +0 -0
  99. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/run.py +0 -0
  100. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/run_helpers.py +0 -0
  101. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/transform.py +0 -0
  102. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/types.py +0 -0
  103. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/utils.py +0 -0
  104. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/validate.py +0 -0
  105. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/validation/__init__.py +0 -0
  106. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus/validation/utils.py +0 -0
  107. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus.egg-info/dependency_links.txt +0 -0
  108. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus.egg-info/entry_points.txt +0 -0
  109. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus.egg-info/requires.txt +0 -0
  110. {etlplus-0.3.10 → etlplus-0.3.17}/etlplus.egg-info/top_level.txt +0 -0
  111. {etlplus-0.3.10 → etlplus-0.3.17}/examples/README.md +0 -0
  112. {etlplus-0.3.10 → etlplus-0.3.17}/examples/configs/pipeline.yml +0 -0
  113. {etlplus-0.3.10 → etlplus-0.3.17}/examples/data/sample.csv +0 -0
  114. {etlplus-0.3.10 → etlplus-0.3.17}/examples/data/sample.json +0 -0
  115. {etlplus-0.3.10 → etlplus-0.3.17}/examples/data/sample.xml +0 -0
  116. {etlplus-0.3.10 → etlplus-0.3.17}/examples/data/sample.xsd +0 -0
  117. {etlplus-0.3.10 → etlplus-0.3.17}/examples/data/sample.yaml +0 -0
  118. {etlplus-0.3.10 → etlplus-0.3.17}/examples/quickstart_python.py +0 -0
  119. {etlplus-0.3.10 → etlplus-0.3.17}/pyproject.toml +0 -0
  120. {etlplus-0.3.10 → etlplus-0.3.17}/pytest.ini +0 -0
  121. {etlplus-0.3.10 → etlplus-0.3.17}/setup.cfg +0 -0
  122. {etlplus-0.3.10 → etlplus-0.3.17}/setup.py +0 -0
  123. {etlplus-0.3.10 → etlplus-0.3.17}/tests/__init__.py +0 -0
  124. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/api/test_u_mocks.py +0 -0
  125. {etlplus-0.3.10 → etlplus-0.3.17}/tests/unit/validation/test_u_validation_utils.py +0 -0
  126. {etlplus-0.3.10 → etlplus-0.3.17}/tools/run_pipeline.py +0 -0
  127. {etlplus-0.3.10 → etlplus-0.3.17}/tools/update_demo_snippets.py +0 -0
@@ -0,0 +1,21 @@
1
+ # .coveragerc
2
+ # ETLPlus
3
+ #
4
+ # Copyright © 2025 Dagitali LLC. All rights reserved.
5
+ #
6
+ # An optional pytest-cov configuration file. Limits coverage measurement to the
7
+ # ETLPlus package and ignore test modules.
8
+ #
9
+ # See:
10
+ # 1. https://pytest-cov.readthedocs.io/en/latest/config.html
11
+
12
+ [run]
13
+ source = etlplus
14
+ branch = true
15
+ omit =
16
+ tests/*
17
+ */tests/*
18
+
19
+ [report]
20
+ skip_covered = true
21
+ show_missing = true
@@ -5,6 +5,10 @@
5
5
  #
6
6
  # A GitHub Actions action to set up Python and install Python package
7
7
  # dependencies.
8
+ #
9
+ # Notes
10
+ # - External GitHub Actions action references are each pinned to specific SHA
11
+ # to limit supply-chain risk.
8
12
 
9
13
  ---
10
14
 
@@ -25,7 +29,7 @@ inputs:
25
29
  runs:
26
30
  using: composite
27
31
  steps:
28
- - uses: actions/setup-python@v5
32
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # Pinned v5
29
33
  with:
30
34
  python-version: ${{ inputs.python-version }}
31
35
 
@@ -4,6 +4,16 @@
4
4
  # Copyright © 2025 Dagitali LLC. All rights reserved.
5
5
  #
6
6
  # A GitHub Actions workflow configuration file for Continuous Integration (CI).
7
+ #
8
+ # Notes
9
+ # - The workflow includes jobs for linting, testing, building distributions,
10
+ # and publishing releases to GitHub and PyPI.
11
+ # - The workflow is hardened by removing global permissions and setting per-job
12
+ # scopes.
13
+ # - To harden workflow security, global permissions are absent, and per-job
14
+ # permissions are set as needed.
15
+ # - External GitHub Actions action references are each pinned to specific SHA
16
+ # to limit supply-chain risk.
7
17
 
8
18
  ---
9
19
 
@@ -28,9 +38,7 @@ on:
28
38
  pull_request:
29
39
  branches: [ main, develop ]
30
40
 
31
- permissions:
32
- contents: read
33
- id-token: write
41
+ permissions: {}
34
42
 
35
43
  jobs:
36
44
  lint:
@@ -38,8 +46,12 @@ jobs:
38
46
  strategy: &python-matrix
39
47
  matrix:
40
48
  python-version: ['3.13', '3.14']
49
+ permissions: &permissions_read
50
+ contents: read
41
51
  steps:
42
- - uses: actions/checkout@v4
52
+ - &checkout_step
53
+ name: Checkout repository
54
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # Pinned v4
43
55
  with:
44
56
  fetch-depth: 0
45
57
  - uses: ./.github/actions/python-bootstrap
@@ -55,27 +67,44 @@ jobs:
55
67
  test:
56
68
  runs-on: ubuntu-latest
57
69
  strategy: *python-matrix
70
+ permissions: *permissions_read
58
71
  steps:
59
- - uses: actions/checkout@v4
60
- with:
61
- fetch-depth: 0
72
+ - *checkout_step
62
73
  - uses: ./.github/actions/python-bootstrap
63
74
  with:
64
75
  python-version: ${{ matrix.python-version }}
65
76
  python-bootstrap: "-e .[dev,yaml]"
66
- - name: Run tests
77
+ - name: Run tests (with coverage)
67
78
  run: |
68
- pytest -q
79
+ pytest -q \
80
+ --cov \
81
+ --cov-branch \
82
+ --cov-config=.coveragerc \
83
+ --cov-report=term-missing \
84
+ --cov-report=xml \
85
+ tests/
86
+
87
+ - name: Upload coverage reports to Codecov
88
+ if: matrix.python-version == '3.13'
89
+ uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # Pinned v5.5.2
90
+ with:
91
+ fail_ci_if_error: true
92
+ files: coverage.xml
93
+ flags: unit
94
+ name: etlplus
95
+ token: ${{ secrets.CODECOV_TOKEN }} # Omit for public repo
96
+ verbose: true
69
97
 
70
98
  build:
71
99
  name: Build distributions
72
100
  runs-on: ubuntu-latest
73
- if: startsWith(github.ref, 'refs/tags/v')
101
+ if: &release_tag_condition >
102
+ startsWith(github.ref, 'refs/tags/v') ||
103
+ startsWith(github.ref, 'refs/tags/rc')
74
104
  needs: [lint, test]
105
+ permissions: *permissions_read
75
106
  steps:
76
- - uses: actions/checkout@v4
77
- with:
78
- fetch-depth: 0
107
+ - *checkout_step
79
108
  - uses: ./.github/actions/python-bootstrap
80
109
  with:
81
110
  python-version: '3.13'
@@ -84,7 +113,7 @@ jobs:
84
113
  run: |
85
114
  python -m build
86
115
  - name: Upload distributions
87
- uses: actions/upload-artifact@v4
116
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # Pinned v4
88
117
  with:
89
118
  name: dist-artifacts
90
119
  path: dist/*
@@ -93,21 +122,20 @@ jobs:
93
122
  release:
94
123
  name: Publish GitHub Release
95
124
  runs-on: ubuntu-latest
96
- if: startsWith(github.ref, 'refs/tags/v')
125
+ if: *release_tag_condition
97
126
  needs: build
98
127
  permissions:
99
128
  contents: write
100
129
  steps:
101
- - uses: actions/checkout@v4
102
- with:
103
- fetch-depth: 0
104
- - name: Download distributions
105
- uses: actions/download-artifact@v4
130
+ - *checkout_step
131
+ - &download_dist_step
132
+ name: Download distributions
133
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # Pinned v4
106
134
  with:
107
135
  name: dist-artifacts
108
136
  path: dist
109
137
  - name: Publish GitHub release
110
- uses: softprops/action-gh-release@v2
138
+ uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # Pinned v2
111
139
  with:
112
140
  files: dist/*
113
141
  generate_release_notes: true
@@ -115,21 +143,18 @@ jobs:
115
143
  publish:
116
144
  name: Publish to PyPI
117
145
  runs-on: ubuntu-latest
118
- if: startsWith(github.ref, 'refs/tags/v')
146
+ if: *release_tag_condition
119
147
  needs: build
148
+ permissions:
149
+ contents: read
150
+ id-token: write
120
151
  environment:
121
152
  name: pypi
122
153
  url: https://pypi.org/project/etlplus/
123
154
  steps:
124
- - uses: actions/checkout@v4
125
- with:
126
- fetch-depth: 0
127
- - name: Download distributions
128
- uses: actions/download-artifact@v4
129
- with:
130
- name: dist-artifacts
131
- path: dist
155
+ - *checkout_step
156
+ - *download_dist_step
132
157
  - name: Publish to PyPI
133
- uses: pypa/gh-action-pypi-publish@release/v1
158
+ uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # Pinned v1
134
159
  with:
135
160
  verbose: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.3.10
3
+ Version: 0.3.17
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.10
3
+ Version: 0.3.17
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,3 +1,4 @@
1
+ .coveragerc
1
2
  .editorconfig
2
3
  .gitattributes
3
4
  .gitignore
@@ -86,9 +87,13 @@ tests/integration/test_i_run_profile_pagination_defaults.py
86
87
  tests/integration/test_i_run_profile_rate_limit_defaults.py
87
88
  tests/unit/conftest.py
88
89
  tests/unit/test_u_cli.py
90
+ tests/unit/test_u_enums.py
89
91
  tests/unit/test_u_extract.py
90
92
  tests/unit/test_u_file.py
91
93
  tests/unit/test_u_load.py
94
+ tests/unit/test_u_mixins.py
95
+ tests/unit/test_u_run.py
96
+ tests/unit/test_u_run_helpers.py
92
97
  tests/unit/test_u_transform.py
93
98
  tests/unit/test_u_utils.py
94
99
  tests/unit/test_u_validate.py
@@ -0,0 +1,210 @@
1
+ """
2
+ :mod:`tests.conftest` module.
3
+
4
+ Global pytest fixtures shared across unit, integration, and end-to-end tests.
5
+
6
+ Notes
7
+ -----
8
+ - Provides CLI helpers so tests no longer need to monkeypatch ``sys.argv``
9
+ inline.
10
+ - Supplies JSON file factories that rely on ``tmp_path`` for automatic
11
+ cleanup.
12
+ - Keeps docstrings NumPy-formatted to satisfy numpydoc linting.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ from collections.abc import Sequence
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from typing import Protocol
23
+
24
+ import pytest
25
+ from requests import PreparedRequest # type: ignore[import]
26
+
27
+ from etlplus.cli import main
28
+
29
+ # SECTION: HELPERS ========================================================== #
30
+
31
+
32
+ def _coerce_cli_args(
33
+ cli_args: tuple[str | Sequence[str], ...],
34
+ ) -> tuple[str, ...]:
35
+ """
36
+ Normalize CLI arguments into a ``tuple[str, ...]``.
37
+
38
+ Parameters
39
+ ----------
40
+ cli_args : tuple[str | Sequence[str], ...]
41
+ Arguments provided to ``cli_runner``/``cli_invoke``.
42
+
43
+ Returns
44
+ -------
45
+ tuple[str, ...]
46
+ Normalized argument tuple safe to concatenate with ``sys.argv``.
47
+ """
48
+ if (
49
+ len(cli_args) == 1
50
+ and isinstance(cli_args[0], Sequence)
51
+ and not isinstance(cli_args[0], (str, bytes))
52
+ ):
53
+ return tuple(str(part) for part in cli_args[0])
54
+ return tuple(str(part) for part in cli_args)
55
+
56
+
57
+ class CliInvoke(Protocol):
58
+ """Protocol describing the :func:`cli_invoke` fixture."""
59
+
60
+ def __call__(
61
+ self,
62
+ *cli_args: str | Sequence[str],
63
+ ) -> tuple[int, str, str]: ...
64
+
65
+
66
+ class CliRunner(Protocol):
67
+ """Protocol describing the ``cli_runner`` fixture."""
68
+
69
+ def __call__(self, *cli_args: str | Sequence[str]) -> int: ...
70
+
71
+
72
+ class JsonFactory(Protocol):
73
+ """Protocol describing the :func:`json_file_factory` fixture."""
74
+
75
+ def __call__(
76
+ self,
77
+ payload: Any,
78
+ *,
79
+ filename: str | None = None,
80
+ ensure_ascii: bool = False,
81
+ ) -> Path: ...
82
+
83
+
84
+ class RequestFactory(Protocol):
85
+ """Protocol describing prepared-request factories."""
86
+
87
+ def __call__(
88
+ self,
89
+ url: str | None = None,
90
+ ) -> PreparedRequest: ...
91
+
92
+
93
+ # SECTION: FIXTURES ========================================================= #
94
+
95
+
96
+ @pytest.fixture(name='base_url')
97
+ def base_url_fixture() -> str:
98
+ """Return the canonical base URL shared across tests."""
99
+
100
+ return 'https://api.example.com'
101
+
102
+
103
+ @pytest.fixture(name='json_file_factory')
104
+ def json_file_factory_fixture(
105
+ tmp_path: Path,
106
+ ) -> JsonFactory:
107
+ """
108
+ Create JSON files under ``tmp_path`` and return their paths.
109
+
110
+ Parameters
111
+ ----------
112
+ tmp_path : Path
113
+ Temporary directory managed by pytest.
114
+
115
+ Returns
116
+ -------
117
+ JsonFactory
118
+ Factory that persists the provided payload as JSON and returns the
119
+ resulting path.
120
+
121
+ Examples
122
+ --------
123
+ >>> path = json_file_factory({'name': 'Ada'})
124
+ >>> path.exists()
125
+ True
126
+ """
127
+
128
+ def _create(
129
+ payload: Any,
130
+ *,
131
+ filename: str | None = None,
132
+ ensure_ascii: bool = False,
133
+ ) -> Path:
134
+ target = tmp_path / (filename or 'payload.json')
135
+ data = (
136
+ payload
137
+ if isinstance(payload, str)
138
+ else json.dumps(payload, indent=2, ensure_ascii=ensure_ascii)
139
+ )
140
+ target.write_text(data)
141
+ return target
142
+
143
+ return _create
144
+
145
+
146
+ @pytest.fixture(name='cli_runner')
147
+ def cli_runner_fixture(
148
+ monkeypatch: pytest.MonkeyPatch,
149
+ ) -> CliRunner:
150
+ """
151
+ Invoke ``etlplus`` CLI commands with isolated ``sys.argv`` state.
152
+
153
+ Parameters
154
+ ----------
155
+ monkeypatch : pytest.MonkeyPatch
156
+ Built-in pytest fixture used to patch ``sys.argv``.
157
+
158
+ Returns
159
+ -------
160
+ CliRunner
161
+ Helper that accepts CLI arguments, runs :func:`etlplus.cli.main`, and
162
+ returns the exit code.
163
+
164
+ Examples
165
+ --------
166
+ >>> cli_runner(('extract', 'file', 'data.json'))
167
+ 0
168
+ """
169
+
170
+ def _run(*cli_args: str | Sequence[str]) -> int:
171
+ args = _coerce_cli_args(cli_args)
172
+ monkeypatch.setattr(sys, 'argv', ['etlplus', *args])
173
+ return main()
174
+
175
+ return _run
176
+
177
+
178
+ @pytest.fixture
179
+ def cli_invoke(
180
+ cli_runner: CliRunner,
181
+ capsys: pytest.CaptureFixture[str],
182
+ ) -> CliInvoke:
183
+ """
184
+ Run CLI commands and return exit code, stdout, and stderr.
185
+
186
+ Parameters
187
+ ----------
188
+ cli_runner : CliRunner
189
+ Helper fixture defined above.
190
+ capsys : pytest.CaptureFixture[str]
191
+ Pytest fixture for capturing stdout/stderr.
192
+
193
+ Returns
194
+ -------
195
+ CliInvoke
196
+ Helper that yields ``(exit_code, stdout, stderr)`` tuples.
197
+
198
+ Examples
199
+ --------
200
+ >>> code, out, err = cli_invoke(('extract', 'file', 'data.json'))
201
+ >>> code
202
+ 0
203
+ """
204
+
205
+ def _invoke(*cli_args: str | Sequence[str]) -> tuple[int, str, str]:
206
+ exit_code = cli_runner(*cli_args)
207
+ captured = capsys.readouterr()
208
+ return exit_code, captured.out, captured.err
209
+
210
+ return _invoke
@@ -11,6 +11,7 @@ Notes
11
11
  from __future__ import annotations
12
12
 
13
13
  import importlib
14
+ import json
14
15
  import pathlib
15
16
  from collections.abc import Callable
16
17
  from typing import Any
@@ -34,6 +35,12 @@ from etlplus.config import PipelineConfig
34
35
  # SECTION: HELPERS ========================================================== #
35
36
 
36
37
 
38
+ pytestmark = pytest.mark.integration
39
+
40
+
41
+ # SECTION: HELPERS ========================================================== #
42
+
43
+
37
44
  # Directory-level marker for integration tests.
38
45
  pytestmark = pytest.mark.integration
39
46
 
@@ -55,8 +62,8 @@ class FakeEndpointClientProtocol(Protocol):
55
62
  # SECTION: FIXTURES ========================================================= #
56
63
 
57
64
 
58
- @pytest.fixture
59
- def capture_load_to_api(
65
+ @pytest.fixture(name='capture_load_to_api')
66
+ def capture_load_to_api_fixture(
60
67
  monkeypatch: pytest.MonkeyPatch,
61
68
  ) -> dict[str, Any]:
62
69
  """
@@ -112,8 +119,8 @@ def capture_load_to_api(
112
119
  return seen
113
120
 
114
121
 
115
- @pytest.fixture
116
- def fake_endpoint_client() -> tuple[
122
+ @pytest.fixture(name='fake_endpoint_client')
123
+ def fake_endpoint_client_fixture() -> tuple[
117
124
  type[FakeEndpointClientProtocol],
118
125
  list[FakeEndpointClientProtocol],
119
126
  ]: # noqa: ANN201
@@ -170,9 +177,83 @@ def fake_endpoint_client() -> tuple[
170
177
  return FakeClient, created
171
178
 
172
179
 
173
- @pytest.fixture
174
- def pipeline_cfg_factory(
180
+ @pytest.fixture(name='file_to_api_pipeline_factory')
181
+ def file_to_api_pipeline_factory_fixture(
182
+ tmp_path: pathlib.Path,
183
+ base_url: str,
184
+ ) -> Callable[..., PipelineConfig]:
185
+ """Build a pipeline wiring a JSON file source to an API target."""
186
+
187
+ def _make(
188
+ *,
189
+ payload: Any | None = None,
190
+ base_url: str = base_url,
191
+ base_path: str | None = '/v1',
192
+ endpoint_path: str = '/ingest',
193
+ endpoint_name: str = 'ingest',
194
+ method: str = 'post',
195
+ headers: dict[str, str] | None = None,
196
+ job_name: str = 'send',
197
+ target_name: str = 'ingest_out',
198
+ ) -> PipelineConfig:
199
+ source_path = tmp_path / f'{job_name}_input.json'
200
+ effective_payload = payload if payload is not None else {'ok': True}
201
+ text = (
202
+ effective_payload
203
+ if isinstance(effective_payload, str)
204
+ else json.dumps(effective_payload)
205
+ )
206
+ source_path.write_text(text, encoding='utf-8')
207
+
208
+ profile = ApiProfileConfig(
209
+ base_url=base_url,
210
+ headers={},
211
+ base_path=base_path or '',
212
+ auth={},
213
+ rate_limit_defaults=None,
214
+ pagination_defaults=None,
215
+ )
216
+ api = ApiConfig(
217
+ base_url=base_url,
218
+ profiles={'default': profile},
219
+ endpoints={endpoint_name: EndpointConfig(path=endpoint_path)},
220
+ )
221
+
222
+ src = ConnectorFile(
223
+ name='file_src',
224
+ type='file',
225
+ format='json',
226
+ path=str(source_path),
227
+ )
228
+ tgt = ConnectorApi(
229
+ name=target_name,
230
+ type='api',
231
+ api='svc',
232
+ endpoint=endpoint_name,
233
+ method=method,
234
+ headers=headers or {},
235
+ )
236
+
237
+ return PipelineConfig(
238
+ apis={'svc': api},
239
+ sources=[src],
240
+ targets=[tgt],
241
+ jobs=[
242
+ JobConfig(
243
+ name=job_name,
244
+ extract=ExtractRef(source='file_src'),
245
+ load=LoadRef(target=target_name),
246
+ ),
247
+ ],
248
+ )
249
+
250
+ return _make
251
+
252
+
253
+ @pytest.fixture(name='pipeline_cfg_factory')
254
+ def pipeline_cfg_factory_fixture(
175
255
  tmp_path: pathlib.Path,
256
+ base_url: str,
176
257
  ) -> Callable[..., PipelineConfig]:
177
258
  """
178
259
  Factory to build a minimal PipelineConfig for runner tests.
@@ -185,6 +266,8 @@ def pipeline_cfg_factory(
185
266
  ----------
186
267
  tmp_path : pathlib.Path
187
268
  The pytest temporary path fixture.
269
+ base_url : str
270
+ Common base URL used across tests.
188
271
 
189
272
  Returns
190
273
  -------
@@ -196,9 +279,10 @@ def pipeline_cfg_factory(
196
279
  *,
197
280
  pagination_defaults: PaginationConfig | None = None,
198
281
  rate_limit_defaults: RateLimitConfig | None = None,
282
+ extract_options: dict[str, Any] | None = None,
199
283
  ) -> PipelineConfig:
200
284
  prof = ApiProfileConfig(
201
- base_url='https://api.example.com',
285
+ base_url=base_url,
202
286
  headers={},
203
287
  base_path='/v1',
204
288
  auth={},
@@ -219,24 +303,29 @@ def pipeline_cfg_factory(
219
303
  format='json',
220
304
  path=str(out_path),
221
305
  )
306
+ job = JobConfig(
307
+ name='job',
308
+ extract=ExtractRef(source='s'),
309
+ load=LoadRef(target='t'),
310
+ )
311
+ if extract_options is not None:
312
+ if job.extract is None:
313
+ msg = 'job.extract is None; cannot set options'
314
+ raise ValueError(msg)
315
+ job.extract.options = extract_options
316
+
222
317
  return PipelineConfig(
223
318
  apis={'svc': api},
224
319
  sources=[src],
225
320
  targets=[tgt],
226
- jobs=[
227
- JobConfig(
228
- name='job',
229
- extract=ExtractRef(source='s'),
230
- load=LoadRef(target='t'),
231
- ),
232
- ],
321
+ jobs=[job],
233
322
  )
234
323
 
235
324
  return _make
236
325
 
237
326
 
238
- @pytest.fixture
239
- def run_patched(
327
+ @pytest.fixture(name='run_patched')
328
+ def run_patched_fixture(
240
329
  monkeypatch: pytest.MonkeyPatch,
241
330
  ) -> Callable[..., dict[str, Any]]:
242
331
  """