jcpm-data 0.1.0__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 (73) hide show
  1. jcpm_data-0.1.0/.gitignore +16 -0
  2. jcpm_data-0.1.0/.python-version +1 -0
  3. jcpm_data-0.1.0/PKG-INFO +435 -0
  4. jcpm_data-0.1.0/README.md +413 -0
  5. jcpm_data-0.1.0/docs/agents/bigquery_partition_and_cluster.md +375 -0
  6. jcpm_data-0.1.0/docs/agents/bigquery_partiton.md +523 -0
  7. jcpm_data-0.1.0/docs/agents/dlt_bigquery.md +398 -0
  8. jcpm_data-0.1.0/docs/agents/dlt_evolute_schema.md +140 -0
  9. jcpm_data-0.1.0/docs/agents/dlt_nameconvention.md +248 -0
  10. jcpm_data-0.1.0/docs/agents/dlt_pipeline.md +165 -0
  11. jcpm_data-0.1.0/docs/agents/dlt_staging.md +130 -0
  12. jcpm_data-0.1.0/docs/agents/dlt_visualize.md +294 -0
  13. jcpm_data-0.1.0/docs/agents/gcp_bigquery_fields.md +1959 -0
  14. jcpm_data-0.1.0/docs/agents/pandas-gbq.md +337 -0
  15. jcpm_data-0.1.0/docs/observabilidade.md +287 -0
  16. jcpm_data-0.1.0/main.py +73 -0
  17. jcpm_data-0.1.0/pyproject.toml +42 -0
  18. jcpm_data-0.1.0/simulacao.py +343 -0
  19. jcpm_data-0.1.0/src/jcpm_data/__init__.py +9 -0
  20. jcpm_data-0.1.0/src/jcpm_data/cli.py +283 -0
  21. jcpm_data-0.1.0/src/jcpm_data/pipelines/__init__.py +130 -0
  22. jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/__init__.py +51 -0
  23. jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/config.py +50 -0
  24. jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/data.py +143 -0
  25. jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/destination.py +307 -0
  26. jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/infer.py +71 -0
  27. jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/options.py +89 -0
  28. jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/schema.py +139 -0
  29. jcpm_data-0.1.0/src/jcpm_data/pipelines/config.py +91 -0
  30. jcpm_data-0.1.0/src/jcpm_data/pipelines/context.py +87 -0
  31. jcpm_data-0.1.0/src/jcpm_data/pipelines/dev.py +80 -0
  32. jcpm_data-0.1.0/src/jcpm_data/pipelines/error_codes.py +117 -0
  33. jcpm_data-0.1.0/src/jcpm_data/pipelines/errors.py +83 -0
  34. jcpm_data-0.1.0/src/jcpm_data/pipelines/logger.py +101 -0
  35. jcpm_data-0.1.0/src/jcpm_data/pipelines/observability/__init__.py +21 -0
  36. jcpm_data-0.1.0/src/jcpm_data/pipelines/observability/events.py +137 -0
  37. jcpm_data-0.1.0/src/jcpm_data/pipelines/observability/run.py +377 -0
  38. jcpm_data-0.1.0/src/jcpm_data/pipelines/pipeline.py +178 -0
  39. jcpm_data-0.1.0/src/jcpm_data/pipelines/quality.py +102 -0
  40. jcpm_data-0.1.0/src/jcpm_data/pipelines/storage/__init__.py +6 -0
  41. jcpm_data-0.1.0/src/jcpm_data/pipelines/storage/config.py +48 -0
  42. jcpm_data-0.1.0/src/jcpm_data/pipelines/storage/datalake.py +212 -0
  43. jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/__init__.py +38 -0
  44. jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/dates.py +30 -0
  45. jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/env.py +91 -0
  46. jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/names.py +30 -0
  47. jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/runtime.py +62 -0
  48. jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/tasks.py +88 -0
  49. jcpm_data-0.1.0/src/jcpm_data/pipelines/validators.py +41 -0
  50. jcpm_data-0.1.0/src/jcpm_data/templates/project/.github/workflows/deploy.yml.jinja +21 -0
  51. jcpm_data-0.1.0/src/jcpm_data/templates/project/README.md.jinja +31 -0
  52. jcpm_data-0.1.0/src/jcpm_data/templates/project/copier.yml +52 -0
  53. jcpm_data-0.1.0/src/jcpm_data/templates/project/deploy.yml.jinja +44 -0
  54. jcpm_data-0.1.0/src/jcpm_data/templates/project/project/__init__.py +0 -0
  55. jcpm_data-0.1.0/src/jcpm_data/templates/project/project/errors.py +15 -0
  56. jcpm_data-0.1.0/src/jcpm_data/templates/project/project/main.py.jinja +64 -0
  57. jcpm_data-0.1.0/src/jcpm_data/templates/project/project/sources/__init__.py +0 -0
  58. jcpm_data-0.1.0/src/jcpm_data/templates/project/project/sources/example.py +130 -0
  59. jcpm_data-0.1.0/src/jcpm_data/templates/project/pyproject.toml.jinja +19 -0
  60. jcpm_data-0.1.0/tests/__init__.py +0 -0
  61. jcpm_data-0.1.0/tests/test_cli.py +164 -0
  62. jcpm_data-0.1.0/tests/test_config.py +74 -0
  63. jcpm_data-0.1.0/tests/test_data_api.py +415 -0
  64. jcpm_data-0.1.0/tests/test_dev.py +121 -0
  65. jcpm_data-0.1.0/tests/test_env.py +52 -0
  66. jcpm_data-0.1.0/tests/test_error_codes.py +148 -0
  67. jcpm_data-0.1.0/tests/test_observability.py +301 -0
  68. jcpm_data-0.1.0/tests/test_partition.py +113 -0
  69. jcpm_data-0.1.0/tests/test_primary_key.py +68 -0
  70. jcpm_data-0.1.0/tests/test_storage.py +114 -0
  71. jcpm_data-0.1.0/tests/test_tasks.py +124 -0
  72. jcpm_data-0.1.0/tests/test_utils.py +63 -0
  73. jcpm_data-0.1.0/uv.lock +2534 -0
@@ -0,0 +1,16 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Ambiente local
13
+ .env
14
+
15
+ # macOS
16
+ .DS_Store
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,435 @@
1
+ Metadata-Version: 2.5
2
+ Name: jcpm-data
3
+ Version: 0.1.0
4
+ Summary: Framework padronizado de engenharia de dados do Grupo JCPM (carga e validação para o DW)
5
+ Author-email: Ullysses Rosendo <ullysses.rosendo@jcpm.com.br>
6
+ License: Proprietary
7
+ Keywords: bigquery,data,data-warehouse,dlt,etl,jcpm
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: click>=8.1.0
10
+ Requires-Dist: copier>=9.0.0
11
+ Requires-Dist: dlt[bigquery]>=1.30.0
12
+ Requires-Dist: google-cloud-storage>=2.0.0
13
+ Requires-Dist: loguru>=0.7.0
14
+ Requires-Dist: pandas>=2.0.0
15
+ Requires-Dist: pydantic>=2.0.0
16
+ Requires-Dist: python-dotenv>=1.0.0
17
+ Requires-Dist: python-ulid>=2.0.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
20
+ Requires-Dist: ruff>=0.6.0; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # jcpm-data
24
+
25
+ Framework padronizado de engenharia de dados do Grupo JCPM. Implementa regras e
26
+ validações de carga de dados para o Data Warehouse (BigQuery via
27
+ [`dlt`](https://dlthub.com/)), com convenção estrita de nomenclatura de tabelas.
28
+
29
+ ## Instalação
30
+
31
+ ```bash
32
+ uv add jcpm-data # ou: pip install jcpm-data
33
+ ```
34
+
35
+ ## Criar um projeto (CLI)
36
+
37
+ A lib traz um CLI (estilo `django-admin`) que gera um projeto a partir de um
38
+ template [copier](https://copier.readthedocs.io/):
39
+
40
+ ```bash
41
+ jcpm-data startproject "Vendas Diarias" . # nome e local (. = atual)
42
+ jcpm-data startproject # pergunta nome, local e scopes
43
+ ```
44
+
45
+ Pergunta os **scopes** (multiselect): `jcpm` e `external` são **exclusivos** (não
46
+ combinam com outros); os shoppings podem ser combinados. Gera:
47
+
48
+ ```
49
+ .
50
+ ├── deploy.yml # contrato de infra (plataforma jcpm-data-devops)
51
+ ├── pyproject.toml # dependências (inclui jcpm-data)
52
+ ├── README.md
53
+ ├── project/
54
+ │ ├── main.py # entrypoint (scope por task + run das sources)
55
+ │ └── sources/example.py # exemplo de @data
56
+ └── .github/workflows/deploy.yml # chama o reusable workflow do devops
57
+ ```
58
+
59
+ ### `jcpm-data run`
60
+
61
+ Executa o pipeline do projeto (`project/main.py`), equivalente a
62
+ `python -m project.main`:
63
+
64
+ ```bash
65
+ jcpm-data run # roda no diretório atual (--path para outro)
66
+ ```
67
+
68
+ ### `jcpm-data check`
69
+
70
+ Dentro de um projeto, valida tudo de uma vez (sai com código ≠ 0 se algo falha):
71
+
72
+ ```bash
73
+ jcpm-data check # roda no diretório atual (--path para outro)
74
+ ```
75
+
76
+ - **deploy.yml** — existe e tem `apiVersion` + `name` (contrato de infra)
77
+ - **sintaxe** — compila todos os `.py`
78
+ - **imports** — importa `project/` e sources (pega imports quebrados)
79
+ - **lint (ruff)** — roda o `ruff` (pulado se não instalado)
80
+
81
+ ## Módulos
82
+
83
+ O código da biblioteca vive em `jcpm_data.pipelines`; a raiz reexporta a API
84
+ pública por conveniência (`from jcpm_data import ...`):
85
+
86
+ | Import (raiz, recomendado) | Módulo-casa | Conteúdo |
87
+ |----------------------------|-------------|----------|
88
+ | `from jcpm_data import Pipeline, ScopeConfig, ScopeName` | `jcpm_data.pipelines` | Núcleo: pipeline e escopo |
89
+ | `from jcpm_data.pipelines.bigquery import data, Column, WRITE_MODE, PARTITION, SCHEMA_POLICY, BigQueryConfig` | `jcpm_data.pipelines.bigquery` | API `@data`, schema e destino BigQuery |
90
+ | `from jcpm_data.pipelines.utils import get_env, get_scope_by_task_id, range_dates, stand_names` | `jcpm_data.pipelines.utils` | Env com escopo, dispatch de tasks, datas, nomes |
91
+ | `from jcpm_data.pipelines.storage import StorageConfig, DataLake` | `jcpm_data.pipelines.storage` | Data lake no GCS (download / mark_processed) |
92
+
93
+ ## Uso (`@data`)
94
+
95
+ O decorator `@data` carrega toda a configuração de carga; o handler só produz os
96
+ registros brutos (`yield` de lotes). A tabela de destino e o `pipeline_name` do
97
+ dlt seguem a convenção `{scope}__{source}__{resource}` (ex.:
98
+ `jcpm__raw_crm__usuarios`). `source` e `resource` devem ser **minúsculos, sem
99
+ espaços/acentos** (senão `IdentifierValidationError`).
100
+
101
+ ```python
102
+ from jcpm_data.pipelines.bigquery import (
103
+ Column, PARTITION, SCHEMA_POLICY, WRITE_MODE, data,
104
+ )
105
+
106
+ USERS_SCHEMA = [
107
+ Column("id", "INT64", mode="REQUIRED"),
108
+ Column("nome", "STRING", mode="NULLABLE"),
109
+ Column("updated_at", "TIMESTAMP", mode="REQUIRED"),
110
+ Column("particao_data", "DATE", mode="REQUIRED"),
111
+ ]
112
+
113
+
114
+ @data(
115
+ source="raw_crm",
116
+ resource="usuarios",
117
+ write_mode=WRITE_MODE.MERGE,
118
+ primary_key=["id"],
119
+ partition_by=PARTITION.DAY, # exige a coluna particao_data
120
+ cluster_by=["status"],
121
+ cursor_field="updated_at", # carga incremental (dlt)
122
+ schema=USERS_SCHEMA,
123
+ schema_policy=SCHEMA_POLICY.IGNORE, # STRICT(freeze) / EVOLVE / IGNORE(descarta col extra)
124
+ )
125
+ def extract_crm_users(context):
126
+ page = 1
127
+ while True:
128
+ records = api.get_users(page=page)
129
+ if not records:
130
+ break
131
+ yield records # cada lote vai para o BigQuery
132
+ page += 1
133
+
134
+
135
+ pipeline.run(data=extract_crm_users, context={"date": date})
136
+ ```
137
+
138
+ - **`Column(name, type, mode, description)`** — tipos BigQuery (`INT64`, `STRING`,
139
+ `DATE`, `TIMESTAMP`, ...) convertidos para o schema do dlt.
140
+ - **`WRITE_MODE`**: `APPEND` / `REPLACE` / `MERGE` / `REPLACE_PARTITION`.
141
+ - `MERGE` exige `primary_key` (upsert por chave).
142
+ - `REPLACE_PARTITION` **substitui as partições presentes no lote** sem exigir
143
+ chave natural — ideal para reprocessar um dia/período. Exige `partition_by`;
144
+ por baixo usa o `merge` do dlt com `merge_key` em `particao_data` (apaga as
145
+ datas do lote no destino e insere as novas; partições de outras datas ficam
146
+ intactas). Com `PARTITION.DAY`, substitui exatamente a partição do dia.
147
+ - **`PARTITION`**: `HOUR`/`DAY`/`MONTH`/`YEAR`. A coluna é sempre **`particao_data`**.
148
+ `DAY` usa o particionamento nativo do dlt; `MONTH`/`YEAR`/`HOUR` **pré-criam a
149
+ tabela** com `PARTITION BY DATE_TRUNC(...)` (exigem `schema`, pois o dlt só faz DAY).
150
+ - **`SCHEMA_POLICY`** → `schema_contract` do dlt:
151
+ - `STRICT` (freeze): falha se vier coluna/tabela nova (a tabela precisa já existir).
152
+ - `EVOLVE` (evolve): adiciona colunas novas automaticamente.
153
+ - `IGNORE`: mantém as linhas e **descarta as colunas fora do schema** — o
154
+ DataFrame é projetado no `schema` antes da escrita e o contrato usa
155
+ `columns: discard_value` (a tabela ainda é criada/evolui).
156
+ - `cluster_by` e `cursor_field` aplicados via `bigquery_adapter`/incremental do dlt.
157
+ - **DataFrames são coagidos ao `schema`** antes da carga (`cast_to_schema`): colunas
158
+ `DATE`/`TIMESTAMP`/`INT64`/... saem no tipo certo (evita o erro `DATE → STRING`).
159
+
160
+ ### Execução e logs
161
+
162
+ - `Pipeline(name, scope, bq_config)` — `name` é o nome do projeto (o mesmo do
163
+ `deploy.yml`) e vira o prefixo dos logs/observabilidade; a `source` vai no
164
+ `run(data=...)`, então o mesmo pipeline roda várias sources.
165
+ - `run(data, context=None, drop_pending=True)` — por padrão descarta pacotes
166
+ pendentes de uma carga anterior (evita o loop em que o dlt re-tenta um pacote cujo
167
+ staging já foi limpo). Use `drop_pending=False` para o retry nativo do dlt.
168
+ - Durante a execução, o framework **emite eventos estruturados** (JSON) para
169
+ observabilidade — `PIPELINE_STARTED` → `STAGE_*` → `PIPELINE_COMPLETED`/
170
+ `PIPELINE_FAILED`, com `run_id` único e um `run_summary` (linhas, tempo, status).
171
+ No handler, use `context.log.info/warning/error(...)` para registrar sinais.
172
+ Detalhes em [`docs/observabilidade.md`](docs/observabilidade.md).
173
+
174
+ Utilitários relacionados:
175
+
176
+ ```python
177
+ from jcpm_data.pipelines.utils import range_dates, stand_names # datas / snake_case
178
+ ```
179
+
180
+ ### Códigos de erro do projeto
181
+
182
+ Declare os erros (código + descrição) num só lugar (`project/errors.py`) e
183
+ reutilize via `context.ERROR.<NOME>` — para **logar** ou **abortar**:
184
+
185
+ ```python
186
+ # project/errors.py
187
+ from jcpm_data import ErrorCatalog
188
+
189
+ class Errors(ErrorCatalog): # NOME = "descrição" (o nome é o código)
190
+ API_TIMEOUT = "A fonte externa não respondeu no tempo limite"
191
+ EMPTY_RESULT = "A consulta retornou sem registros"
192
+ ```
193
+
194
+ O mesmo `context.ERROR.<NOME>` serve para **dois usos diferentes** — e quem decide
195
+ abortar é o `raise`, **não** o log:
196
+
197
+ ```python
198
+ # no handler
199
+ def extract(context):
200
+ # 1) LOGAR (NÃO levanta, NÃO aborta): registra um evento ERROR e segue
201
+ context.log.error(context.ERROR.API_TIMEOUT)
202
+
203
+ # 2) ABORTAR: raise levanta a exceção e interrompe a carga
204
+ raise context.ERROR.API_TIMEOUT("status 504")
205
+ ```
206
+
207
+ > **`context.log.error` NÃO levanta exceção** — é só um log estruturado (como o
208
+ > `logger.error` do Python). O pipeline **continua**. Para **parar** a carga, use
209
+ > `raise context.ERROR.X(...)`.
210
+
211
+ | Como você usa | Levanta exceção? | Evento(s) | Status final |
212
+ |---------------|------------------|-----------|--------------|
213
+ | `context.log.error(context.ERROR.X)` | **Não** (segue) | `ERROR` | `WARNING` (concluiu) |
214
+ | `raise context.ERROR.X("detalhe")` | **Sim** (aborta) | `ERROR` + `PIPELINE_FAILED` | `FAILED` |
215
+
216
+ Detalhes:
217
+ - `context.log.info/warning/error` aceitam o `ErrorCode` direto (usam o código +
218
+ descrição). **Nenhum** deles interrompe o fluxo — são logs.
219
+ - `raise context.ERROR.X("detalhe")` levanta um `PipelineError` com o `code` do
220
+ projeto; o `PIPELINE_FAILED` sai com `error_code=API_TIMEOUT` e a `message` inclui
221
+ a descrição + o detalhe.
222
+ - Os códigos são registrados ao importar o módulo (o `main.py` gerado já faz
223
+ `import project.errors`). Também dá para usar `Errors.API_TIMEOUT` direto.
224
+
225
+ ### Dev: inferir o schema de um DataFrame
226
+
227
+ Para não escrever o `schema=` na mão, passe um DataFrame de amostra — o
228
+ `infer_schema` devolve a lista de `Column` pronta para usar:
229
+
230
+ ```python
231
+ from jcpm_data.pipelines.dev import infer_schema
232
+
233
+ schema = infer_schema(df) # -> [Column("customer_id", "INT64", ...), ...]
234
+
235
+ @data(source="crm", resource="usuarios", schema=infer_schema(df))
236
+ def extract(context): ...
237
+ ```
238
+
239
+ - Tipo deduzido do dtype/valores; `mode=REQUIRED` quando a coluna não tem nulos.
240
+ - Nomes padronizados (snake_case ASCII) por default (`standardize=False` mantém).
241
+
242
+ Para inspecionar o **conteúdo** de cada coluna (ver o que tem antes de definir
243
+ `schema`/`primary_key`), `sample_distinct` devolve uma amostra de valores distintos
244
+ por coluna:
245
+
246
+ ```python
247
+ from jcpm_data.pipelines.dev import sample_distinct
248
+
249
+ sample_distinct(df, n=10) # -> {coluna: [até n valores distintos, não nulos]}
250
+ ```
251
+
252
+ ## Chave primária (validação automática)
253
+
254
+ Quando você define `primary_key` no `@data`, a lib **valida os dados antes de
255
+ enviar ao BigQuery**: se a chave tiver **nulos** ou **duplicatas**, a carga
256
+ **falha** com `PrimaryKeyValidationError` (nada chega ao destino). A verificação
257
+ usa o DataFrame + schema que já estão em mãos — não envolve o dlt.
258
+
259
+ Para checar manualmente no desenvolvimento (`jcpm_data.pipelines.quality`):
260
+
261
+ ```python
262
+ from jcpm_data.pipelines.quality import check_primary_key
263
+
264
+ r = check_primary_key(df, ["cod_contrato", "item_cobranca"])
265
+ if not r: # é "truthy" quando a chave é única
266
+ print(r)
267
+ # PrimaryKeyCheck(['cod_contrato', 'item_cobranca']) NÃO única: 4 linha(s),
268
+ # 3 combinação(ões) distinta(s), 1 duplicada(s) (2 linha(s))
269
+ ```
270
+
271
+ - `r.unique`, `r.duplicated_rows`, `r.duplicated_combinations`, `r.examples` (as
272
+ combinações que mais repetem).
273
+ - **Nulo na chave levanta `PrimaryKeyValidationError`** — uma PK não pode ter nulo.
274
+
275
+ ## Storage (GCS): `DataLake`
276
+
277
+ Acesso ao data lake no Google Cloud Storage:
278
+
279
+ ```python
280
+ from jcpm_data.pipelines.storage import StorageConfig, DataLake
281
+
282
+ storage = StorageConfig(bucket="meu-bucket", path_processed="processed")
283
+ lake = DataLake(storage=storage)
284
+
285
+ # Baixa um arquivo, uma pasta (prefixo) ou um padrão glob.
286
+ # Retorna DownloadedFile (com .local = path local e .remote = blob no GCS).
287
+ lake.download("landing/*.zip") # usa StorageConfig.download_dir (criado se não existir)
288
+
289
+ # Itera os arquivos locais — glob interno, sem importar glob:
290
+ for f in lake.local_dir("**/*.zip"):
291
+ pipeline.run(data=minha_source, context={"zipfile": str(f)}) # f é um Path local
292
+ lake.mark_processed(f) # aceita o path LOCAL e deriva o blob no GCS -> processed/...
293
+ ```
294
+
295
+ - O `download` **já devolve** a lista de `DownloadedFile` (não precisa re-listar). Se
296
+ preferir, itere o retorno direto: `for f in lake.download("landing/*.zip"): ...`.
297
+ - `lake.local_dir()` lista **todos** os arquivos de `download_dir` (recursivo); com um
298
+ padrão (`lake.local_dir("**/*.zip")`) filtra. (Para o caminho da pasta em si, use
299
+ `storage.download_dir`.)
300
+ - `mark_processed` aceita `DownloadedFile`, caminho **remoto** (GCS) **ou** caminho
301
+ **local** sob `download_dir` (deriva o remoto removendo o prefixo).
302
+
303
+ - **`StorageConfig`**: `bucket`, `project_id` (ADC se nulo), `path` (origem default
304
+ do download), `path_processed` (pasta de processados), `download_dir` (local).
305
+ Deriva do `.env` / env vars (`JCPM_GCS_BUCKET`, `JCPM_GCS_PATH_PROCESSED`, ...).
306
+ - **`download(path=None, dest=None)`**: baixa um **arquivo**, uma **pasta** (prefixo)
307
+ ou um **padrão glob** (`*.zip`, `landing/2026/*.json` — via `fnmatch`, onde `*` casa
308
+ também `/`, logo recursivo). Preserva a estrutura e retorna a lista de `Path` locais.
309
+ - **`mark_processed(file)`**: move (copy+delete) o arquivo para `path_processed`,
310
+ trocando a primeira pasta do caminho e mantendo o resto.
311
+
312
+ ## Validações de contrato
313
+
314
+ Executadas automaticamente em `Pipeline.run()` (via `validate_datasource`),
315
+ **antes** de qualquer acesso à infraestrutura:
316
+
317
+ | Validação | Regra | Erro |
318
+ |-----------|-------|------|
319
+ | Identificadores | `source`/`resource` e nomes de coluna: minúsculos, `[a-z0-9_]`, sem espaços/acentos. | `IdentifierValidationError` |
320
+ | Chave primária | Obrigatória quando `write_mode=MERGE`. | `PrimaryKeyValidationError` |
321
+ | Partição | Se `partition_by` definido e houver `schema`, a coluna `particao_data` deve estar nele. | `DatePartitionValidationError` |
322
+ | Replace partição | `write_mode=REPLACE_PARTITION` exige `partition_by`. | `DatePartitionValidationError` |
323
+ | Schema | `primary_key`/`cluster_by`/`cursor_field` devem existir no `schema` (quando informado). | `SchemaValidationError` |
324
+ | Tipo de coluna | `Column(type=...)` deve ser um tipo BigQuery válido. | `ColumnNameValidationError` |
325
+
326
+ Todos os erros herdam de `JcpmDataError` e de `ValueError` (compatível com
327
+ `except ValueError`), e expõem um `code` estável (ex.: `JCPM_PRIMARY_KEY`).
328
+
329
+ ## Cloud Run Jobs: uma task por scope
330
+
331
+ Em um Cloud Run Job com N tasks, cada task recebe `CLOUD_RUN_TASK_INDEX` (0..N-1).
332
+ `get_scope_by_task_id` mapeia o índice da task para o scope correspondente e
333
+ **valida** que o valor é mesmo um scope:
334
+
335
+ ```python
336
+ from jcpm_data import Pipeline, ScopeConfig, ScopeName
337
+ from jcpm_data.pipelines.bigquery import BigQueryConfig
338
+ from jcpm_data.pipelines.utils import get_scope_by_task_id
339
+
340
+ # Uma task por scope (ordem = índice da task). Configure --tasks=3 no Job.
341
+ SCOPES = [
342
+ ScopeName.RIOMAR_RECIFE,
343
+ ScopeName.RIOMAR_ARACAJU,
344
+ ScopeName.SALVADOR_SHOPPING,
345
+ ]
346
+
347
+
348
+ def main() -> None:
349
+ scope_name = get_scope_by_task_id(SCOPES) # usa CLOUD_RUN_TASK_INDEX
350
+ scope = ScopeConfig(name=scope_name)
351
+ pipeline = Pipeline(name="meu-projeto", scope=scope, bq_config=BigQueryConfig())
352
+ pipeline.run(data=minha_source, context={"date": "2026-03"})
353
+ ```
354
+
355
+ - `task_id` pode ser passado explicitamente (ex.: testes locais); sem ele, lê
356
+ `CLOUD_RUN_TASK_INDEX` (ou `0` fora do Cloud Run).
357
+ - Itens da lista podem ser `ScopeName`, `ScopeConfig` ou `str` (coagido para um
358
+ `ScopeName` conhecido). Valor desconhecido → `ScopeValidationError`.
359
+ - Índice fora do intervalo ou lista vazia → `TaskIndexError`.
360
+ - **`get_object_by_task_id(objetos, task_id=None)`** é a versão genérica (qualquer
361
+ objeto, sem validação de scope) — ex.: um cliente de API por task.
362
+
363
+ ## Variáveis de ambiente com escopo
364
+
365
+ `get_env` resolve segredos/parâmetros por tenant seguindo a convenção
366
+ `{SCOPE}_{KEY}`:
367
+
368
+ ```python
369
+ from jcpm_data import ScopeName
370
+ from jcpm_data.pipelines.utils import get_env
371
+
372
+ # Lê RIOMAR_RECIFE_API_KEY (do ambiente ou do .env)
373
+ api_key = get_env("api_key", scope=ScopeName.RIOMAR_RECIFE)
374
+
375
+ # Sem escopo -> API_KEY
376
+ token = get_env("token")
377
+
378
+ # Opcional (não lança se ausente)
379
+ host = get_env("host", scope="external", default="localhost")
380
+ ```
381
+
382
+ - Sem `default`, a variável é **obrigatória**: lança `EnvVarNotFoundError` se ausente.
383
+ - `scope` aceita `ScopeName`, `ScopeConfig` ou `str`.
384
+ - Um arquivo `.env` próximo é carregado automaticamente na primeira leitura (sem
385
+ sobrescrever variáveis já definidas). Use `load_env(path=...)` para controlar.
386
+
387
+ ## Configuração do BigQuery por ambiente
388
+
389
+ `BigQueryConfig` deriva seus defaults do ambiente de execução:
390
+
391
+ | Variável | Default | Descrição |
392
+ |----------|---------|-----------|
393
+ | `JCPM_BQ_PROJECT_ID` / `GOOGLE_CLOUD_PROJECT` | `None` (usa ADC) | Projeto GCP |
394
+ | `JCPM_BQ_LOCATION` | `us-central1` | Região do dataset |
395
+ | `JCPM_BQ_DATASET` | `raw` | Dataset de destino |
396
+ | `JCPM_BQ_TRUNCATE_STAGING` | `true` | Limpa o dataset de staging após a carga |
397
+ | `JCPM_BQ_ADD_LOAD_ID` | `true` | Adiciona `_dlt_load_id` (id da carga) na tabela — lineage |
398
+
399
+ > O `.env` mais próximo é carregado automaticamente ao construir `BigQueryConfig`.
400
+ > A coluna `_dlt_load_id` é injetada no DDL de tabelas particionadas e, em tabelas
401
+ > **já existentes** que não a têm, é adicionada como **NULLABLE** antes da carga
402
+ > (o dlt não consegue adicionar um campo NOT NULL a uma tabela existente).
403
+
404
+ > **Staging:** para `MERGE`/`REPLACE` o dlt usa um dataset separado `<dataset>_staging`
405
+ > (ex.: `raw_staging`). Com `truncate_staging=True` as tabelas ficam **vazias** após a
406
+ > carga, mas o dataset permanece (é reutilizado) — isso é esperado. Não dá para usar o
407
+ > mesmo dataset do destino (o dlt exige nomes diferentes).
408
+
409
+ ## Logs e observabilidade
410
+
411
+ Todo log sai como **JSON estruturado** no stdout (via
412
+ [`loguru`](https://github.com/Delgan/loguru)), em qualquer ambiente — em Cloud Run
413
+ vira `jsonPayload` no Cloud Logging. O framework instrumenta `Pipeline.run` e emite
414
+ eventos padronizados (`PIPELINE_STARTED` → `STAGE_*` → `PIPELINE_COMPLETED`/
415
+ `PIPELINE_FAILED`), cada um com `run_id`, `pipeline_id` e um `message` descritivo
416
+ prefixado por `<name>:<pipeline_id>`. Nível por `LOG_LEVEL` (default `INFO`).
417
+
418
+ No handler, registre sinais que entram na timeline:
419
+
420
+ ```python
421
+ def extract(context):
422
+ context.log.info("extraindo clientes...")
423
+ context.log.warning("taxa de nulos alta", code="HIGH_NULL_RATE", metadata={"col": "cpf"})
424
+ ...
425
+ ```
426
+
427
+ Guia completo (eventos, `run_summary`, correlação, queries no Cloud Logging):
428
+ [`docs/observabilidade.md`](docs/observabilidade.md).
429
+
430
+ ## Desenvolvimento
431
+
432
+ ```bash
433
+ uv sync --extra dev
434
+ uv run pytest
435
+ ```