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.
- jcpm_data-0.1.0/.gitignore +16 -0
- jcpm_data-0.1.0/.python-version +1 -0
- jcpm_data-0.1.0/PKG-INFO +435 -0
- jcpm_data-0.1.0/README.md +413 -0
- jcpm_data-0.1.0/docs/agents/bigquery_partition_and_cluster.md +375 -0
- jcpm_data-0.1.0/docs/agents/bigquery_partiton.md +523 -0
- jcpm_data-0.1.0/docs/agents/dlt_bigquery.md +398 -0
- jcpm_data-0.1.0/docs/agents/dlt_evolute_schema.md +140 -0
- jcpm_data-0.1.0/docs/agents/dlt_nameconvention.md +248 -0
- jcpm_data-0.1.0/docs/agents/dlt_pipeline.md +165 -0
- jcpm_data-0.1.0/docs/agents/dlt_staging.md +130 -0
- jcpm_data-0.1.0/docs/agents/dlt_visualize.md +294 -0
- jcpm_data-0.1.0/docs/agents/gcp_bigquery_fields.md +1959 -0
- jcpm_data-0.1.0/docs/agents/pandas-gbq.md +337 -0
- jcpm_data-0.1.0/docs/observabilidade.md +287 -0
- jcpm_data-0.1.0/main.py +73 -0
- jcpm_data-0.1.0/pyproject.toml +42 -0
- jcpm_data-0.1.0/simulacao.py +343 -0
- jcpm_data-0.1.0/src/jcpm_data/__init__.py +9 -0
- jcpm_data-0.1.0/src/jcpm_data/cli.py +283 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/__init__.py +130 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/__init__.py +51 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/config.py +50 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/data.py +143 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/destination.py +307 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/infer.py +71 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/options.py +89 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/bigquery/schema.py +139 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/config.py +91 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/context.py +87 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/dev.py +80 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/error_codes.py +117 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/errors.py +83 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/logger.py +101 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/observability/__init__.py +21 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/observability/events.py +137 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/observability/run.py +377 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/pipeline.py +178 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/quality.py +102 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/storage/__init__.py +6 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/storage/config.py +48 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/storage/datalake.py +212 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/__init__.py +38 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/dates.py +30 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/env.py +91 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/names.py +30 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/runtime.py +62 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/utils/tasks.py +88 -0
- jcpm_data-0.1.0/src/jcpm_data/pipelines/validators.py +41 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/.github/workflows/deploy.yml.jinja +21 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/README.md.jinja +31 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/copier.yml +52 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/deploy.yml.jinja +44 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/project/__init__.py +0 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/project/errors.py +15 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/project/main.py.jinja +64 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/project/sources/__init__.py +0 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/project/sources/example.py +130 -0
- jcpm_data-0.1.0/src/jcpm_data/templates/project/pyproject.toml.jinja +19 -0
- jcpm_data-0.1.0/tests/__init__.py +0 -0
- jcpm_data-0.1.0/tests/test_cli.py +164 -0
- jcpm_data-0.1.0/tests/test_config.py +74 -0
- jcpm_data-0.1.0/tests/test_data_api.py +415 -0
- jcpm_data-0.1.0/tests/test_dev.py +121 -0
- jcpm_data-0.1.0/tests/test_env.py +52 -0
- jcpm_data-0.1.0/tests/test_error_codes.py +148 -0
- jcpm_data-0.1.0/tests/test_observability.py +301 -0
- jcpm_data-0.1.0/tests/test_partition.py +113 -0
- jcpm_data-0.1.0/tests/test_primary_key.py +68 -0
- jcpm_data-0.1.0/tests/test_storage.py +114 -0
- jcpm_data-0.1.0/tests/test_tasks.py +124 -0
- jcpm_data-0.1.0/tests/test_utils.py +63 -0
- jcpm_data-0.1.0/uv.lock +2534 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
jcpm_data-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|