etlplus 0.3.5__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 (128) hide show
  1. etlplus-0.3.17/.coveragerc +21 -0
  2. etlplus-0.3.17/.github/actions/python-bootstrap/action.yml +42 -0
  3. etlplus-0.3.17/.github/workflows/ci.yml +160 -0
  4. {etlplus-0.3.5 → etlplus-0.3.17}/PKG-INFO +1 -1
  5. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus.egg-info/PKG-INFO +1 -1
  6. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus.egg-info/SOURCES.txt +6 -0
  7. etlplus-0.3.17/tests/conftest.py +210 -0
  8. {etlplus-0.3.5 → 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.5 → 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.5 → etlplus-0.3.17}/tests/integration/test_i_pipeline_smoke.py +41 -36
  13. {etlplus-0.3.5 → 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.5 → etlplus-0.3.17}/tests/integration/test_i_run_profile_pagination_defaults.py +11 -7
  16. {etlplus-0.3.5 → etlplus-0.3.17}/tests/integration/test_i_run_profile_rate_limit_defaults.py +6 -0
  17. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/conftest.py +42 -15
  18. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_auth.py +113 -123
  19. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_config.py +60 -16
  20. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_endpoint_client.py +447 -268
  21. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_pagination_client.py +5 -0
  22. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_pagination_config.py +5 -0
  23. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_paginator.py +5 -0
  24. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_rate_limit_config.py +5 -0
  25. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_rate_limiter.py +5 -0
  26. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_request_manager.py +3 -2
  27. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_retry_manager.py +6 -0
  28. {etlplus-0.3.5 → 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.5 → 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.5 → 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.5 → 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.5 → etlplus-0.3.17}/tests/unit/test_u_transform.py +80 -0
  41. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/test_u_utils.py +92 -3
  42. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/test_u_validate.py +39 -0
  43. etlplus-0.3.5/.github/workflows/ci.yml +0 -87
  44. etlplus-0.3.5/tests/conftest.py +0 -11
  45. etlplus-0.3.5/tests/integration/test_i_cli.py +0 -348
  46. etlplus-0.3.5/tests/integration/test_i_pagination_strategy.py +0 -452
  47. etlplus-0.3.5/tests/integration/test_i_run.py +0 -133
  48. etlplus-0.3.5/tests/unit/config/test_u_connector.py +0 -54
  49. etlplus-0.3.5/tests/unit/config/test_u_pipeline.py +0 -194
  50. etlplus-0.3.5/tests/unit/test_u_cli.py +0 -124
  51. etlplus-0.3.5/tests/unit/test_u_file.py +0 -100
  52. {etlplus-0.3.5 → etlplus-0.3.17}/.editorconfig +0 -0
  53. {etlplus-0.3.5 → etlplus-0.3.17}/.gitattributes +0 -0
  54. {etlplus-0.3.5 → etlplus-0.3.17}/.gitignore +0 -0
  55. {etlplus-0.3.5 → etlplus-0.3.17}/.pre-commit-config.yaml +0 -0
  56. {etlplus-0.3.5 → etlplus-0.3.17}/.ruff.toml +0 -0
  57. {etlplus-0.3.5 → etlplus-0.3.17}/CODE_OF_CONDUCT.md +0 -0
  58. {etlplus-0.3.5 → etlplus-0.3.17}/CONTRIBUTING.md +0 -0
  59. {etlplus-0.3.5 → etlplus-0.3.17}/DEMO.md +0 -0
  60. {etlplus-0.3.5 → etlplus-0.3.17}/LICENSE +0 -0
  61. {etlplus-0.3.5 → etlplus-0.3.17}/Makefile +0 -0
  62. {etlplus-0.3.5 → etlplus-0.3.17}/README.md +0 -0
  63. {etlplus-0.3.5 → etlplus-0.3.17}/REFERENCES.md +0 -0
  64. {etlplus-0.3.5 → etlplus-0.3.17}/docs/pipeline-guide.md +0 -0
  65. {etlplus-0.3.5 → etlplus-0.3.17}/docs/snippets/installation_version.md +0 -0
  66. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/__init__.py +0 -0
  67. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/__main__.py +0 -0
  68. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/__version__.py +0 -0
  69. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/README.md +0 -0
  70. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/__init__.py +0 -0
  71. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/auth.py +0 -0
  72. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/config.py +0 -0
  73. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/endpoint_client.py +0 -0
  74. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/errors.py +0 -0
  75. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/pagination/__init__.py +0 -0
  76. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/pagination/client.py +0 -0
  77. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/pagination/config.py +0 -0
  78. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/pagination/paginator.py +0 -0
  79. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/rate_limiting/__init__.py +0 -0
  80. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/rate_limiting/config.py +0 -0
  81. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  82. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/request_manager.py +0 -0
  83. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/retry_manager.py +0 -0
  84. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/transport.py +0 -0
  85. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/api/types.py +0 -0
  86. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/cli.py +0 -0
  87. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/config/__init__.py +0 -0
  88. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/config/connector.py +0 -0
  89. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/config/jobs.py +0 -0
  90. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/config/pipeline.py +0 -0
  91. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/config/profile.py +0 -0
  92. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/config/types.py +0 -0
  93. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/config/utils.py +0 -0
  94. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/enums.py +0 -0
  95. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/extract.py +0 -0
  96. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/file.py +0 -0
  97. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/load.py +0 -0
  98. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/mixins.py +0 -0
  99. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/py.typed +0 -0
  100. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/run.py +0 -0
  101. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/run_helpers.py +0 -0
  102. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/transform.py +0 -0
  103. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/types.py +0 -0
  104. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/utils.py +0 -0
  105. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/validate.py +0 -0
  106. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/validation/__init__.py +0 -0
  107. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus/validation/utils.py +0 -0
  108. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus.egg-info/dependency_links.txt +0 -0
  109. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus.egg-info/entry_points.txt +0 -0
  110. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus.egg-info/requires.txt +0 -0
  111. {etlplus-0.3.5 → etlplus-0.3.17}/etlplus.egg-info/top_level.txt +0 -0
  112. {etlplus-0.3.5 → etlplus-0.3.17}/examples/README.md +0 -0
  113. {etlplus-0.3.5 → etlplus-0.3.17}/examples/configs/pipeline.yml +0 -0
  114. {etlplus-0.3.5 → etlplus-0.3.17}/examples/data/sample.csv +0 -0
  115. {etlplus-0.3.5 → etlplus-0.3.17}/examples/data/sample.json +0 -0
  116. {etlplus-0.3.5 → etlplus-0.3.17}/examples/data/sample.xml +0 -0
  117. {etlplus-0.3.5 → etlplus-0.3.17}/examples/data/sample.xsd +0 -0
  118. {etlplus-0.3.5 → etlplus-0.3.17}/examples/data/sample.yaml +0 -0
  119. {etlplus-0.3.5 → etlplus-0.3.17}/examples/quickstart_python.py +0 -0
  120. {etlplus-0.3.5 → etlplus-0.3.17}/pyproject.toml +0 -0
  121. {etlplus-0.3.5 → etlplus-0.3.17}/pytest.ini +0 -0
  122. {etlplus-0.3.5 → etlplus-0.3.17}/setup.cfg +0 -0
  123. {etlplus-0.3.5 → etlplus-0.3.17}/setup.py +0 -0
  124. {etlplus-0.3.5 → etlplus-0.3.17}/tests/__init__.py +0 -0
  125. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/api/test_u_mocks.py +0 -0
  126. {etlplus-0.3.5 → etlplus-0.3.17}/tests/unit/validation/test_u_validation_utils.py +0 -0
  127. {etlplus-0.3.5 → etlplus-0.3.17}/tools/run_pipeline.py +0 -0
  128. {etlplus-0.3.5 → 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
@@ -0,0 +1,42 @@
1
+ # action.yml
2
+ # ETLPlus
3
+ #
4
+ # Copyright © 2025 Dagitali LLC. All rights reserved.
5
+ #
6
+ # A GitHub Actions action to set up Python and install Python package
7
+ # dependencies.
8
+ #
9
+ # Notes
10
+ # - External GitHub Actions action references are each pinned to specific SHA
11
+ # to limit supply-chain risk.
12
+
13
+ ---
14
+
15
+ name: Setup Python and Install Dependencies
16
+
17
+ description: >-
18
+ Sets up the requested Python runtime, upgrades pip, and installs the provided
19
+ dependency list to keep workflow jobs consistent.
20
+
21
+ inputs:
22
+ python-version:
23
+ description: Python version to install via actions/setup-python
24
+ required: true
25
+ python-bootstrap:
26
+ description: Arguments passed to "pip install"
27
+ required: true
28
+
29
+ runs:
30
+ using: composite
31
+ steps:
32
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # Pinned v5
33
+ with:
34
+ python-version: ${{ inputs.python-version }}
35
+
36
+ - name: Upgrade pip
37
+ shell: bash
38
+ run: python -m pip install --upgrade pip
39
+
40
+ - name: Install dependencies
41
+ shell: bash
42
+ run: pip install ${{ inputs.python-bootstrap }}
@@ -0,0 +1,160 @@
1
+ # ci.yml
2
+ # ETLPlus
3
+ #
4
+ # Copyright © 2025 Dagitali LLC. All rights reserved.
5
+ #
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.
17
+
18
+ ---
19
+
20
+ name: CI
21
+
22
+ on:
23
+ push:
24
+ branches:
25
+ # GitFlow branches
26
+ - main
27
+ - develop
28
+ - '**/feature/**'
29
+ - '**/bugfix/**'
30
+ - '**/release/**'
31
+ - '**/hotfix/**'
32
+
33
+ # Extended branches
34
+ - '**/chore/**'
35
+ - '**/ci/**'
36
+ - '**/docs/**'
37
+ tags: [ 'v*.*.*' ]
38
+ pull_request:
39
+ branches: [ main, develop ]
40
+
41
+ permissions: {}
42
+
43
+ jobs:
44
+ lint:
45
+ runs-on: ubuntu-latest
46
+ strategy: &python-matrix
47
+ matrix:
48
+ python-version: ['3.13', '3.14']
49
+ permissions: &permissions_read
50
+ contents: read
51
+ steps:
52
+ - &checkout_step
53
+ name: Checkout repository
54
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # Pinned v4
55
+ with:
56
+ fetch-depth: 0
57
+ - uses: ./.github/actions/python-bootstrap
58
+ with:
59
+ python-version: ${{ matrix.python-version }}
60
+ python-bootstrap: ".[dev]"
61
+ - name: Ruff check
62
+ run: |
63
+ ruff version
64
+ ruff check .
65
+ ruff format --check .
66
+
67
+ test:
68
+ runs-on: ubuntu-latest
69
+ strategy: *python-matrix
70
+ permissions: *permissions_read
71
+ steps:
72
+ - *checkout_step
73
+ - uses: ./.github/actions/python-bootstrap
74
+ with:
75
+ python-version: ${{ matrix.python-version }}
76
+ python-bootstrap: "-e .[dev,yaml]"
77
+ - name: Run tests (with coverage)
78
+ run: |
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
97
+
98
+ build:
99
+ name: Build distributions
100
+ runs-on: ubuntu-latest
101
+ if: &release_tag_condition >
102
+ startsWith(github.ref, 'refs/tags/v') ||
103
+ startsWith(github.ref, 'refs/tags/rc')
104
+ needs: [lint, test]
105
+ permissions: *permissions_read
106
+ steps:
107
+ - *checkout_step
108
+ - uses: ./.github/actions/python-bootstrap
109
+ with:
110
+ python-version: '3.13'
111
+ python-bootstrap: build
112
+ - name: Build distributions
113
+ run: |
114
+ python -m build
115
+ - name: Upload distributions
116
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # Pinned v4
117
+ with:
118
+ name: dist-artifacts
119
+ path: dist/*
120
+ if-no-files-found: error
121
+
122
+ release:
123
+ name: Publish GitHub Release
124
+ runs-on: ubuntu-latest
125
+ if: *release_tag_condition
126
+ needs: build
127
+ permissions:
128
+ contents: write
129
+ steps:
130
+ - *checkout_step
131
+ - &download_dist_step
132
+ name: Download distributions
133
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # Pinned v4
134
+ with:
135
+ name: dist-artifacts
136
+ path: dist
137
+ - name: Publish GitHub release
138
+ uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # Pinned v2
139
+ with:
140
+ files: dist/*
141
+ generate_release_notes: true
142
+
143
+ publish:
144
+ name: Publish to PyPI
145
+ runs-on: ubuntu-latest
146
+ if: *release_tag_condition
147
+ needs: build
148
+ permissions:
149
+ contents: read
150
+ id-token: write
151
+ environment:
152
+ name: pypi
153
+ url: https://pypi.org/project/etlplus/
154
+ steps:
155
+ - *checkout_step
156
+ - *download_dist_step
157
+ - name: Publish to PyPI
158
+ uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # Pinned v1
159
+ with:
160
+ verbose: true
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.3.5
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.5
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
@@ -13,6 +14,7 @@ REFERENCES.md
13
14
  pyproject.toml
14
15
  pytest.ini
15
16
  setup.py
17
+ .github/actions/python-bootstrap/action.yml
16
18
  .github/workflows/ci.yml
17
19
  docs/pipeline-guide.md
18
20
  docs/snippets/installation_version.md
@@ -85,9 +87,13 @@ tests/integration/test_i_run_profile_pagination_defaults.py
85
87
  tests/integration/test_i_run_profile_rate_limit_defaults.py
86
88
  tests/unit/conftest.py
87
89
  tests/unit/test_u_cli.py
90
+ tests/unit/test_u_enums.py
88
91
  tests/unit/test_u_extract.py
89
92
  tests/unit/test_u_file.py
90
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
91
97
  tests/unit/test_u_transform.py
92
98
  tests/unit/test_u_utils.py
93
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
  """