dtex 0.2.0__tar.gz → 0.2.2__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 (138) hide show
  1. {dtex-0.2.0 → dtex-0.2.2}/PKG-INFO +21 -2
  2. {dtex-0.2.0 → dtex-0.2.2}/README.md +20 -1
  3. {dtex-0.2.0 → dtex-0.2.2}/docs/03-connector-contract.md +1 -0
  4. {dtex-0.2.0 → dtex-0.2.2}/dtex/__init__.py +4 -0
  5. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/bigquery/client.py +67 -8
  6. {dtex-0.2.0 → dtex-0.2.2}/dtex/engine/runner.py +1 -0
  7. {dtex-0.2.0 → dtex-0.2.2}/dtex/registry.py +13 -2
  8. {dtex-0.2.0 → dtex-0.2.2}/dtex/skills/dtex-write-connector.md +74 -2
  9. dtex-0.2.2/dtex/sources/stripe/README.md +188 -0
  10. dtex-0.2.2/dtex/sources/stripe/queries/charges_daily.sql +24 -0
  11. dtex-0.2.2/dtex/sources/stripe/queries/invoices_paid.sql +21 -0
  12. dtex-0.2.2/dtex/sources/stripe/queries/subscriptions_active.sql +17 -0
  13. dtex-0.2.2/dtex/sources/stripe/register.yaml +322 -0
  14. dtex-0.2.2/dtex/sources/stripe/sigma_client.py +389 -0
  15. dtex-0.2.2/dtex/sources/stripe/source.py +439 -0
  16. {dtex-0.2.0 → dtex-0.2.2}/dtex/types.py +60 -0
  17. {dtex-0.2.0 → dtex-0.2.2}/pyproject.toml +1 -1
  18. {dtex-0.2.0 → dtex-0.2.2}/tests/connectors/test_stripe.py +229 -1
  19. {dtex-0.2.0 → dtex-0.2.2}/tests/destinations/test_bigquery.py +73 -0
  20. {dtex-0.2.0 → dtex-0.2.2}/tests/test_registry.py +3 -1
  21. {dtex-0.2.0 → dtex-0.2.2}/tests/test_types.py +42 -0
  22. dtex-0.2.0/dtex/sources/stripe/README.md +0 -128
  23. dtex-0.2.0/dtex/sources/stripe/register.yaml +0 -206
  24. dtex-0.2.0/dtex/sources/stripe/source.py +0 -222
  25. {dtex-0.2.0 → dtex-0.2.2}/.gitignore +0 -0
  26. {dtex-0.2.0 → dtex-0.2.2}/CONTRIBUTING.md +0 -0
  27. {dtex-0.2.0 → dtex-0.2.2}/LICENSE +0 -0
  28. {dtex-0.2.0 → dtex-0.2.2}/docs/00-vision-and-naming.md +0 -0
  29. {dtex-0.2.0 → dtex-0.2.2}/docs/01-landscape-and-comparison.md +0 -0
  30. {dtex-0.2.0 → dtex-0.2.2}/docs/02-architecture.md +0 -0
  31. {dtex-0.2.0 → dtex-0.2.2}/docs/04-connector-body.md +0 -0
  32. {dtex-0.2.0 → dtex-0.2.2}/docs/05-destinations-and-state.md +0 -0
  33. {dtex-0.2.0 → dtex-0.2.2}/docs/06-project-anatomy.md +0 -0
  34. {dtex-0.2.0 → dtex-0.2.2}/docs/07-cli-and-library-api.md +0 -0
  35. {dtex-0.2.0 → dtex-0.2.2}/docs/08-security.md +0 -0
  36. {dtex-0.2.0 → dtex-0.2.2}/docs/09-logging-and-observability.md +0 -0
  37. {dtex-0.2.0 → dtex-0.2.2}/docs/10-roadmap-and-scope.md +0 -0
  38. {dtex-0.2.0 → dtex-0.2.2}/docs/11-open-questions.md +0 -0
  39. {dtex-0.2.0 → dtex-0.2.2}/docs/12-configs.md +0 -0
  40. {dtex-0.2.0 → dtex-0.2.2}/docs/README.md +0 -0
  41. {dtex-0.2.0 → dtex-0.2.2}/docs/_archive/stripe-research-2026-05.md +0 -0
  42. {dtex-0.2.0 → dtex-0.2.2}/docs/_internal/release.md +0 -0
  43. {dtex-0.2.0 → dtex-0.2.2}/docs/_internal/streams-redesign-plan.md +0 -0
  44. {dtex-0.2.0 → dtex-0.2.2}/dtex/cli/__init__.py +0 -0
  45. {dtex-0.2.0 → dtex-0.2.2}/dtex/cli/_discovery.py +0 -0
  46. {dtex-0.2.0 → dtex-0.2.2}/dtex/cli/_format.py +0 -0
  47. {dtex-0.2.0 → dtex-0.2.2}/dtex/cli/_runs.py +0 -0
  48. {dtex-0.2.0 → dtex-0.2.2}/dtex/cli/_scaffold.py +0 -0
  49. {dtex-0.2.0 → dtex-0.2.2}/dtex/cli/_secrets.py +0 -0
  50. {dtex-0.2.0 → dtex-0.2.2}/dtex/cli/_skills.py +0 -0
  51. {dtex-0.2.0 → dtex-0.2.2}/dtex/cli/_state.py +0 -0
  52. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/__init__.py +0 -0
  53. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/bigquery/README.md +0 -0
  54. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/bigquery/__init__.py +0 -0
  55. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/bigquery/ddl.py +0 -0
  56. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/bigquery/destination.py +0 -0
  57. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/bigquery/register.yaml +0 -0
  58. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/duckdb/__init__.py +0 -0
  59. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/duckdb/ddl.py +0 -0
  60. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/duckdb/destination.py +0 -0
  61. {dtex-0.2.0 → dtex-0.2.2}/dtex/destinations/duckdb/register.yaml +0 -0
  62. {dtex-0.2.0 → dtex-0.2.2}/dtex/engine/__init__.py +0 -0
  63. {dtex-0.2.0 → dtex-0.2.2}/dtex/engine/config.py +0 -0
  64. {dtex-0.2.0 → dtex-0.2.2}/dtex/engine/configs.py +0 -0
  65. {dtex-0.2.0 → dtex-0.2.2}/dtex/engine/discovery.py +0 -0
  66. {dtex-0.2.0 → dtex-0.2.2}/dtex/engine/logger.py +0 -0
  67. {dtex-0.2.0 → dtex-0.2.2}/dtex/engine/normalize.py +0 -0
  68. {dtex-0.2.0 → dtex-0.2.2}/dtex/py.typed +0 -0
  69. {dtex-0.2.0 → dtex-0.2.2}/dtex/secrets/__init__.py +0 -0
  70. {dtex-0.2.0 → dtex-0.2.2}/dtex/secrets/_aws.py +0 -0
  71. {dtex-0.2.0 → dtex-0.2.2}/dtex/secrets/_gcp.py +0 -0
  72. {dtex-0.2.0 → dtex-0.2.2}/dtex/secrets/_vault.py +0 -0
  73. {dtex-0.2.0 → dtex-0.2.2}/dtex/secrets/resolvers.py +0 -0
  74. {dtex-0.2.0 → dtex-0.2.2}/dtex/skills/dtex-debug.md +0 -0
  75. {dtex-0.2.0 → dtex-0.2.2}/dtex/skills/dtex-write-config.md +0 -0
  76. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/__init__.py +0 -0
  77. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/filesystem/README.md +0 -0
  78. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/filesystem/__init__.py +0 -0
  79. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/filesystem/backends.py +0 -0
  80. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/filesystem/readers.py +0 -0
  81. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/filesystem/register.yaml +0 -0
  82. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/filesystem/source.py +0 -0
  83. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/postgres/README.md +0 -0
  84. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/postgres/__init__.py +0 -0
  85. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/postgres/client.py +0 -0
  86. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/postgres/register.yaml +0 -0
  87. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/postgres/source.py +0 -0
  88. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/postgres/type_mapping.py +0 -0
  89. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/rest/README.md +0 -0
  90. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/rest/__init__.py +0 -0
  91. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/rest/client.py +0 -0
  92. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/rest/extractors.py +0 -0
  93. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/rest/pagination.py +0 -0
  94. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/rest/register.yaml +0 -0
  95. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/rest/source.py +0 -0
  96. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/shiphero/README.md +0 -0
  97. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/shiphero/__init__.py +0 -0
  98. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/shiphero/client.py +0 -0
  99. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/shiphero/pagination.py +0 -0
  100. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/shiphero/queries.py +0 -0
  101. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/shiphero/register.yaml +0 -0
  102. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/shiphero/source.py +0 -0
  103. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/shiphero/windows.py +0 -0
  104. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/stripe/__init__.py +0 -0
  105. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/stripe/client.py +0 -0
  106. {dtex-0.2.0 → dtex-0.2.2}/dtex/sources/stripe/pagination.py +0 -0
  107. {dtex-0.2.0 → dtex-0.2.2}/tests/__init__.py +0 -0
  108. {dtex-0.2.0 → dtex-0.2.2}/tests/conftest.py +0 -0
  109. {dtex-0.2.0 → dtex-0.2.2}/tests/connectors/__init__.py +0 -0
  110. {dtex-0.2.0 → dtex-0.2.2}/tests/connectors/test_filesystem.py +0 -0
  111. {dtex-0.2.0 → dtex-0.2.2}/tests/connectors/test_postgres.py +0 -0
  112. {dtex-0.2.0 → dtex-0.2.2}/tests/connectors/test_rest.py +0 -0
  113. {dtex-0.2.0 → dtex-0.2.2}/tests/connectors/test_shiphero.py +0 -0
  114. {dtex-0.2.0 → dtex-0.2.2}/tests/destinations/__init__.py +0 -0
  115. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/configs/echo.yml +0 -0
  116. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/destinations/lockedfake/destination.py +0 -0
  117. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/destinations/lockedfake/register.yaml +0 -0
  118. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/dtex_project.yml +0 -0
  119. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/profiles.yml +0 -0
  120. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/sources/echo/register.yaml +0 -0
  121. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/sources/echo/source.py +0 -0
  122. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/sources/multifile_echo/__init__.py +0 -0
  123. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/sources/multifile_echo/helper.py +0 -0
  124. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/sources/multifile_echo/register.yaml +0 -0
  125. {dtex-0.2.0 → dtex-0.2.2}/tests/fixtures/sources/multifile_echo/source.py +0 -0
  126. {dtex-0.2.0 → dtex-0.2.2}/tests/test_cli.py +0 -0
  127. {dtex-0.2.0 → dtex-0.2.2}/tests/test_configs.py +0 -0
  128. {dtex-0.2.0 → dtex-0.2.2}/tests/test_duckdb_destination.py +0 -0
  129. {dtex-0.2.0 → dtex-0.2.2}/tests/test_engine.py +0 -0
  130. {dtex-0.2.0 → dtex-0.2.2}/tests/test_normalize.py +0 -0
  131. {dtex-0.2.0 → dtex-0.2.2}/tests/test_run_records.py +0 -0
  132. {dtex-0.2.0 → dtex-0.2.2}/tests/test_secret_resolver_aws.py +0 -0
  133. {dtex-0.2.0 → dtex-0.2.2}/tests/test_secret_resolver_gcp.py +0 -0
  134. {dtex-0.2.0 → dtex-0.2.2}/tests/test_secret_resolver_vault.py +0 -0
  135. {dtex-0.2.0 → dtex-0.2.2}/tests/test_secret_resolvers.py +0 -0
  136. {dtex-0.2.0 → dtex-0.2.2}/tests/test_skeleton.py +0 -0
  137. {dtex-0.2.0 → dtex-0.2.2}/tests/test_skills_install.py +0 -0
  138. {dtex-0.2.0 → dtex-0.2.2}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dtex
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: dtex (data extraction tool) — an open-source Python EL tool: pipelines as configs, connectors as folders, CLI-first.
5
5
  Project-URL: Homepage, https://github.com/vej-ai/dtex
6
6
  Project-URL: Documentation, https://github.com/vej-ai/dtex/tree/main/docs
@@ -104,7 +104,7 @@ params. Run it with `dtex run -p <config>`. The library equivalent is
104
104
  **Sources:** `filesystem` (CSV/JSONL/Parquet from local, GCS, or S3),
105
105
  `rest` (paginated REST APIs — 4 pagination strategies, 4 auth modes),
106
106
  `postgres` (keyset pagination, no `OFFSET`), `shiphero` (GraphQL),
107
- `stripe` (resource-as-stream over the REST API).
107
+ `stripe` (REST resource-as-stream + opt-in Sigma SQL-as-stream).
108
108
 
109
109
  **Destinations:** `duckdb` (zero-config dev default, all 5 capabilities) and
110
110
  `bigquery` (production warehouse — Parquet-staged via GCS + LOAD jobs,
@@ -119,6 +119,25 @@ per-destination caps.
119
119
  **Secret managers:** GCP Secret Manager, AWS Secrets Manager, HashiCorp
120
120
  Vault — each as an opt-in extra.
121
121
 
122
+ ## Bundled Claude skills
123
+
124
+ dtex ships three [Claude](https://claude.com) skills inside the wheel:
125
+ `dtex-write-config`, `dtex-write-connector`, `dtex-debug`. They teach
126
+ Claude (or any other agent runtime that reads `.claude/skills/`) the
127
+ post-streams-redesign config schema, the `@stream` decorator pattern, and
128
+ the debugging playbook. Install them into a dtex project with:
129
+
130
+ ```sh
131
+ dtex skills install # copies into ./.claude/skills/dtex/
132
+ ```
133
+
134
+ The first `dtex` command run inside a project that lacks installed skills
135
+ prints a one-line hint (suppressed thereafter). Run `dtex skills list` to
136
+ see which ship with this dtex version and which are installed locally.
137
+
138
+ If you're not using Claude, just ignore the hint — the skills are inert
139
+ markdown until installed.
140
+
122
141
  ## Documentation
123
142
 
124
143
  The full design handbook lives in [`docs/`](https://github.com/vej-ai/dtex/tree/main/docs).
@@ -54,7 +54,7 @@ params. Run it with `dtex run -p <config>`. The library equivalent is
54
54
  **Sources:** `filesystem` (CSV/JSONL/Parquet from local, GCS, or S3),
55
55
  `rest` (paginated REST APIs — 4 pagination strategies, 4 auth modes),
56
56
  `postgres` (keyset pagination, no `OFFSET`), `shiphero` (GraphQL),
57
- `stripe` (resource-as-stream over the REST API).
57
+ `stripe` (REST resource-as-stream + opt-in Sigma SQL-as-stream).
58
58
 
59
59
  **Destinations:** `duckdb` (zero-config dev default, all 5 capabilities) and
60
60
  `bigquery` (production warehouse — Parquet-staged via GCS + LOAD jobs,
@@ -69,6 +69,25 @@ per-destination caps.
69
69
  **Secret managers:** GCP Secret Manager, AWS Secrets Manager, HashiCorp
70
70
  Vault — each as an opt-in extra.
71
71
 
72
+ ## Bundled Claude skills
73
+
74
+ dtex ships three [Claude](https://claude.com) skills inside the wheel:
75
+ `dtex-write-config`, `dtex-write-connector`, `dtex-debug`. They teach
76
+ Claude (or any other agent runtime that reads `.claude/skills/`) the
77
+ post-streams-redesign config schema, the `@stream` decorator pattern, and
78
+ the debugging playbook. Install them into a dtex project with:
79
+
80
+ ```sh
81
+ dtex skills install # copies into ./.claude/skills/dtex/
82
+ ```
83
+
84
+ The first `dtex` command run inside a project that lacks installed skills
85
+ prints a one-line hint (suppressed thereafter). Run `dtex skills list` to
86
+ see which ship with this dtex version and which are installed locally.
87
+
88
+ If you're not using Claude, just ignore the hint — the skills are inert
89
+ markdown until installed.
90
+
72
91
  ## Documentation
73
92
 
74
93
  The full design handbook lives in [`docs/`](https://github.com/vej-ai/dtex/tree/main/docs).
@@ -420,6 +420,7 @@ objects the function asks for. A function that needs nothing extra can be
420
420
  | `state` | `State` | Per-stream persisted state (chapter 04). Free-form key/value scratch space that survives between runs. |
421
421
  | `cursor` | `Cursor` | Incremental cursor helper, present only if the stream declares an `incremental` block. See §3.2. |
422
422
  | `log` | `Logger` | Structured logger. `log.info(...)`, `log.warning(...)`. Correlation ids attached by the engine. |
423
+ | `stream_def` | `StreamDef` | The parsed `register.yaml` entry for this stream. Lets a connector introspect its own declaration — e.g. the baked `stripe` connector reads `stream_def.sigma.query` to decide which SQL file to submit for Sigma-backed streams. Use when one connector serves more than one extraction surface and the surface is declared in the manifest. |
423
424
 
424
425
  #### What a `@stream` function yields
425
426
 
@@ -70,9 +70,11 @@ from dtex.types import (
70
70
  RunStatus,
71
71
  Schema,
72
72
  SchemaContract,
73
+ SigmaConfig,
73
74
  State,
74
75
  StateBackend,
75
76
  StateRecord,
77
+ StreamDef,
76
78
  StreamMeta,
77
79
  StreamMode,
78
80
  StreamResult,
@@ -136,9 +138,11 @@ __all__ = [
136
138
  "RunStatus",
137
139
  "Schema",
138
140
  "SchemaContract",
141
+ "SigmaConfig",
139
142
  "State",
140
143
  "StateBackend",
141
144
  "StateRecord",
145
+ "StreamDef",
142
146
  "StreamMode",
143
147
  "StreamRunConfig",
144
148
  "StreamMeta",
@@ -139,6 +139,56 @@ def _status_code(exc: BaseException) -> int | None:
139
139
  return None
140
140
 
141
141
 
142
+ def _is_retryable_network_error(exc: BaseException) -> bool:
143
+ """Whether ``exc`` is a transient network-layer failure worth retrying.
144
+
145
+ Long-running runs (a backfill against a high-row-count source like
146
+ RevenueCat that involves hundreds of sequential LOAD jobs) routinely
147
+ hit stale-TCP-socket failures: a keep-alive connection held open by
148
+ the google-cloud-bigquery client gets killed by an intermediary,
149
+ and the next reuse raises a ``requests.exceptions.ConnectionError``
150
+ (typically wrapping ``http.client.RemoteDisconnected``). These have
151
+ NO HTTP status code (the connection died before any response
152
+ arrived), so the status-code path in :func:`run_with_retries` would
153
+ re-raise them immediately. This helper recognises them so the
154
+ retry loop covers them.
155
+
156
+ Genuine programming errors (``KeyError``, ``TypeError``, etc.) are
157
+ NOT matched — they should surface immediately, not get masked under
158
+ a few backoff cycles. The match is by concrete network-exception
159
+ type, not "any statusless exception."
160
+ """
161
+ # requests-level connection/timeout failures (ConnectionError /
162
+ # Timeout / ConnectTimeout / ReadTimeout / ChunkedEncodingError /
163
+ # ProxyError / SSLError — all RequestException subclasses).
164
+ import requests # noqa: PLC0415 — narrow runtime import
165
+
166
+ if isinstance(
167
+ exc,
168
+ (
169
+ requests.exceptions.ConnectionError,
170
+ requests.exceptions.Timeout,
171
+ requests.exceptions.ChunkedEncodingError,
172
+ ),
173
+ ):
174
+ return True
175
+
176
+ # google-api-core wraps some of the above under its own
177
+ # ServiceUnavailable / RetryError. These usually DO carry a 503
178
+ # status code so the existing path catches them, but RetryError
179
+ # (raised when google's own internal retry exhausts) sometimes
180
+ # surfaces without one.
181
+ try:
182
+ from google.api_core import exceptions as gapic_exc # noqa: PLC0415
183
+
184
+ if isinstance(exc, gapic_exc.RetryError):
185
+ return True
186
+ except ImportError: # pragma: no cover — google-api-core is installed
187
+ pass
188
+
189
+ return False
190
+
191
+
142
192
  def run_with_retries(
143
193
  operation: Any,
144
194
  *,
@@ -146,14 +196,16 @@ def run_with_retries(
146
196
  backoff_seconds: float,
147
197
  sleep: Any = time.sleep,
148
198
  ) -> Any:
149
- """Run ``operation()`` with exponential backoff on transient BigQuery errors.
199
+ """Run ``operation()`` with exponential backoff on transient failures.
150
200
 
151
201
  ``operation`` is a zero-arg callable — typically a ``lambda: job.result(
152
202
  timeout=...)`` or a ``lambda: client.load_table_from_uri(...).result(
153
- timeout=...)``. A retryable failure (status in :data:`_RETRYABLE_STATUS`)
154
- sleeps ``backoff_seconds * 2**attempt`` and retries; a non-retryable
155
- failure re-raises immediately. The ``sleep`` parameter is injectable so
156
- unit tests can avoid real wall-clock delays.
203
+ timeout=...)``. A retryable failure (status in :data:`_RETRYABLE_STATUS`
204
+ OR a connection-class network error matched by
205
+ :func:`_is_retryable_network_error`) sleeps ``backoff_seconds * 2**attempt``
206
+ and retries; a non-retryable failure re-raises immediately. The
207
+ ``sleep`` parameter is injectable so unit tests can avoid real
208
+ wall-clock delays.
157
209
  """
158
210
  if max_attempts < 1:
159
211
  max_attempts = 1
@@ -163,16 +215,23 @@ def run_with_retries(
163
215
  return operation()
164
216
  except Exception as exc: # noqa: BLE001 — re-raises non-retryable below
165
217
  status = _status_code(exc)
166
- if status is None or status not in _RETRYABLE_STATUS:
218
+ retryable_by_status = status is not None and status in _RETRYABLE_STATUS
219
+ retryable_by_network = _is_retryable_network_error(exc)
220
+ if not (retryable_by_status or retryable_by_network):
167
221
  raise
168
222
  last_exc = exc
169
223
  if attempt == max_attempts - 1:
170
224
  raise
171
225
  delay = backoff_seconds * (2 ** attempt)
226
+ reason = (
227
+ f"status={status}"
228
+ if retryable_by_status
229
+ else f"network={type(exc).__name__}"
230
+ )
172
231
  logger.warning(
173
- "BigQuery transient error (status=%s, attempt=%d/%d): %s — "
232
+ "BigQuery transient error (%s, attempt=%d/%d): %s — "
174
233
  "retrying in %.1fs",
175
- status,
234
+ reason,
176
235
  attempt + 1,
177
236
  max_attempts,
178
237
  exc,
@@ -585,6 +585,7 @@ def _run_one_stream(
585
585
  "config": stream_config,
586
586
  "state": state,
587
587
  "log": log,
588
+ "stream_def": stream_def,
588
589
  }
589
590
  if cursor is not None:
590
591
  available["cursor"] = cursor
@@ -102,12 +102,23 @@ __all__ = [
102
102
  # Injection — the parameter names the engine may inject (docs/03 §3.1)
103
103
  # ---------------------------------------------------------------------------
104
104
 
105
- STREAM_INJECTABLES: frozenset[str] = frozenset({"config", "state", "cursor", "log"})
105
+ STREAM_INJECTABLES: frozenset[str] = frozenset(
106
+ {"config", "state", "cursor", "log", "stream_def"}
107
+ )
106
108
  """The parameter names the engine injects into a ``@stream`` function — docs/03 §3.1.
107
109
 
108
110
  The engine inspects the function signature and injects, *by name*, only the
109
111
  objects the function declares. A ``@stream`` function may declare any subset of
110
- these four; declaring anything else is a discovery-time error (docs/03 §7 rule 8).
112
+ these; declaring anything else is a discovery-time error (docs/03 §7 rule 8).
113
+
114
+ * ``config`` — resolved :class:`dtex.Config` (params + secrets, per-stream layered).
115
+ * ``state`` — the per-stream state blob (a :class:`dtex.State` proxy).
116
+ * ``cursor`` — the per-stream :class:`dtex.Cursor` (incremental streams only).
117
+ * ``log`` — a standard logging-style logger pre-configured with run-correlation.
118
+ * ``stream_def`` — the parsed :class:`dtex.StreamDef` declaration for this
119
+ stream (its own ``register.yaml`` entry). Lets a connector introspect its
120
+ own metadata — e.g. the ``stripe`` connector dispatches on
121
+ ``stream_def.sigma`` to choose REST-vs-Sigma extraction per stream.
111
122
  """
112
123
 
113
124
  # NOTE: docs/03 §4 says a ``@stream_method`` follows "the same injection rules
@@ -131,8 +131,8 @@ def customers(config: Config, state: State, cursor: Cursor, log) -> Iterator[Bat
131
131
  ### Injected arguments
132
132
 
133
133
  The engine declares which arguments it can inject — `config`, `state`,
134
- `cursor`, `log` — and supplies only the ones the function names. List
135
- only what you use; the engine matches by name.
134
+ `cursor`, `log`, `stream_def` — and supplies only the ones the function
135
+ names. List only what you use; the engine matches by name.
136
136
 
137
137
  - **`config`** — the resolved `Config` for this run. Read params via
138
138
  `config.get("key")` or attribute access. Read secrets via
@@ -143,6 +143,12 @@ only what you use; the engine matches by name.
143
143
  that need to remember more than a cursor.
144
144
  - **`log`** — a standard logging-style logger. `log.info(...)`,
145
145
  `log.warning(...)`.
146
+ - **`stream_def`** — the parsed `StreamDef` for this stream — its own
147
+ `register.yaml` entry. Lets a connector introspect its own
148
+ declaration. Useful when one connector serves more than one
149
+ extraction surface (REST + Sigma SQL, GA + Reporting API, etc.) and
150
+ the surface choice is declared in the manifest. See "Dual-API
151
+ connectors" below.
146
152
 
147
153
  ### Three rules to drill in
148
154
 
@@ -162,6 +168,72 @@ but it does not reshape nested dicts into flat columns. If the API returns
162
168
  `{"attributes": {"country": "US"}}` and your schema declares
163
169
  `country: STRING`, you must flatten in the `@stream` function before yielding.
164
170
 
171
+ ## Dual-API connectors — when one source serves two surfaces
172
+
173
+ Some APIs come in two flavors that the same operator pulls from in one
174
+ pipeline. Stripe is the canonical example: a standard REST API
175
+ (resource-as-stream, GA) AND a Sigma SQL API (query-as-stream, paid,
176
+ ~3-hour lag). Both belong to one "Stripe" connector, not two.
177
+
178
+ The dtex pattern is **one source folder, two extraction surfaces, per-stream
179
+ opt-in**. The baked `stripe` connector demonstrates this:
180
+
181
+ ```yaml
182
+ # dtex/sources/stripe/register.yaml — abbreviated
183
+ streams:
184
+ - name: charges # REST — no `sigma:` block
185
+ table: stripe_charges
186
+ incremental: {cursor_field: created, cursor_type: int}
187
+ schema: [...]
188
+
189
+ - name: charges_daily # Sigma — opted in via `sigma:` block
190
+ table: charges
191
+ sigma:
192
+ query: queries/charges_daily.sql
193
+ incremental: {cursor_field: created, cursor_type: timestamp}
194
+ schema: [...]
195
+ ```
196
+
197
+ In `source.py`, each `@stream` function knows which surface it belongs
198
+ to by which helper it calls. The Sigma functions take the `stream_def`
199
+ injectable, read `stream_def.sigma.query`, and load the SQL from the
200
+ named file:
201
+
202
+ ```python
203
+ @stream(name="charges_daily")
204
+ def charges_daily(
205
+ stream_def: StreamDef, config: Config, cursor: Cursor, log
206
+ ) -> Iterator[Batch]:
207
+ yield from _extract_sigma_stream(stream_def, config, cursor, log)
208
+
209
+ # _extract_sigma_stream(stream_def, ...) reads stream_def.sigma.query
210
+ # from the connector folder, binds {since} from the cursor, submits to
211
+ # Sigma, downloads CSV, yields batches.
212
+ ```
213
+
214
+ The decision rules:
215
+
216
+ * **One connector, not two.** Users binding `source: stripe` see one
217
+ surface; the `sigma:` block is the opt-in marker per stream. Don't
218
+ fork into `stripe` and `stripe_sigma`.
219
+ * **Sigma streams declare `sigma: {query: <path>}`.** Path is relative
220
+ to the connector folder. The `@stream` function reads it from
221
+ `stream_def.sigma.query` (via the `stream_def` injectable) and
222
+ loads the SQL.
223
+ * **REST streams don't change.** No `sigma:` block, no `stream_def`
224
+ arg required — existing connectors stay shaped exactly as they were.
225
+ * **Shared client params, shared auth.** A single Stripe restricted
226
+ key with both REST + Sigma scopes drives both surfaces from one
227
+ `api_key` secret. Keep it that way; don't introduce per-surface
228
+ secrets unless the API genuinely demands separate keys.
229
+
230
+ The same pattern fits other APIs with a "live REST + analytical SQL"
231
+ shape — Shopify GraphQL + Shopify Bulk Operations, Salesforce REST +
232
+ SOQL, GA Data API + BigQuery export, etc. The `sigma:` block is
233
+ Stripe-specific naming today; future dual-surface connectors should
234
+ either reuse it (if they have similar SQL semantics) or add a parallel
235
+ marker (`bulk:`, `soql:`) following the same opt-in shape.
236
+
165
237
  ## The `client.py` pattern
166
238
 
167
239
  ```python
@@ -0,0 +1,188 @@
1
+ # Stripe — baked source connector
2
+
3
+ One Stripe connector, **two extraction surfaces**:
4
+
5
+ * **REST** (default) — resource-as-stream over Stripe's GA REST API at
6
+ `https://api.stripe.com/v1`. One stream per resource type
7
+ (`charges`, `invoices`, `customers`, `subscriptions`), incremental on
8
+ the Unix `created` timestamp, paginated via Stripe's cursor model
9
+ (`starting_after` + `has_more`). Cheap, near-live, no extra
10
+ subscription.
11
+ * **Sigma SQL** (opt-in per stream) — query-as-stream over Stripe's
12
+ Sigma API. Each Sigma stream's `register.yaml` entry carries a
13
+ `sigma: {query: queries/<name>.sql}` block; the connector reads the
14
+ SQL body, submits it via `POST /v2/data/reporting/query_runs`, polls
15
+ to completion, downloads the CSV result, and yields batches. Lets a
16
+ single stream JOIN/aggregate across Stripe's internal tables in
17
+ Stripe-side compute. Requires a **paid Sigma subscription** and
18
+ carries a **~3-hour data lag**.
19
+
20
+ The two surfaces share one source folder, one set of connector params,
21
+ and one `api_key` — a single Stripe restricted key with the relevant
22
+ read scopes drives both. Configs mix REST and Sigma streams freely.
23
+
24
+ ## Authentication
25
+
26
+ The connector reads a **restricted Stripe secret key** with **read-only
27
+ scopes** from the `STRIPE_API_KEY` environment variable:
28
+
29
+ ```sh
30
+ export STRIPE_API_KEY="rk_live_..."
31
+ ```
32
+
33
+ Recommended restricted-key scopes:
34
+
35
+ | Surface | Resource | Scope |
36
+ |---|---|---|
37
+ | REST | `/charges` | `charge:read` |
38
+ | REST | `/invoices` | `invoice:read` |
39
+ | REST | `/customers` | `customer:read` |
40
+ | REST | `/subscriptions` | `subscription:read` |
41
+ | Sigma | `POST /v2/data/reporting/query_runs` | `sigma:read`, `reporting:read` |
42
+
43
+ If your config only uses REST streams you don't need the Sigma scopes,
44
+ and vice versa.
45
+
46
+ The key is sent as `Authorization: Bearer <key>` on every request. It is
47
+ **never logged** — the `Authorization` header is redacted at each client's
48
+ single logging choke point.
49
+
50
+ ## Supported resources (v1)
51
+
52
+ Four streams, all `write_disposition: merge` on `primary_key: id`:
53
+
54
+ - **`charges`** → `stripe_charges`
55
+ - **`invoices`** → `stripe_invoices`
56
+ - **`customers`** → `stripe_customers`
57
+ - **`subscriptions`** → `stripe_subscriptions`
58
+
59
+ Each declares an explicit schema covering the common columns (`id`, `object`,
60
+ `created`, `livemode`, …) plus the resource-specific scalars. Nested
61
+ sub-objects (e.g. `payment_method_details`, `lines`, `items`, `metadata`)
62
+ land as `JSON` columns — the dtex convention for nested data
63
+ (docs/04 §"Nested data").
64
+
65
+ ## Incremental model
66
+
67
+ Every stream uses:
68
+
69
+ ```yaml
70
+ incremental:
71
+ cursor_field: created
72
+ cursor_type: int # Stripe `created` is a Unix-timestamp integer
73
+ lookback: 6h # declared hint
74
+ initial_value: "1704067200" # 2024-01-01 UTC
75
+ ```
76
+
77
+ On each run the connector translates the engine's `cursor.start_value()` into
78
+ a `created[gte]=<ts>` query parameter, walks every page with
79
+ `starting_after=<last_id>`, and `cursor.observe()`s each record so the engine
80
+ can advance the cursor.
81
+
82
+ Stripe lists are returned **newest first** within the new-records window;
83
+ `cursor.observe()` takes the *max*, so the final cursor is correct regardless
84
+ of arrival order. Per dtex's commit model the cursor is committed only
85
+ after every batch durably lands, so a mid-stream crash safely re-runs the
86
+ whole window — no lost or skipped rows.
87
+
88
+ `merge` write disposition means re-fetched objects (an invoice whose status
89
+ changed after it was first loaded) upsert in place, so a periodic
90
+ `lookback`-style re-fetch is idempotent.
91
+
92
+ ## Usage
93
+
94
+ `register.yaml` declares the connector; the project binds it to a
95
+ destination via its `destination:` block or via the project-level
96
+ `default_destination`. Running it is the standard dtex one-liner:
97
+
98
+ ```python
99
+ import dtex
100
+
101
+ result = dtex.run(
102
+ connector="stripe",
103
+ target="prod",
104
+ # Run a single stream:
105
+ select=("charges",),
106
+ # Or override params per invocation:
107
+ params={"page_size": 100, "requests_per_second": 50},
108
+ )
109
+ print(result.status, result.rows_loaded)
110
+ ```
111
+
112
+ CLI equivalent:
113
+
114
+ ```sh
115
+ dtex run stripe --target prod --select charges
116
+ ```
117
+
118
+ ## Operational notes
119
+
120
+ - **Rate limit.** Default `requests_per_second: 25` is conservative against
121
+ Stripe's documented ~100/sec live-mode limit. Tune up via `params` if you
122
+ control the account's throttling budget.
123
+ - **Retries.** `429` honors `Retry-After` exactly; `5xx` retries with
124
+ exponential backoff up to `max_retries: 5`; other `4xx` (`401`, `403`,
125
+ `404`) raise immediately — a bad request will not get better on its own.
126
+ - **Dependencies.** `requests>=2.31` (declared in `pyproject.toml`); no
127
+ Stripe SDK — raw HTTP keeps the dependency surface minimal and avoids
128
+ version coupling to the SDK's release cadence.
129
+ - **API version pin.** `api_version` defaults to `2024-12-18.acacia`; see
130
+ [docs/connectors/stripe-research.md](../../../docs/connectors/stripe-research.md)
131
+ for the source of that pin and where to check for the current GA.
132
+
133
+ ## Sigma SQL streams
134
+
135
+ Three Sigma streams ship in `register.yaml`:
136
+
137
+ - **`charges_daily`** → `charges` (incremental on `created`)
138
+ - **`subscriptions_active`** → `subscriptions` (full refresh)
139
+ - **`invoices_paid`** → `invoices` (incremental on `status_transitions_paid_at`)
140
+
141
+ The SQL bodies live under `queries/<stream_name>.sql`. The connector binds
142
+ the `{since}` placeholder from each stream's `Cursor.start_value()` (or
143
+ the connector-level `sigma_initial_since` for non-incremental streams) as a
144
+ Presto `TIMESTAMP 'YYYY-MM-DD HH:MM:SS'` literal — Sigma rejects ISO-8601's
145
+ `T` separator and timezone suffixes.
146
+
147
+ ### Adding a 4th Sigma stream
148
+
149
+ Two files + one function:
150
+
151
+ 1. Drop your query at `queries/<new_stream>.sql`. Use `{since}` if it's
152
+ incremental.
153
+ 2. Append a stream to `register.yaml` declaring the schema and the
154
+ `sigma:` block:
155
+ ```yaml
156
+ - name: my_new_stream
157
+ table: my_new_table
158
+ primary_key: id
159
+ write_disposition: merge
160
+ sigma:
161
+ query: queries/my_new_stream.sql
162
+ incremental:
163
+ cursor_field: <col>
164
+ cursor_type: timestamp
165
+ initial_value: "2024-01-01T00:00:00Z"
166
+ schema:
167
+ - {name: id, type: STRING, mode: REQUIRED}
168
+ - {name: <col>, type: TIMESTAMP, mode: REQUIRED}
169
+ - ...
170
+ ```
171
+ 3. Add a one-line `@stream` wrapper in `source.py` that delegates to
172
+ `_extract_sigma_stream` (the existing functions show the pattern).
173
+
174
+ ### Operational notes (Sigma-specific)
175
+
176
+ - **Account ID required.** The config must set `params.account_id` to the
177
+ `acct_...` ID of the Stripe account being queried. Stripe rejects every
178
+ Sigma request with HTTP 403 otherwise.
179
+ - **API version pin.** `sigma_api_version` defaults to
180
+ `2026-04-22.preview`; the Query Run API is preview. Bump deliberately.
181
+ - **Polling.** `sigma_poll_interval_seconds` (default `2.0`) is the gap
182
+ between status polls; `sigma_poll_timeout_seconds` (default `600`) is the
183
+ hard ceiling per query. Heavy queries on large accounts approach the
184
+ ceiling; bump if needed.
185
+ - **Download retries.** A truncated CSV mid-body retries the whole download
186
+ up to `max_retries` (default `5`) with exponential backoff
187
+ (`retry_backoff_seconds`, default `1.0`). Duplicate-free because no rows
188
+ are yielded until the full CSV is in hand.
@@ -0,0 +1,24 @@
1
+ -- charges_daily — every Charge created after the cursor floor.
2
+ --
3
+ -- Sigma's dialect is PrestoDB-flavored. The {since} placeholder is
4
+ -- substituted by source.py at run time from the engine's Cursor; it
5
+ -- arrives here as an ISO-8601 string and is compared against `created`
6
+ -- (a Sigma TIMESTAMP column).
7
+ --
8
+ -- Schema is mirrored in register.yaml. If you add a column here, add
9
+ -- it there (and bump the Stripe Sigma API version pin if the new
10
+ -- column is preview-only).
11
+
12
+ SELECT
13
+ id,
14
+ amount,
15
+ currency,
16
+ created,
17
+ status,
18
+ customer_id,
19
+ description,
20
+ paid,
21
+ refunded
22
+ FROM charges
23
+ WHERE created > TIMESTAMP {since}
24
+ ORDER BY created
@@ -0,0 +1,21 @@
1
+ -- invoices_paid — paid invoices since the cursor floor.
2
+ --
3
+ -- Sigma's invoices table tracks lifecycle transitions in the
4
+ -- `status_transitions_*` columns. `status_transitions_paid_at` is the
5
+ -- timestamp the invoice transitioned to paid; that's the right
6
+ -- incremental cursor (`created` or `date` would re-load an invoice
7
+ -- whose payment processed long after it was issued).
8
+
9
+ SELECT
10
+ id,
11
+ customer_id,
12
+ subscription_id,
13
+ total,
14
+ amount_paid,
15
+ currency,
16
+ status_transitions_paid_at,
17
+ status,
18
+ number
19
+ FROM invoices
20
+ WHERE status_transitions_paid_at > TIMESTAMP {since}
21
+ ORDER BY status_transitions_paid_at
@@ -0,0 +1,17 @@
1
+ -- subscriptions_active — currently-active subscriptions, full snapshot.
2
+ --
3
+ -- No {since} placeholder: this stream is full-refresh (`write_disposition:
4
+ -- replace` in register.yaml). Sigma's `subscriptions` table is the
5
+ -- authoritative current state of every subscription.
6
+
7
+ SELECT
8
+ id,
9
+ customer_id,
10
+ status,
11
+ current_period_start,
12
+ current_period_end,
13
+ cancel_at_period_end,
14
+ created
15
+ FROM subscriptions
16
+ WHERE status = 'active'
17
+ ORDER BY created