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.
Files changed (45) hide show
  1. jcpm_data/__init__.py +9 -0
  2. jcpm_data/cli.py +283 -0
  3. jcpm_data/pipelines/__init__.py +130 -0
  4. jcpm_data/pipelines/bigquery/__init__.py +51 -0
  5. jcpm_data/pipelines/bigquery/config.py +50 -0
  6. jcpm_data/pipelines/bigquery/data.py +143 -0
  7. jcpm_data/pipelines/bigquery/destination.py +307 -0
  8. jcpm_data/pipelines/bigquery/infer.py +71 -0
  9. jcpm_data/pipelines/bigquery/options.py +89 -0
  10. jcpm_data/pipelines/bigquery/schema.py +139 -0
  11. jcpm_data/pipelines/config.py +91 -0
  12. jcpm_data/pipelines/context.py +87 -0
  13. jcpm_data/pipelines/dev.py +80 -0
  14. jcpm_data/pipelines/error_codes.py +117 -0
  15. jcpm_data/pipelines/errors.py +83 -0
  16. jcpm_data/pipelines/logger.py +101 -0
  17. jcpm_data/pipelines/observability/__init__.py +21 -0
  18. jcpm_data/pipelines/observability/events.py +137 -0
  19. jcpm_data/pipelines/observability/run.py +377 -0
  20. jcpm_data/pipelines/pipeline.py +178 -0
  21. jcpm_data/pipelines/quality.py +102 -0
  22. jcpm_data/pipelines/storage/__init__.py +6 -0
  23. jcpm_data/pipelines/storage/config.py +48 -0
  24. jcpm_data/pipelines/storage/datalake.py +212 -0
  25. jcpm_data/pipelines/utils/__init__.py +38 -0
  26. jcpm_data/pipelines/utils/dates.py +30 -0
  27. jcpm_data/pipelines/utils/env.py +91 -0
  28. jcpm_data/pipelines/utils/names.py +30 -0
  29. jcpm_data/pipelines/utils/runtime.py +62 -0
  30. jcpm_data/pipelines/utils/tasks.py +88 -0
  31. jcpm_data/pipelines/validators.py +41 -0
  32. jcpm_data/templates/project/.github/workflows/deploy.yml.jinja +21 -0
  33. jcpm_data/templates/project/README.md.jinja +31 -0
  34. jcpm_data/templates/project/copier.yml +52 -0
  35. jcpm_data/templates/project/deploy.yml.jinja +44 -0
  36. jcpm_data/templates/project/project/__init__.py +0 -0
  37. jcpm_data/templates/project/project/errors.py +15 -0
  38. jcpm_data/templates/project/project/main.py.jinja +64 -0
  39. jcpm_data/templates/project/project/sources/__init__.py +0 -0
  40. jcpm_data/templates/project/project/sources/example.py +130 -0
  41. jcpm_data/templates/project/pyproject.toml.jinja +19 -0
  42. jcpm_data-0.1.0.dist-info/METADATA +435 -0
  43. jcpm_data-0.1.0.dist-info/RECORD +45 -0
  44. jcpm_data-0.1.0.dist-info/WHEEL +4 -0
  45. 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
+ )