etlplus 0.10.1__tar.gz → 0.10.5__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 (151) hide show
  1. {etlplus-0.10.1/etlplus.egg-info → etlplus-0.10.5}/PKG-INFO +1 -1
  2. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/enums.py +37 -3
  3. {etlplus-0.10.1 → etlplus-0.10.5/etlplus.egg-info}/PKG-INFO +1 -1
  4. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus.egg-info/SOURCES.txt +1 -0
  5. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/cli/conftest.py +105 -4
  6. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/cli/test_u_cli_handlers.py +90 -370
  7. etlplus-0.10.5/tests/unit/cli/test_u_cli_io.py +326 -0
  8. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/cli/test_u_cli_main.py +59 -25
  9. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/cli/test_u_cli_state.py +45 -41
  10. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_enums.py +17 -9
  11. {etlplus-0.10.1 → etlplus-0.10.5}/.coveragerc +0 -0
  12. {etlplus-0.10.1 → etlplus-0.10.5}/.editorconfig +0 -0
  13. {etlplus-0.10.1 → etlplus-0.10.5}/.gitattributes +0 -0
  14. {etlplus-0.10.1 → etlplus-0.10.5}/.github/actions/python-bootstrap/action.yml +0 -0
  15. {etlplus-0.10.1 → etlplus-0.10.5}/.github/workflows/ci.yml +0 -0
  16. {etlplus-0.10.1 → etlplus-0.10.5}/.gitignore +0 -0
  17. {etlplus-0.10.1 → etlplus-0.10.5}/.pre-commit-config.yaml +0 -0
  18. {etlplus-0.10.1 → etlplus-0.10.5}/.ruff.toml +0 -0
  19. {etlplus-0.10.1 → etlplus-0.10.5}/CODE_OF_CONDUCT.md +0 -0
  20. {etlplus-0.10.1 → etlplus-0.10.5}/CONTRIBUTING.md +0 -0
  21. {etlplus-0.10.1 → etlplus-0.10.5}/DEMO.md +0 -0
  22. {etlplus-0.10.1 → etlplus-0.10.5}/LICENSE +0 -0
  23. {etlplus-0.10.1 → etlplus-0.10.5}/MANIFEST.in +0 -0
  24. {etlplus-0.10.1 → etlplus-0.10.5}/Makefile +0 -0
  25. {etlplus-0.10.1 → etlplus-0.10.5}/README.md +0 -0
  26. {etlplus-0.10.1 → etlplus-0.10.5}/REFERENCES.md +0 -0
  27. {etlplus-0.10.1 → etlplus-0.10.5}/docs/README.md +0 -0
  28. {etlplus-0.10.1 → etlplus-0.10.5}/docs/pipeline-guide.md +0 -0
  29. {etlplus-0.10.1 → etlplus-0.10.5}/docs/snippets/installation_version.md +0 -0
  30. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/__init__.py +0 -0
  31. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/__main__.py +0 -0
  32. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/__version__.py +0 -0
  33. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/README.md +0 -0
  34. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/__init__.py +0 -0
  35. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/auth.py +0 -0
  36. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/config.py +0 -0
  37. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/endpoint_client.py +0 -0
  38. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/errors.py +0 -0
  39. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/pagination/__init__.py +0 -0
  40. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/pagination/client.py +0 -0
  41. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/pagination/config.py +0 -0
  42. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/pagination/paginator.py +0 -0
  43. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/rate_limiting/__init__.py +0 -0
  44. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/rate_limiting/config.py +0 -0
  45. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/rate_limiting/rate_limiter.py +0 -0
  46. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/request_manager.py +0 -0
  47. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/retry_manager.py +0 -0
  48. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/transport.py +0 -0
  49. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/api/types.py +0 -0
  50. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/cli/__init__.py +0 -0
  51. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/cli/commands.py +0 -0
  52. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/cli/constants.py +0 -0
  53. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/cli/handlers.py +0 -0
  54. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/cli/io.py +0 -0
  55. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/cli/main.py +0 -0
  56. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/cli/options.py +0 -0
  57. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/cli/state.py +0 -0
  58. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/cli/types.py +0 -0
  59. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/config/__init__.py +0 -0
  60. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/config/connector.py +0 -0
  61. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/config/jobs.py +0 -0
  62. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/config/pipeline.py +0 -0
  63. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/config/profile.py +0 -0
  64. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/config/types.py +0 -0
  65. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/config/utils.py +0 -0
  66. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/database/__init__.py +0 -0
  67. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/database/ddl.py +0 -0
  68. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/database/engine.py +0 -0
  69. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/database/orm.py +0 -0
  70. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/database/schema.py +0 -0
  71. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/database/types.py +0 -0
  72. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/extract.py +0 -0
  73. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/file.py +0 -0
  74. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/load.py +0 -0
  75. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/mixins.py +0 -0
  76. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/py.typed +0 -0
  77. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/run.py +0 -0
  78. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/run_helpers.py +0 -0
  79. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/templates/__init__.py +0 -0
  80. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/templates/ddl.sql.j2 +0 -0
  81. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/templates/view.sql.j2 +0 -0
  82. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/transform.py +0 -0
  83. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/types.py +0 -0
  84. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/utils.py +0 -0
  85. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/validate.py +0 -0
  86. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/validation/__init__.py +0 -0
  87. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus/validation/utils.py +0 -0
  88. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus.egg-info/dependency_links.txt +0 -0
  89. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus.egg-info/entry_points.txt +0 -0
  90. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus.egg-info/requires.txt +0 -0
  91. {etlplus-0.10.1 → etlplus-0.10.5}/etlplus.egg-info/top_level.txt +0 -0
  92. {etlplus-0.10.1 → etlplus-0.10.5}/examples/README.md +0 -0
  93. {etlplus-0.10.1 → etlplus-0.10.5}/examples/configs/ddl_spec.yml +0 -0
  94. {etlplus-0.10.1 → etlplus-0.10.5}/examples/configs/pipeline.yml +0 -0
  95. {etlplus-0.10.1 → etlplus-0.10.5}/examples/data/sample.csv +0 -0
  96. {etlplus-0.10.1 → etlplus-0.10.5}/examples/data/sample.json +0 -0
  97. {etlplus-0.10.1 → etlplus-0.10.5}/examples/data/sample.xml +0 -0
  98. {etlplus-0.10.1 → etlplus-0.10.5}/examples/data/sample.xsd +0 -0
  99. {etlplus-0.10.1 → etlplus-0.10.5}/examples/data/sample.yaml +0 -0
  100. {etlplus-0.10.1 → etlplus-0.10.5}/examples/quickstart_python.py +0 -0
  101. {etlplus-0.10.1 → etlplus-0.10.5}/pyproject.toml +0 -0
  102. {etlplus-0.10.1 → etlplus-0.10.5}/pytest.ini +0 -0
  103. {etlplus-0.10.1 → etlplus-0.10.5}/setup.cfg +0 -0
  104. {etlplus-0.10.1 → etlplus-0.10.5}/setup.py +0 -0
  105. {etlplus-0.10.1 → etlplus-0.10.5}/tests/__init__.py +0 -0
  106. {etlplus-0.10.1 → etlplus-0.10.5}/tests/conftest.py +0 -0
  107. {etlplus-0.10.1 → etlplus-0.10.5}/tests/integration/conftest.py +0 -0
  108. {etlplus-0.10.1 → etlplus-0.10.5}/tests/integration/test_i_cli.py +0 -0
  109. {etlplus-0.10.1 → etlplus-0.10.5}/tests/integration/test_i_examples_data_parity.py +0 -0
  110. {etlplus-0.10.1 → etlplus-0.10.5}/tests/integration/test_i_pagination_strategy.py +0 -0
  111. {etlplus-0.10.1 → etlplus-0.10.5}/tests/integration/test_i_pipeline_smoke.py +0 -0
  112. {etlplus-0.10.1 → etlplus-0.10.5}/tests/integration/test_i_pipeline_yaml_load.py +0 -0
  113. {etlplus-0.10.1 → etlplus-0.10.5}/tests/integration/test_i_run.py +0 -0
  114. {etlplus-0.10.1 → etlplus-0.10.5}/tests/integration/test_i_run_profile_pagination_defaults.py +0 -0
  115. {etlplus-0.10.1 → etlplus-0.10.5}/tests/integration/test_i_run_profile_rate_limit_defaults.py +0 -0
  116. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/conftest.py +0 -0
  117. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_auth.py +0 -0
  118. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_config.py +0 -0
  119. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_endpoint_client.py +0 -0
  120. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_mocks.py +0 -0
  121. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_pagination_client.py +0 -0
  122. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_pagination_config.py +0 -0
  123. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_paginator.py +0 -0
  124. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_rate_limit_config.py +0 -0
  125. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_rate_limiter.py +0 -0
  126. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_request_manager.py +0 -0
  127. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_retry_manager.py +0 -0
  128. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_transport.py +0 -0
  129. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/api/test_u_types.py +0 -0
  130. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/config/test_u_config_utils.py +0 -0
  131. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/config/test_u_connector.py +0 -0
  132. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/config/test_u_jobs.py +0 -0
  133. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/config/test_u_pipeline.py +0 -0
  134. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/conftest.py +0 -0
  135. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/database/test_u_database_ddl.py +0 -0
  136. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/database/test_u_database_engine.py +0 -0
  137. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/database/test_u_database_orm.py +0 -0
  138. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/database/test_u_database_schema.py +0 -0
  139. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_extract.py +0 -0
  140. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_file.py +0 -0
  141. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_load.py +0 -0
  142. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_main.py +0 -0
  143. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_mixins.py +0 -0
  144. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_run.py +0 -0
  145. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_run_helpers.py +0 -0
  146. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_transform.py +0 -0
  147. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_utils.py +0 -0
  148. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_validate.py +0 -0
  149. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/test_u_version.py +0 -0
  150. {etlplus-0.10.1 → etlplus-0.10.5}/tests/unit/validation/test_u_validation_utils.py +0 -0
  151. {etlplus-0.10.1 → etlplus-0.10.5}/tools/update_demo_snippets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.10.1
3
+ Version: 0.10.5
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
@@ -300,12 +300,16 @@ class FileFormat(CoercibleStrEnum):
300
300
  '.yml': 'yaml',
301
301
  # MIME types
302
302
  'application/avro': 'avro',
303
+ 'application/csv': 'csv',
303
304
  'application/feather': 'feather',
304
305
  'application/gzip': 'gz',
305
306
  'application/json': 'json',
306
307
  'application/jsonlines': 'ndjson',
307
308
  'application/ndjson': 'ndjson',
308
309
  'application/orc': 'orc',
310
+ 'application/parquet': 'parquet',
311
+ 'application/vnd.apache.avro': 'avro',
312
+ 'application/vnd.apache.parquet': 'parquet',
309
313
  'application/vnd.apache.arrow.file': 'feather',
310
314
  'application/vnd.apache.orc': 'orc',
311
315
  'application/vnd.ms-excel': 'xls',
@@ -314,13 +318,20 @@ class FileFormat(CoercibleStrEnum):
314
318
  'officedocument.spreadsheetml.sheet'
315
319
  ): 'xlsx',
316
320
  'application/x-avro': 'avro',
321
+ 'application/x-csv': 'csv',
322
+ 'application/x-feather': 'feather',
323
+ 'application/x-orc': 'orc',
317
324
  'application/x-ndjson': 'ndjson',
318
325
  'application/x-parquet': 'parquet',
326
+ 'application/x-yaml': 'yaml',
319
327
  'application/xml': 'xml',
320
328
  'application/zip': 'zip',
321
329
  'text/csv': 'csv',
322
330
  'text/plain': 'txt',
323
331
  'text/tab-separated-values': 'tsv',
332
+ 'text/tsv': 'tsv',
333
+ 'text/xml': 'xml',
334
+ 'text/yaml': 'yaml',
324
335
  }
325
336
 
326
337
 
@@ -524,6 +535,7 @@ def coerce_http_method(
524
535
 
525
536
  def infer_file_format_and_compression(
526
537
  value: object,
538
+ filename: object | None = None,
527
539
  ) -> tuple[FileFormat | None, CompressionFormat | None]:
528
540
  """
529
541
  Infer data format and compression from a filename, extension, or MIME type.
@@ -532,6 +544,9 @@ def infer_file_format_and_compression(
532
544
  ----------
533
545
  value : object
534
546
  A filename, extension, MIME type, or existing enum member.
547
+ filename : object | None, optional
548
+ A filename to consult for extension-based inference (e.g. when
549
+ ``value`` is ``application/octet-stream``).
535
550
 
536
551
  Returns
537
552
  -------
@@ -552,10 +567,29 @@ def infer_file_format_and_compression(
552
567
  normalized = text.casefold()
553
568
  mime = normalized.split(';', 1)[0].strip()
554
569
 
570
+ is_octet_stream = mime == 'application/octet-stream'
555
571
  compression = CompressionFormat.try_coerce(mime)
556
- fmt = FileFormat.try_coerce(mime)
557
-
558
- suffixes = PurePath(text).suffixes
572
+ fmt = None if is_octet_stream else FileFormat.try_coerce(mime)
573
+
574
+ is_mime = mime.startswith(
575
+ (
576
+ 'application/',
577
+ 'text/',
578
+ 'audio/',
579
+ 'image/',
580
+ 'video/',
581
+ 'multipart/',
582
+ ),
583
+ )
584
+ suffix_source: object | None = filename if filename is not None else text
585
+ if is_mime and filename is None:
586
+ suffix_source = None
587
+
588
+ suffixes = (
589
+ PurePath(str(suffix_source)).suffixes
590
+ if suffix_source is not None
591
+ else []
592
+ )
559
593
  if suffixes:
560
594
  normalized_suffixes = [suffix.casefold() for suffix in suffixes]
561
595
  compression = (
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etlplus
3
- Version: 0.10.1
3
+ Version: 0.10.5
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
@@ -134,6 +134,7 @@ tests/unit/api/test_u_transport.py
134
134
  tests/unit/api/test_u_types.py
135
135
  tests/unit/cli/conftest.py
136
136
  tests/unit/cli/test_u_cli_handlers.py
137
+ tests/unit/cli/test_u_cli_io.py
137
138
  tests/unit/cli/test_u_cli_main.py
138
139
  tests/unit/cli/test_u_cli_state.py
139
140
  tests/unit/config/test_u_config_utils.py
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import json
15
15
  import types
16
16
  from collections.abc import Callable
17
+ from collections.abc import Mapping
17
18
  from dataclasses import dataclass
18
19
  from dataclasses import field
19
20
  from pathlib import Path
@@ -33,6 +34,84 @@ from etlplus.config import PipelineConfig
33
34
 
34
35
  CSV_TEXT: Final[str] = 'a,b\n1,2\n3,4\n'
35
36
 
37
+ type CaptureHandler = Callable[[object, str], dict[str, object]]
38
+ type CaptureIo = dict[str, list[tuple[tuple[object, ...], dict[str, object]]]]
39
+ type InvokeCli = Callable[..., Result]
40
+ type StubCommand = Callable[[Callable[..., object]], None]
41
+
42
+
43
+ def assert_emit_json(
44
+ calls: CaptureIo,
45
+ payload: object,
46
+ *,
47
+ pretty: bool,
48
+ ) -> None:
49
+ """
50
+ Assert that :func:`emit_json` was called once with expected payload.
51
+
52
+ Parameters
53
+ ----------
54
+ calls : CaptureIo
55
+ Captured IO calls from the CLI fixtures.
56
+ payload : object
57
+ Expected JSON payload.
58
+ pretty : bool
59
+ Expected pretty-print flag.
60
+ """
61
+ assert calls['emit_json'] == [((payload,), {'pretty': pretty})]
62
+
63
+
64
+ def assert_emit_or_write(
65
+ calls: CaptureIo,
66
+ payload: object,
67
+ target: object,
68
+ *,
69
+ pretty: bool,
70
+ ) -> dict[str, object]:
71
+ """
72
+ Assert that :func:`emit_or_write` was called once and return kwargs.
73
+
74
+ Parameters
75
+ ----------
76
+ calls : CaptureIo
77
+ Captured IO calls from the CLI fixtures.
78
+ payload : object
79
+ Expected payload argument.
80
+ target : object
81
+ Expected output path argument.
82
+ pretty : bool
83
+ Expected pretty-print flag.
84
+
85
+ Returns
86
+ -------
87
+ dict[str, object]
88
+ Captured keyword arguments for the call.
89
+ """
90
+ assert len(calls['emit_or_write']) == 1
91
+ args, kwargs = calls['emit_or_write'][0]
92
+ assert args[0] == payload
93
+ assert args[1] == target
94
+ assert kwargs['pretty'] is pretty
95
+ return kwargs
96
+
97
+
98
+ def assert_mapping_contains(
99
+ actual: Mapping[str, object],
100
+ expected: Mapping[str, object],
101
+ ) -> None:
102
+ """
103
+ Assert that ``actual`` contains the ``expected`` key/value pairs.
104
+
105
+ Parameters
106
+ ----------
107
+ actual : Mapping[str, object]
108
+ Mapping returned by the handler capture fixture.
109
+ expected : Mapping[str, object]
110
+ Expected key/value pairs that must be present in ``actual``.
111
+ """
112
+ for key, value in expected.items():
113
+ assert actual[key] == value
114
+
36
115
 
37
116
  @dataclass(frozen=True, slots=True)
38
117
  class DummyCfg:
@@ -60,8 +139,20 @@ class DummyCfg:
60
139
  @pytest.fixture(name='capture_handler')
61
140
  def capture_handler_fixture(
62
141
  monkeypatch: pytest.MonkeyPatch,
63
- ) -> Callable[[object, str], dict[str, object]]:
64
- """Patch a handler function and capture the kwargs it receives."""
142
+ ) -> CaptureHandler:
143
+ """
144
+ Patch a handler function and capture the kwargs it receives.
145
+
146
+ Parameters
147
+ ----------
148
+ monkeypatch : pytest.MonkeyPatch
149
+ Pytest monkeypatch fixture.
150
+
151
+ Returns
152
+ -------
153
+ CaptureHandler
154
+ Callable that records handler keyword arguments.
155
+ """
65
156
 
66
157
  def _capture(module: object, attr: str) -> dict[str, object]:
67
158
  calls: dict[str, object] = {}
@@ -77,14 +168,24 @@ def capture_handler_fixture(
77
168
 
78
169
 
79
170
  @pytest.fixture(name='capture_io')
80
- def capture_io_fixture(monkeypatch: pytest.MonkeyPatch):
171
+ def capture_io_fixture(monkeypatch: pytest.MonkeyPatch) -> CaptureIo:
81
172
  """
82
173
  Patch handler functions and capture CLI output.
83
174
  Returns a dict with lists of call args for each function.
175
+
176
+ Parameters
177
+ ----------
178
+ monkeypatch : pytest.MonkeyPatch
179
+ Pytest monkeypatch fixture.
180
+
181
+ Returns
182
+ -------
183
+ CaptureIo
184
+ Mapping of captured IO call arguments by function name.
84
185
  """
85
186
  import etlplus.cli.io as _io
86
187
 
87
- calls: dict[str, list] = {
188
+ calls: CaptureIo = {
88
189
  'emit_or_write': [],
89
190
  'emit_json': [],
90
191
  'print_json': [],