jcpm-data 0.1.0__py3-none-any.whl
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/__init__.py +9 -0
- jcpm_data/cli.py +283 -0
- jcpm_data/pipelines/__init__.py +130 -0
- jcpm_data/pipelines/bigquery/__init__.py +51 -0
- jcpm_data/pipelines/bigquery/config.py +50 -0
- jcpm_data/pipelines/bigquery/data.py +143 -0
- jcpm_data/pipelines/bigquery/destination.py +307 -0
- jcpm_data/pipelines/bigquery/infer.py +71 -0
- jcpm_data/pipelines/bigquery/options.py +89 -0
- jcpm_data/pipelines/bigquery/schema.py +139 -0
- jcpm_data/pipelines/config.py +91 -0
- jcpm_data/pipelines/context.py +87 -0
- jcpm_data/pipelines/dev.py +80 -0
- jcpm_data/pipelines/error_codes.py +117 -0
- jcpm_data/pipelines/errors.py +83 -0
- jcpm_data/pipelines/logger.py +101 -0
- jcpm_data/pipelines/observability/__init__.py +21 -0
- jcpm_data/pipelines/observability/events.py +137 -0
- jcpm_data/pipelines/observability/run.py +377 -0
- jcpm_data/pipelines/pipeline.py +178 -0
- jcpm_data/pipelines/quality.py +102 -0
- jcpm_data/pipelines/storage/__init__.py +6 -0
- jcpm_data/pipelines/storage/config.py +48 -0
- jcpm_data/pipelines/storage/datalake.py +212 -0
- jcpm_data/pipelines/utils/__init__.py +38 -0
- jcpm_data/pipelines/utils/dates.py +30 -0
- jcpm_data/pipelines/utils/env.py +91 -0
- jcpm_data/pipelines/utils/names.py +30 -0
- jcpm_data/pipelines/utils/runtime.py +62 -0
- jcpm_data/pipelines/utils/tasks.py +88 -0
- jcpm_data/pipelines/validators.py +41 -0
- jcpm_data/templates/project/.github/workflows/deploy.yml.jinja +21 -0
- jcpm_data/templates/project/README.md.jinja +31 -0
- jcpm_data/templates/project/copier.yml +52 -0
- jcpm_data/templates/project/deploy.yml.jinja +44 -0
- jcpm_data/templates/project/project/__init__.py +0 -0
- jcpm_data/templates/project/project/errors.py +15 -0
- jcpm_data/templates/project/project/main.py.jinja +64 -0
- jcpm_data/templates/project/project/sources/__init__.py +0 -0
- jcpm_data/templates/project/project/sources/example.py +130 -0
- jcpm_data/templates/project/pyproject.toml.jinja +19 -0
- jcpm_data-0.1.0.dist-info/METADATA +435 -0
- jcpm_data-0.1.0.dist-info/RECORD +45 -0
- jcpm_data-0.1.0.dist-info/WHEEL +4 -0
- jcpm_data-0.1.0.dist-info/entry_points.txt +2 -0
jcpm_data/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""jcpm_data — framework de carga de dados do Grupo JCPM.
|
|
2
|
+
|
|
3
|
+
A API de pipelines vive em ``jcpm_data.pipelines`` e é reexportada aqui por
|
|
4
|
+
conveniência (``from jcpm_data import Pipeline, ScopeConfig, ...``). O CLI de
|
|
5
|
+
scaffolding (``jcpm-data startproject``) está em ``jcpm_data.cli``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .pipelines import * # noqa: F401,F403
|
|
9
|
+
from .pipelines import __all__, __version__ # noqa: F401
|
jcpm_data/cli.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""CLI do framework — ``jcpm-data startproject`` (scaffolding via copier)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import unicodedata
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator, List, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "project"
|
|
17
|
+
|
|
18
|
+
_SKIP_DIRS = {".venv", "venv", "__pycache__", ".git", "node_modules", "dist", "build"}
|
|
19
|
+
|
|
20
|
+
SA_ACCOUNT_ID_MAX = 30
|
|
21
|
+
DEVOPS_PREFIX_RESERVED = 3
|
|
22
|
+
PROJECT_NAME_MAX = SA_ACCOUNT_ID_MAX - DEVOPS_PREFIX_RESERVED # 27
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _project_slug(name: str) -> str:
|
|
26
|
+
"""Slug snake_case do projeto: minúsculo, sem acento, só ``[a-z0-9_]``.
|
|
27
|
+
|
|
28
|
+
Ex.: ``"Vendas Diárias"`` -> ``"vendas_diarias"``. É o nome usado no
|
|
29
|
+
``deploy.yml`` e no ``pyproject.toml`` do projeto gerado.
|
|
30
|
+
"""
|
|
31
|
+
nfkd = unicodedata.normalize("NFKD", name)
|
|
32
|
+
without_accents = "".join(c for c in nfkd if not unicodedata.combining(c))
|
|
33
|
+
slug = re.sub(r"[^a-z0-9]+", "_", without_accents.lower())
|
|
34
|
+
return slug.strip("_")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _project_name_error(deploy_name: str) -> Optional[str]:
|
|
38
|
+
"""Erro se o nome não couber no limite da SA após o prefixo da devops."""
|
|
39
|
+
if len(deploy_name) > PROJECT_NAME_MAX:
|
|
40
|
+
return (
|
|
41
|
+
f"nome do projeto '{deploy_name}' tem {len(deploy_name)} caracteres; "
|
|
42
|
+
f"o máximo é {PROJECT_NAME_MAX} (a plataforma devops adiciona um prefixo "
|
|
43
|
+
f"e o account_id da service account no GCP tem {SA_ACCOUNT_ID_MAX} chars)."
|
|
44
|
+
)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def scaffold(
|
|
49
|
+
name: str,
|
|
50
|
+
location: Optional[str] = ".",
|
|
51
|
+
*,
|
|
52
|
+
scopes: Optional[Sequence[str]] = None,
|
|
53
|
+
interactive: bool = False,
|
|
54
|
+
) -> Path:
|
|
55
|
+
"""Cria um novo projeto a partir do template copier.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
name: nome do projeto (obrigatório).
|
|
59
|
+
location: diretório de destino. ``.``/vazio/``None`` usa o diretório atual.
|
|
60
|
+
scopes: escopos do projeto. O template **não** tem default — no modo
|
|
61
|
+
interativo o copier pergunta (multiselect, validando a exclusividade
|
|
62
|
+
de jcpm/external); no modo programático (padrão), sem ``scopes`` usa-se
|
|
63
|
+
um escopo mínimo (``['jcpm']``) para não exigir prompt.
|
|
64
|
+
interactive: se ``True``, o copier pergunta os campos sem resposta (os
|
|
65
|
+
escopos). Usado pelo comando ``startproject``.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
O ``Path`` do diretório onde o projeto foi criado.
|
|
69
|
+
"""
|
|
70
|
+
if not name:
|
|
71
|
+
raise ValueError("Nome do projeto é obrigatório.")
|
|
72
|
+
|
|
73
|
+
slug = _project_slug(name)
|
|
74
|
+
name_error = _project_name_error(slug)
|
|
75
|
+
if name_error:
|
|
76
|
+
raise ValueError(name_error)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
from copier import run_copy
|
|
80
|
+
except ImportError as exc: # pragma: no cover - depende do ambiente
|
|
81
|
+
raise SystemExit(
|
|
82
|
+
"O comando 'startproject' requer o copier. "
|
|
83
|
+
"Instale com: uv add 'jcpm-data[cli]' (ou pip install 'jcpm-data[cli]')."
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
dest = Path.cwd() if location in (".", "", None) else Path(location)
|
|
87
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
# Passa o slug (snake_case, sem acento) computado em Python — o Jinja do
|
|
90
|
+
# copier não remove acentos.
|
|
91
|
+
data: dict = {"project_name": name, "project_slug": slug}
|
|
92
|
+
if scopes:
|
|
93
|
+
data["scopes"] = list(scopes)
|
|
94
|
+
elif not interactive:
|
|
95
|
+
data["scopes"] = ["jcpm"]
|
|
96
|
+
|
|
97
|
+
run_copy(
|
|
98
|
+
str(TEMPLATE_DIR),
|
|
99
|
+
str(dest),
|
|
100
|
+
data=data,
|
|
101
|
+
defaults=not interactive,
|
|
102
|
+
quiet=not interactive,
|
|
103
|
+
)
|
|
104
|
+
return dest
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@click.group()
|
|
108
|
+
@click.version_option(package_name="jcpm-data", prog_name="jcpm-data")
|
|
109
|
+
def main() -> None:
|
|
110
|
+
"""CLI do framework jcpm-data."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@main.command(name="startproject")
|
|
114
|
+
@click.argument("name", required=False)
|
|
115
|
+
@click.argument("location", required=False)
|
|
116
|
+
def startproject(name: Optional[str], location: Optional[str]) -> None:
|
|
117
|
+
"""Cria um novo projeto a partir do template.
|
|
118
|
+
|
|
119
|
+
NAME é o nome do projeto; LOCATION o destino (. = diretório atual).
|
|
120
|
+
"""
|
|
121
|
+
if not name:
|
|
122
|
+
name = click.prompt("Nome do projeto")
|
|
123
|
+
if location is None:
|
|
124
|
+
location = click.prompt("Local do projeto", default=".")
|
|
125
|
+
|
|
126
|
+
# Interativo: o copier pergunta os escopos (multiselect), respeitando a
|
|
127
|
+
# exclusividade de jcpm/external. Sem default — o usuário escolhe.
|
|
128
|
+
dest = scaffold(name, location, interactive=True)
|
|
129
|
+
click.secho(f"Projeto '{name}' criado em {dest}", fg="green")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
# check
|
|
134
|
+
# --------------------------------------------------------------------------- #
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class CheckResult:
|
|
139
|
+
name: str
|
|
140
|
+
status: str # "ok" | "fail" | "skip"
|
|
141
|
+
detail: str = ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _py_files(root: Path) -> Iterator[Path]:
|
|
145
|
+
for path in root.rglob("*.py"):
|
|
146
|
+
rel = path.relative_to(root)
|
|
147
|
+
if any(part in _SKIP_DIRS or part.startswith(".") for part in rel.parts):
|
|
148
|
+
continue
|
|
149
|
+
yield path
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _module_name(root: Path, path: Path) -> str:
|
|
153
|
+
parts = list(path.relative_to(root).with_suffix("").parts)
|
|
154
|
+
if parts and parts[-1] == "__init__":
|
|
155
|
+
parts = parts[:-1]
|
|
156
|
+
return ".".join(parts)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _check_deploy_yaml(root: Path) -> CheckResult:
|
|
160
|
+
deploy = root / "deploy.yml"
|
|
161
|
+
if not deploy.exists():
|
|
162
|
+
return CheckResult("deploy.yml", "fail", "arquivo não encontrado")
|
|
163
|
+
if importlib.util.find_spec("yaml") is None: # pragma: no cover
|
|
164
|
+
return CheckResult("deploy.yml", "skip", "pyyaml indisponível")
|
|
165
|
+
import yaml
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
data = yaml.safe_load(deploy.read_text()) or {}
|
|
169
|
+
except yaml.YAMLError as exc:
|
|
170
|
+
return CheckResult("deploy.yml", "fail", f"YAML inválido: {exc}")
|
|
171
|
+
missing = [k for k in ("apiVersion", "name") if not data.get(k)]
|
|
172
|
+
if missing:
|
|
173
|
+
return CheckResult("deploy.yml", "fail", f"campos ausentes: {missing}")
|
|
174
|
+
name_error = _project_name_error(str(data["name"]))
|
|
175
|
+
if name_error:
|
|
176
|
+
return CheckResult("deploy.yml", "fail", name_error)
|
|
177
|
+
return CheckResult(
|
|
178
|
+
"deploy.yml", "ok", f"name={data['name']}, apiVersion={data['apiVersion']}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _check_syntax(root: Path) -> CheckResult:
|
|
183
|
+
errors = []
|
|
184
|
+
for path in _py_files(root):
|
|
185
|
+
try:
|
|
186
|
+
compile(path.read_text(), str(path), "exec")
|
|
187
|
+
except SyntaxError as exc:
|
|
188
|
+
errors.append(f"{path.relative_to(root)}:{exc.lineno}: {exc.msg}")
|
|
189
|
+
if errors:
|
|
190
|
+
return CheckResult("sintaxe", "fail", "; ".join(errors))
|
|
191
|
+
return CheckResult("sintaxe", "ok")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_imports(root: Path) -> CheckResult:
|
|
195
|
+
pkg = root / "project"
|
|
196
|
+
if not pkg.is_dir():
|
|
197
|
+
return CheckResult("imports", "skip", "pasta project/ não encontrada")
|
|
198
|
+
modules = sorted({_module_name(root, p) for p in _py_files(pkg)})
|
|
199
|
+
modules = [m for m in modules if m]
|
|
200
|
+
if not modules:
|
|
201
|
+
return CheckResult("imports", "skip", "nenhum módulo em project/")
|
|
202
|
+
code = "import importlib\n" + "\n".join(
|
|
203
|
+
f"importlib.import_module({m!r})" for m in modules
|
|
204
|
+
)
|
|
205
|
+
proc = subprocess.run(
|
|
206
|
+
[sys.executable, "-c", code],
|
|
207
|
+
cwd=str(root),
|
|
208
|
+
capture_output=True,
|
|
209
|
+
text=True,
|
|
210
|
+
timeout=120,
|
|
211
|
+
)
|
|
212
|
+
if proc.returncode != 0:
|
|
213
|
+
detail = (proc.stderr.strip().splitlines() or ["erro de import"])[-1]
|
|
214
|
+
return CheckResult("imports", "fail", detail)
|
|
215
|
+
return CheckResult("imports", "ok", f"{len(modules)} módulo(s)")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _check_lint(root: Path) -> CheckResult:
|
|
219
|
+
if importlib.util.find_spec("ruff") is None:
|
|
220
|
+
return CheckResult("lint (ruff)", "skip", "ruff não instalado")
|
|
221
|
+
proc = subprocess.run(
|
|
222
|
+
[sys.executable, "-m", "ruff", "check", str(root)],
|
|
223
|
+
cwd=str(root),
|
|
224
|
+
capture_output=True,
|
|
225
|
+
text=True,
|
|
226
|
+
timeout=120,
|
|
227
|
+
)
|
|
228
|
+
if proc.returncode != 0:
|
|
229
|
+
return CheckResult("lint (ruff)", "fail", proc.stdout.strip() or proc.stderr.strip())
|
|
230
|
+
return CheckResult("lint (ruff)", "ok")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_checks(path: str = ".") -> List[CheckResult]:
|
|
234
|
+
"""Roda todas as verificações sobre o projeto em ``path``."""
|
|
235
|
+
root = Path(path).resolve()
|
|
236
|
+
return [
|
|
237
|
+
_check_deploy_yaml(root),
|
|
238
|
+
_check_syntax(root),
|
|
239
|
+
_check_imports(root),
|
|
240
|
+
_check_lint(root),
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def run_project(path: str = ".") -> int:
|
|
245
|
+
"""Executa ``project/main.py`` do projeto (via ``python -m project.main``)."""
|
|
246
|
+
root = Path(path).resolve()
|
|
247
|
+
main_file = root / "project" / "main.py"
|
|
248
|
+
if not main_file.exists():
|
|
249
|
+
raise SystemExit(
|
|
250
|
+
f"'{main_file}' não encontrado — rode dentro de um projeto jcpm-data."
|
|
251
|
+
)
|
|
252
|
+
proc = subprocess.run([sys.executable, "-m", "project.main"], cwd=str(root))
|
|
253
|
+
return proc.returncode
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@main.command(name="run")
|
|
257
|
+
@click.option("--path", default=".", help="Diretório do projeto (default: atual).")
|
|
258
|
+
def run(path: str) -> None:
|
|
259
|
+
"""Executa o pipeline do projeto (`project/main.py`)."""
|
|
260
|
+
raise SystemExit(run_project(path))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@main.command()
|
|
264
|
+
@click.option("--path", default=".", help="Diretório do projeto (default: atual).")
|
|
265
|
+
def check(path: str) -> None:
|
|
266
|
+
"""Verifica o projeto: linter, estrutura e imports quebrados."""
|
|
267
|
+
colors = {"ok": "green", "fail": "red", "skip": "yellow"}
|
|
268
|
+
failed = False
|
|
269
|
+
for r in run_checks(path):
|
|
270
|
+
line = f"{r.name}: {r.status}"
|
|
271
|
+
if r.detail:
|
|
272
|
+
line += f" — {r.detail}"
|
|
273
|
+
click.secho(line, fg=colors[r.status])
|
|
274
|
+
failed = failed or r.status == "fail"
|
|
275
|
+
|
|
276
|
+
if failed:
|
|
277
|
+
click.secho("\nFalhas encontradas.", fg="red", bold=True)
|
|
278
|
+
raise SystemExit(1)
|
|
279
|
+
click.secho("\nTudo certo.", fg="green", bold=True)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__": # pragma: no cover
|
|
283
|
+
main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from .bigquery import (
|
|
2
|
+
PARTITION,
|
|
3
|
+
SCHEMA_POLICY,
|
|
4
|
+
WRITE_MODE,
|
|
5
|
+
BigQueryConfig,
|
|
6
|
+
Column,
|
|
7
|
+
DataSource,
|
|
8
|
+
Partition,
|
|
9
|
+
SchemaPolicy,
|
|
10
|
+
WriteMode,
|
|
11
|
+
data,
|
|
12
|
+
infer_schema,
|
|
13
|
+
)
|
|
14
|
+
from .config import (
|
|
15
|
+
ScopeConfig,
|
|
16
|
+
ScopeName,
|
|
17
|
+
build_table_name,
|
|
18
|
+
ensure_scope,
|
|
19
|
+
is_scope,
|
|
20
|
+
)
|
|
21
|
+
from .context import Context, get_scope_from_context
|
|
22
|
+
from .error_codes import ErrorCatalog, ErrorCode, PipelineError
|
|
23
|
+
from .quality import PrimaryKeyCheck, check_primary_key
|
|
24
|
+
from .errors import (
|
|
25
|
+
ColumnNameValidationError,
|
|
26
|
+
DatePartitionValidationError,
|
|
27
|
+
EnvVarNotFoundError,
|
|
28
|
+
IdentifierValidationError,
|
|
29
|
+
JcpmDataError,
|
|
30
|
+
PrimaryKeyValidationError,
|
|
31
|
+
SchemaValidationError,
|
|
32
|
+
ScopeValidationError,
|
|
33
|
+
TaskIndexError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
)
|
|
36
|
+
from .logger import log, logger
|
|
37
|
+
from .observability import (
|
|
38
|
+
Event,
|
|
39
|
+
EventType,
|
|
40
|
+
PipelineRun,
|
|
41
|
+
RunSummary,
|
|
42
|
+
Severity,
|
|
43
|
+
Status,
|
|
44
|
+
)
|
|
45
|
+
from .pipeline import Pipeline
|
|
46
|
+
from .storage import DataLake, StorageConfig
|
|
47
|
+
from .utils import (
|
|
48
|
+
env_var_name,
|
|
49
|
+
get_env,
|
|
50
|
+
get_object_by_task_id,
|
|
51
|
+
get_scope_by_task_id,
|
|
52
|
+
load_env,
|
|
53
|
+
range_dates,
|
|
54
|
+
resolve_task_index,
|
|
55
|
+
stand_names,
|
|
56
|
+
)
|
|
57
|
+
from .validators import validate_identifier
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.0"
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
# config
|
|
63
|
+
"ScopeConfig",
|
|
64
|
+
"ScopeName",
|
|
65
|
+
"BigQueryConfig",
|
|
66
|
+
"build_table_name",
|
|
67
|
+
"ensure_scope",
|
|
68
|
+
"is_scope",
|
|
69
|
+
# env
|
|
70
|
+
"get_env",
|
|
71
|
+
"env_var_name",
|
|
72
|
+
"load_env",
|
|
73
|
+
# context
|
|
74
|
+
"Context",
|
|
75
|
+
"get_scope_from_context",
|
|
76
|
+
# tasks (Cloud Run Jobs)
|
|
77
|
+
"get_object_by_task_id",
|
|
78
|
+
"get_scope_by_task_id",
|
|
79
|
+
"resolve_task_index",
|
|
80
|
+
# log
|
|
81
|
+
"logger",
|
|
82
|
+
"log",
|
|
83
|
+
# data API (@data / BigQuery)
|
|
84
|
+
"data",
|
|
85
|
+
"DataSource",
|
|
86
|
+
"Column",
|
|
87
|
+
"WriteMode",
|
|
88
|
+
"WRITE_MODE",
|
|
89
|
+
"Partition",
|
|
90
|
+
"PARTITION",
|
|
91
|
+
"SchemaPolicy",
|
|
92
|
+
"SCHEMA_POLICY",
|
|
93
|
+
# dev / qualidade
|
|
94
|
+
"infer_schema",
|
|
95
|
+
"check_primary_key",
|
|
96
|
+
"PrimaryKeyCheck",
|
|
97
|
+
# utils extras
|
|
98
|
+
"range_dates",
|
|
99
|
+
"stand_names",
|
|
100
|
+
# storage (GCS)
|
|
101
|
+
"StorageConfig",
|
|
102
|
+
"DataLake",
|
|
103
|
+
# pipeline
|
|
104
|
+
"Pipeline",
|
|
105
|
+
# observabilidade (eventos padronizados)
|
|
106
|
+
"PipelineRun",
|
|
107
|
+
"EventType",
|
|
108
|
+
"Status",
|
|
109
|
+
"Severity",
|
|
110
|
+
"Event",
|
|
111
|
+
"RunSummary",
|
|
112
|
+
# códigos de erro do projeto
|
|
113
|
+
"ErrorCatalog",
|
|
114
|
+
"ErrorCode",
|
|
115
|
+
"PipelineError",
|
|
116
|
+
# validators
|
|
117
|
+
"validate_identifier",
|
|
118
|
+
# errors
|
|
119
|
+
"JcpmDataError",
|
|
120
|
+
"ValidationError",
|
|
121
|
+
"EnvVarNotFoundError",
|
|
122
|
+
"TaskIndexError",
|
|
123
|
+
"ScopeValidationError",
|
|
124
|
+
"IdentifierValidationError",
|
|
125
|
+
"SchemaValidationError",
|
|
126
|
+
"ColumnNameValidationError",
|
|
127
|
+
"DatePartitionValidationError",
|
|
128
|
+
"PrimaryKeyValidationError",
|
|
129
|
+
"__version__",
|
|
130
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Pacote BigQuery — config do destino, schema e a API ``@data``."""
|
|
2
|
+
|
|
3
|
+
from .config import BigQueryConfig
|
|
4
|
+
from .data import DataSource, data, validate_datasource
|
|
5
|
+
from .destination import (
|
|
6
|
+
build_create_table_ddl,
|
|
7
|
+
build_data_resource,
|
|
8
|
+
build_destination,
|
|
9
|
+
build_dlt_pipeline,
|
|
10
|
+
ensure_partitioned_table,
|
|
11
|
+
)
|
|
12
|
+
from .infer import infer_schema
|
|
13
|
+
from .options import (
|
|
14
|
+
DATE_PARTITION_COLUMN,
|
|
15
|
+
PARTITION,
|
|
16
|
+
SCHEMA_POLICY,
|
|
17
|
+
WRITE_MODE,
|
|
18
|
+
Partition,
|
|
19
|
+
SchemaPolicy,
|
|
20
|
+
WriteMode,
|
|
21
|
+
partition_sql,
|
|
22
|
+
)
|
|
23
|
+
from .schema import Column, cast_to_schema, columns_to_dlt
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# config / destino
|
|
27
|
+
"BigQueryConfig",
|
|
28
|
+
"build_destination",
|
|
29
|
+
"build_dlt_pipeline",
|
|
30
|
+
"build_data_resource",
|
|
31
|
+
# API @data
|
|
32
|
+
"data",
|
|
33
|
+
"DataSource",
|
|
34
|
+
"validate_datasource",
|
|
35
|
+
"Column",
|
|
36
|
+
"columns_to_dlt",
|
|
37
|
+
"cast_to_schema",
|
|
38
|
+
# dev helper
|
|
39
|
+
"infer_schema",
|
|
40
|
+
# opções
|
|
41
|
+
"WriteMode",
|
|
42
|
+
"WRITE_MODE",
|
|
43
|
+
"Partition",
|
|
44
|
+
"PARTITION",
|
|
45
|
+
"SchemaPolicy",
|
|
46
|
+
"SCHEMA_POLICY",
|
|
47
|
+
"DATE_PARTITION_COLUMN",
|
|
48
|
+
"partition_sql",
|
|
49
|
+
"build_create_table_ddl",
|
|
50
|
+
"ensure_partitioned_table",
|
|
51
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Configuração do destino BigQuery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BigQueryConfig(BaseModel):
|
|
12
|
+
"""Config do destino BigQuery.
|
|
13
|
+
|
|
14
|
+
Os defaults são derivados do ambiente de execução (``.env`` / variáveis de
|
|
15
|
+
ambiente / ADC) em vez de fixados no código.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@model_validator(mode="before")
|
|
19
|
+
@classmethod
|
|
20
|
+
def _load_dotenv(cls, data):
|
|
21
|
+
# Carrega o .env mais próximo para que os defaults (e os.environ do
|
|
22
|
+
# consumidor) enxerguem as variáveis — idempotente.
|
|
23
|
+
from ..utils.env import load_env
|
|
24
|
+
|
|
25
|
+
load_env()
|
|
26
|
+
return data
|
|
27
|
+
|
|
28
|
+
project_id: Optional[str] = Field(
|
|
29
|
+
default_factory=lambda: os.getenv("JCPM_BQ_PROJECT_ID")
|
|
30
|
+
or os.getenv("GOOGLE_CLOUD_PROJECT"),
|
|
31
|
+
description="ID do projeto GCP. Se nulo, usa credenciais implícitas (ADC).",
|
|
32
|
+
)
|
|
33
|
+
location: str = Field(
|
|
34
|
+
default_factory=lambda: os.getenv("JCPM_BQ_LOCATION", "us-central1"),
|
|
35
|
+
description="Região do dataset no BigQuery",
|
|
36
|
+
)
|
|
37
|
+
dataset_name: str = Field(
|
|
38
|
+
default_factory=lambda: os.getenv("JCPM_BQ_DATASET", "raw"),
|
|
39
|
+
description="Dataset de destino (camada). Default: raw",
|
|
40
|
+
)
|
|
41
|
+
truncate_staging: bool = Field(
|
|
42
|
+
default_factory=lambda: os.getenv("JCPM_BQ_TRUNCATE_STAGING", "true").lower()
|
|
43
|
+
!= "false",
|
|
44
|
+
description="Limpa (trunca) o dataset de staging após a carga. Default: True.",
|
|
45
|
+
)
|
|
46
|
+
add_load_id: bool = Field(
|
|
47
|
+
default_factory=lambda: os.getenv("JCPM_BQ_ADD_LOAD_ID", "true").lower()
|
|
48
|
+
!= "false",
|
|
49
|
+
description="Adiciona a coluna '_dlt_load_id' (id da carga) na tabela final. Default: True.",
|
|
50
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Decorator ``@data`` e o objeto ``DataSource`` que carrega a config de carga.
|
|
2
|
+
|
|
3
|
+
Diferente do ``@source`` clássico (handler retorna ``Data``), aqui toda a
|
|
4
|
+
configuração de gravação vive no decorator e o handler apenas produz os registros
|
|
5
|
+
brutos (``yield`` de lotes). Ex.:
|
|
6
|
+
|
|
7
|
+
@data(source="raw_crm", resource="usuarios", write_mode=WRITE_MODE.MERGE,
|
|
8
|
+
primary_key=["id"], partition_by=PARTITION.DAY, schema=USERS_SCHEMA)
|
|
9
|
+
def extract_crm_users(context):
|
|
10
|
+
yield api.get_users()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Callable, List, Optional, Union
|
|
17
|
+
|
|
18
|
+
from ..errors import (
|
|
19
|
+
DatePartitionValidationError,
|
|
20
|
+
PrimaryKeyValidationError,
|
|
21
|
+
SchemaValidationError,
|
|
22
|
+
)
|
|
23
|
+
from ..validators import validate_identifier
|
|
24
|
+
from .options import (
|
|
25
|
+
DATE_PARTITION_COLUMN,
|
|
26
|
+
Partition,
|
|
27
|
+
SchemaPolicy,
|
|
28
|
+
WriteMode,
|
|
29
|
+
needs_custom_partitioning,
|
|
30
|
+
)
|
|
31
|
+
from .schema import Column, schema_column_names
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _as_list(value: Optional[Union[str, List[str]]]) -> Optional[List[str]]:
|
|
35
|
+
if value is None:
|
|
36
|
+
return None
|
|
37
|
+
return [value] if isinstance(value, str) else list(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class DataSource:
|
|
42
|
+
"""Fonte de dados + configuração de carga no BigQuery."""
|
|
43
|
+
|
|
44
|
+
handler: Callable
|
|
45
|
+
source: str
|
|
46
|
+
resource: str
|
|
47
|
+
write_mode: WriteMode = WriteMode.APPEND
|
|
48
|
+
primary_key: Optional[List[str]] = None
|
|
49
|
+
partition_by: Optional[Partition] = None
|
|
50
|
+
cluster_by: Optional[List[str]] = None
|
|
51
|
+
cursor_field: Optional[str] = None
|
|
52
|
+
schema: Optional[List[Column]] = None
|
|
53
|
+
schema_policy: Optional[SchemaPolicy] = None
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
self.primary_key = _as_list(self.primary_key)
|
|
57
|
+
self.cluster_by = _as_list(self.cluster_by)
|
|
58
|
+
|
|
59
|
+
def __call__(self, context):
|
|
60
|
+
"""Executa o handler — DataSource é usável como o próprio callable."""
|
|
61
|
+
return self.handler(context)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def data(
|
|
65
|
+
*,
|
|
66
|
+
source: str,
|
|
67
|
+
resource: str,
|
|
68
|
+
write_mode: WriteMode = WriteMode.APPEND,
|
|
69
|
+
primary_key: Optional[Union[str, List[str]]] = None,
|
|
70
|
+
partition_by: Optional[Partition] = None,
|
|
71
|
+
cluster_by: Optional[Union[str, List[str]]] = None,
|
|
72
|
+
cursor_field: Optional[str] = None,
|
|
73
|
+
schema: Optional[List[Column]] = None,
|
|
74
|
+
schema_policy: Optional[SchemaPolicy] = None,
|
|
75
|
+
) -> Callable[[Callable], DataSource]:
|
|
76
|
+
"""Decorator que transforma um handler em um :class:`DataSource`."""
|
|
77
|
+
|
|
78
|
+
def decorator(handler: Callable) -> DataSource:
|
|
79
|
+
return DataSource(
|
|
80
|
+
handler=handler,
|
|
81
|
+
source=source,
|
|
82
|
+
resource=resource,
|
|
83
|
+
write_mode=write_mode,
|
|
84
|
+
primary_key=primary_key,
|
|
85
|
+
partition_by=partition_by,
|
|
86
|
+
cluster_by=cluster_by,
|
|
87
|
+
cursor_field=cursor_field,
|
|
88
|
+
schema=schema,
|
|
89
|
+
schema_policy=schema_policy,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return decorator
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_datasource(ds: DataSource) -> None:
|
|
96
|
+
"""Valida a configuração de um ``DataSource`` (fail-fast antes da carga)."""
|
|
97
|
+
cols = schema_column_names(ds.schema) if ds.schema else None
|
|
98
|
+
|
|
99
|
+
# Nomes de coluna do schema devem ser identificadores válidos (snake_case).
|
|
100
|
+
for name in cols or []:
|
|
101
|
+
validate_identifier(name, field="coluna")
|
|
102
|
+
|
|
103
|
+
# MERGE exige primary_key.
|
|
104
|
+
if ds.write_mode is WriteMode.MERGE and not ds.primary_key:
|
|
105
|
+
raise PrimaryKeyValidationError(
|
|
106
|
+
"write_mode=MERGE exige 'primary_key'."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# REPLACE_PARTITION substitui a partição via particao_data — exige partition_by.
|
|
110
|
+
if ds.write_mode is WriteMode.REPLACE_PARTITION and ds.partition_by is None:
|
|
111
|
+
raise DatePartitionValidationError(
|
|
112
|
+
"write_mode=REPLACE_PARTITION exige 'partition_by' "
|
|
113
|
+
"(a partição é substituída pela coluna particao_data)."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Partição: a coluna é sempre 'particao_data' e deve existir no schema.
|
|
117
|
+
if ds.partition_by is not None and cols is not None:
|
|
118
|
+
if DATE_PARTITION_COLUMN not in cols:
|
|
119
|
+
raise DatePartitionValidationError(
|
|
120
|
+
f"a coluna de partição '{DATE_PARTITION_COLUMN}' não está no schema. "
|
|
121
|
+
f"Colunas: {cols}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Granularidade não-diária (MONTH/YEAR/HOUR) exige schema para pré-criar a
|
|
125
|
+
# tabela com DATE_TRUNC (o dlt só particiona por DAY nativamente).
|
|
126
|
+
if needs_custom_partitioning(ds.partition_by) and not ds.schema:
|
|
127
|
+
raise DatePartitionValidationError(
|
|
128
|
+
f"particionamento por {ds.partition_by.name} exige 'schema' "
|
|
129
|
+
f"(para criar a tabela particionada corretamente no BigQuery)."
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Colunas referenciadas devem existir no schema (quando schema informado).
|
|
133
|
+
if cols is not None:
|
|
134
|
+
for field_name, ref in (("primary_key", ds.primary_key), ("cluster_by", ds.cluster_by)):
|
|
135
|
+
for c in ref or []:
|
|
136
|
+
if c not in cols:
|
|
137
|
+
raise SchemaValidationError(
|
|
138
|
+
f"{field_name}: coluna '{c}' não está no schema. Colunas: {cols}"
|
|
139
|
+
)
|
|
140
|
+
if ds.cursor_field and ds.cursor_field not in cols:
|
|
141
|
+
raise SchemaValidationError(
|
|
142
|
+
f"cursor_field '{ds.cursor_field}' não está no schema. Colunas: {cols}"
|
|
143
|
+
)
|